mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff | |||
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b |
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"com.zarz.spotiflac/download_progress_stream"
|
"com.zarz.spotiflac/download_progress_stream"
|
||||||
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
||||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||||
private val STREAM_POLLING_INTERVAL_MS = 1200L
|
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||||
|
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
@@ -55,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private var flutterBackCallback: OnBackPressedCallback? = null
|
private var flutterBackCallback: OnBackPressedCallback? = null
|
||||||
@Volatile private var safScanCancel = false
|
@Volatile private var safScanCancel = false
|
||||||
@Volatile private var safScanActive = false
|
@Volatile private var safScanActive = false
|
||||||
|
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
|
||||||
|
@Volatile private var procSelfFdReadable: Boolean? = null
|
||||||
private val safTreeLauncher = registerForActivityResult(
|
private val safTreeLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) { activityResult ->
|
) { activityResult ->
|
||||||
@@ -374,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
synchronized(safScanLock) {
|
synchronized(safScanLock) {
|
||||||
safScanProgress = SafScanProgress()
|
safScanProgress = SafScanProgress()
|
||||||
}
|
}
|
||||||
|
// Allow re-probing /proc/self/fd readability on every new scan session.
|
||||||
|
procSelfFdReadable = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
|
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
|
||||||
@@ -454,7 +459,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"Download progress stream poll failed: ${e.message}",
|
"Download progress stream poll failed: ${e.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
delay(STREAM_POLLING_INTERVAL_MS)
|
delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,6 +476,18 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
libraryScanProgressEventSink = sink
|
libraryScanProgressEventSink = sink
|
||||||
lastLibraryScanProgressPayload = null
|
lastLibraryScanProgressPayload = null
|
||||||
libraryScanProgressStreamJob = scope.launch {
|
libraryScanProgressStreamJob = scope.launch {
|
||||||
|
try {
|
||||||
|
val initialPayload = withContext(Dispatchers.IO) {
|
||||||
|
readLibraryScanProgressJsonForStream()
|
||||||
|
}
|
||||||
|
lastLibraryScanProgressPayload = initialPayload
|
||||||
|
sink.success(parseJsonPayload(initialPayload))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Library scan progress initial poll failed: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
while (isActive && libraryScanProgressEventSink === sink) {
|
while (isActive && libraryScanProgressEventSink === sink) {
|
||||||
try {
|
try {
|
||||||
val payload = withContext(Dispatchers.IO) {
|
val payload = withContext(Dispatchers.IO) {
|
||||||
@@ -486,7 +503,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"Library scan progress stream poll failed: ${e.message}",
|
"Library scan progress stream poll failed: ${e.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
delay(STREAM_POLLING_INTERVAL_MS)
|
delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,29 +794,59 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
|
||||||
|
val normalizedPath = stablePath.trim()
|
||||||
|
if (normalizedPath.isEmpty()) return ""
|
||||||
|
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
|
||||||
|
}
|
||||||
|
|
||||||
private fun readAudioMetadataFromUri(
|
private fun readAudioMetadataFromUri(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
displayNameHint: String? = null,
|
displayNameHint: String? = null,
|
||||||
fallbackExt: String? = null,
|
fallbackExt: String? = null,
|
||||||
|
coverCacheKey: String = "",
|
||||||
): JSONObject? {
|
): JSONObject? {
|
||||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||||
|
|
||||||
try {
|
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
|
||||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
if (procSelfFdReadable != false) {
|
||||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
try {
|
||||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
|
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||||
if (metadataJson.isNotBlank()) {
|
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||||
val obj = JSONObject(metadataJson)
|
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||||
if (!obj.has("error")) {
|
directPath,
|
||||||
return obj
|
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 {
|
val tempPath = try {
|
||||||
@@ -813,7 +860,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
} ?: return null
|
} ?: return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
|
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||||
|
tempPath,
|
||||||
|
displayName,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if (metadataJson.isBlank()) return null
|
if (metadataJson.isBlank()) return null
|
||||||
val obj = JSONObject(metadataJson)
|
val obj = JSONObject(metadataJson)
|
||||||
return if (obj.has("error")) null else obj
|
return if (obj.has("error")) null else obj
|
||||||
@@ -1190,6 +1241,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
|
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() }
|
||||||
|
val coverCacheKey = buildLibraryCoverCacheKey(
|
||||||
|
audioDoc.uri.toString(),
|
||||||
|
audioLastModified,
|
||||||
|
)
|
||||||
|
|
||||||
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||||
if (tempAudioPath == null) {
|
if (tempAudioPath == null) {
|
||||||
@@ -1208,11 +1264,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
||||||
|
|
||||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
|
||||||
tempCuePath,
|
tempCuePath,
|
||||||
tempDir,
|
tempDir,
|
||||||
cueDoc.uri.toString(),
|
cueDoc.uri.toString(),
|
||||||
cueLastModified
|
cueLastModified,
|
||||||
|
coverCacheKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
val cueArray = JSONArray(cueResultsJson)
|
val cueArray = JSONArray(cueResultsJson)
|
||||||
@@ -1264,13 +1321,19 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||||
|
val stableUri = doc.uri.toString()
|
||||||
|
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
|
||||||
|
val metadataObj = readAudioMetadataFromUri(
|
||||||
|
doc.uri,
|
||||||
|
name,
|
||||||
|
fallbackExt,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if (metadataObj == null) {
|
if (metadataObj == null) {
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
|
||||||
val stableUri = doc.uri.toString()
|
|
||||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||||
metadataObj.put("filePath", stableUri)
|
metadataObj.put("filePath", stableUri)
|
||||||
metadataObj.put("fileModTime", lastModified)
|
metadataObj.put("fileModTime", lastModified)
|
||||||
@@ -1538,6 +1601,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
|
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified }
|
||||||
|
val coverCacheKey = buildLibraryCoverCacheKey(
|
||||||
|
audioDoc.uri.toString(),
|
||||||
|
audioLastModified,
|
||||||
|
)
|
||||||
|
|
||||||
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
|
||||||
if (tempAudioPath == null) {
|
if (tempAudioPath == null) {
|
||||||
@@ -1554,11 +1622,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
tempAudioPath = renamedAudio.absolutePath
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
|
||||||
tempCuePath,
|
tempCuePath,
|
||||||
tempDir,
|
tempDir,
|
||||||
cueDoc.uri.toString(),
|
cueDoc.uri.toString(),
|
||||||
cueLastModified
|
cueLastModified,
|
||||||
|
coverCacheKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
val cueArray = JSONArray(cueResultsJson)
|
val cueArray = JSONArray(cueResultsJson)
|
||||||
@@ -1655,13 +1724,19 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||||
|
val stableUri = doc.uri.toString()
|
||||||
|
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified)
|
||||||
|
val metadataObj = readAudioMetadataFromUri(
|
||||||
|
doc.uri,
|
||||||
|
name,
|
||||||
|
fallbackExt,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if (metadataObj == null) {
|
if (metadataObj == null) {
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
|
||||||
val stableUri = doc.uri.toString()
|
|
||||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||||
metadataObj.put("filePath", stableUri)
|
metadataObj.put("filePath", stableUri)
|
||||||
metadataObj.put("fileModTime", safeLastModified)
|
metadataObj.put("fileModTime", safeLastModified)
|
||||||
@@ -1941,13 +2016,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"parseSpotifyUrl" -> {
|
|
||||||
val url = call.argument<String>("url") ?: ""
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.parseSpotifyURL(url)
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -2365,11 +2433,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return@withContext obj.toString()
|
return@withContext obj.toString()
|
||||||
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
|
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
|
||||||
}
|
}
|
||||||
// FLAC: Go wrote directly to temp, copy back now
|
// FLAC: Go wrote directly to temp, copy back now
|
||||||
if (!writeUriFromPath(uri, tempPath)) {
|
if (!writeUriFromPath(uri, tempPath)) {
|
||||||
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
}
|
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||||
raw
|
}
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
raw
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
throw e
|
throw e
|
||||||
@@ -2711,13 +2781,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"getSpotifyMetadataWithFallback" -> {
|
|
||||||
val url = call.argument<String>("url") ?: ""
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"checkAvailabilityFromDeezerID" -> {
|
"checkAvailabilityFromDeezerID" -> {
|
||||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -980,6 +980,8 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
|
artistValues := make([]string, 0, 1)
|
||||||
|
albumArtistValues := make([]string, 0, 1)
|
||||||
|
|
||||||
// Read vendor string length
|
// Read vendor string length
|
||||||
var vendorLen uint32
|
var vendorLen uint32
|
||||||
@@ -1034,9 +1036,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "TITLE":
|
case "TITLE":
|
||||||
metadata.Title = value
|
metadata.Title = value
|
||||||
case "ARTIST":
|
case "ARTIST":
|
||||||
metadata.Artist = value
|
artistValues = append(artistValues, value)
|
||||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||||
metadata.AlbumArtist = value
|
albumArtistValues = append(albumArtistValues, value)
|
||||||
case "ALBUM":
|
case "ALBUM":
|
||||||
metadata.Album = value
|
metadata.Album = value
|
||||||
case "DATE", "YEAR":
|
case "DATE", "YEAR":
|
||||||
@@ -1066,6 +1068,13 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(artistValues) > 0 {
|
||||||
|
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||||
|
}
|
||||||
|
if len(albumArtistValues) > 0 {
|
||||||
|
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||||
@@ -1620,14 +1629,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||||
|
explicitKey = strings.TrimSpace(explicitKey)
|
||||||
|
if explicitKey != "" {
|
||||||
|
return explicitKey
|
||||||
|
}
|
||||||
|
|
||||||
cacheKey := filePath
|
cacheKey := filePath
|
||||||
if stat, err := os.Stat(filePath); err == nil {
|
if stat, err := os.Stat(filePath); err == nil {
|
||||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||||
}
|
}
|
||||||
|
return cacheKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
hash := hashString(cacheKey)
|
hash := hashString(cacheKey)
|
||||||
|
|
||||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,11 @@ type CueSheet struct {
|
|||||||
|
|
||||||
// CueTrack represents a single track in a cue sheet
|
// CueTrack represents a single track in a cue sheet
|
||||||
type CueTrack struct {
|
type CueTrack struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
|
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||||
@@ -433,6 +433,17 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
|||||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||||
|
cuePath,
|
||||||
|
audioDir,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
sheet, err := ParseCueFile(cuePath)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -441,7 +452,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
|
return scanCueSheetForLibrary(
|
||||||
|
cuePath,
|
||||||
|
sheet,
|
||||||
|
audioPath,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||||
@@ -459,7 +478,7 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
|
|||||||
return audioPath, nil
|
return audioPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
if sheet == nil {
|
if sheet == nil {
|
||||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
}
|
}
|
||||||
@@ -492,7 +511,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" {
|
if coverCacheDir != "" {
|
||||||
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
audioPath,
|
||||||
|
"",
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if err == nil && cp != "" {
|
if err == nil && cp != "" {
|
||||||
coverPath = cp
|
coverPath = cp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
if deezerID != "" {
|
if deezerID != "" {
|
||||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||||
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
if resolvedID != "" {
|
if resolvedID != "" {
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
// Fall through to ISRC search instead of using wrong track.
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
} else {
|
} else {
|
||||||
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
if resolvedID != "" {
|
if resolvedID != "" {
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
@@ -260,9 +260,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
|||||||
return nil // Can't verify — don't block the download.
|
return nil // Can't verify — don't block the download.
|
||||||
}
|
}
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: trackResp.Track.Name,
|
Title: trackResp.Track.Name,
|
||||||
ArtistName: trackResp.Track.Artists,
|
ArtistName: trackResp.Track.Artists,
|
||||||
Duration: trackResp.Track.DurationMS / 1000,
|
ISRC: trackResp.Track.ISRC,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
SkipNameVerification: skipNameVerification,
|
||||||
}
|
}
|
||||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
@@ -522,18 +524,19 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
TrackNumber: req.TrackNumber,
|
Date: req.ReleaseDate,
|
||||||
TotalTracks: req.TotalTracks,
|
TrackNumber: req.TrackNumber,
|
||||||
DiscNumber: req.DiscNumber,
|
TotalTracks: req.TotalTracks,
|
||||||
ISRC: req.ISRC,
|
DiscNumber: req.DiscNumber,
|
||||||
Genre: req.Genre,
|
ISRC: req.ISRC,
|
||||||
Label: req.Label,
|
Genre: req.Genre,
|
||||||
Copyright: req.Copyright,
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
|
|||||||
+377
-188
@@ -13,25 +13,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseSpotifyURL(url string) (string, error) {
|
|
||||||
parsed, err := parseSpotifyURI(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]string{
|
|
||||||
"type": parsed.Type,
|
|
||||||
"id": parsed.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
@@ -68,6 +49,7 @@ type DownloadRequest struct {
|
|||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
EmbedMetadata bool `json:"embed_metadata"`
|
EmbedMetadata bool `json:"embed_metadata"`
|
||||||
|
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
@@ -135,6 +117,314 @@ type DownloadResult struct {
|
|||||||
DecryptionKey string
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reEnrichRequest struct {
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
MaxQuality bool `json:"max_quality"`
|
||||||
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
|
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
SearchOnline bool `json:"search_online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.SpotifyID != "" {
|
||||||
|
req.SpotifyID = track.SpotifyID
|
||||||
|
} else if track.DeezerID != "" {
|
||||||
|
req.SpotifyID = "deezer:" + track.DeezerID
|
||||||
|
} else if track.QobuzID != "" {
|
||||||
|
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||||
|
} else if track.TidalID != "" {
|
||||||
|
req.SpotifyID = "tidal:" + track.TidalID
|
||||||
|
} else if track.ID != "" {
|
||||||
|
req.SpotifyID = track.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.AlbumName != "" {
|
||||||
|
req.AlbumName = track.AlbumName
|
||||||
|
}
|
||||||
|
if track.AlbumArtist != "" {
|
||||||
|
req.AlbumArtist = track.AlbumArtist
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 {
|
||||||
|
req.TrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 {
|
||||||
|
req.DiscNumber = track.DiscNumber
|
||||||
|
}
|
||||||
|
if track.ReleaseDate != "" {
|
||||||
|
req.ReleaseDate = track.ReleaseDate
|
||||||
|
}
|
||||||
|
if track.ISRC != "" {
|
||||||
|
req.ISRC = track.ISRC
|
||||||
|
}
|
||||||
|
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
||||||
|
req.CoverURL = coverURL
|
||||||
|
}
|
||||||
|
if track.DurationMS > 0 {
|
||||||
|
req.DurationMs = int64(track.DurationMS)
|
||||||
|
}
|
||||||
|
if track.Genre != "" {
|
||||||
|
req.Genre = track.Genre
|
||||||
|
}
|
||||||
|
if track.Label != "" {
|
||||||
|
req.Label = track.Label
|
||||||
|
}
|
||||||
|
if track.Copyright != "" {
|
||||||
|
req.Copyright = track.Copyright
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||||
|
return DownloadRequest{
|
||||||
|
TrackName: req.TrackName,
|
||||||
|
ArtistName: req.ArtistName,
|
||||||
|
AlbumName: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
DurationMS: int(req.DurationMs),
|
||||||
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string {
|
||||||
|
metadata := map[string]string{}
|
||||||
|
if req.TrackName != "" {
|
||||||
|
metadata["TITLE"] = req.TrackName
|
||||||
|
}
|
||||||
|
if req.ArtistName != "" {
|
||||||
|
metadata["ARTIST"] = req.ArtistName
|
||||||
|
}
|
||||||
|
if req.AlbumName != "" {
|
||||||
|
metadata["ALBUM"] = req.AlbumName
|
||||||
|
}
|
||||||
|
if req.AlbumArtist != "" {
|
||||||
|
metadata["ALBUMARTIST"] = req.AlbumArtist
|
||||||
|
}
|
||||||
|
if req.ReleaseDate != "" {
|
||||||
|
metadata["DATE"] = req.ReleaseDate
|
||||||
|
}
|
||||||
|
if req.ISRC != "" {
|
||||||
|
metadata["ISRC"] = req.ISRC
|
||||||
|
}
|
||||||
|
if req.Genre != "" {
|
||||||
|
metadata["GENRE"] = req.Genre
|
||||||
|
}
|
||||||
|
if req.TrackNumber > 0 {
|
||||||
|
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
||||||
|
}
|
||||||
|
if req.DiscNumber > 0 {
|
||||||
|
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
||||||
|
}
|
||||||
|
if req.Label != "" {
|
||||||
|
metadata["ORGANIZATION"] = req.Label
|
||||||
|
}
|
||||||
|
if req.Copyright != "" {
|
||||||
|
metadata["COPYRIGHT"] = req.Copyright
|
||||||
|
}
|
||||||
|
if lyricsLRC != "" {
|
||||||
|
metadata["LYRICS"] = lyricsLRC
|
||||||
|
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadReq := reEnrichDownloadRequest(req)
|
||||||
|
currentISRC := strings.TrimSpace(req.ISRC)
|
||||||
|
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||||
|
var best *ExtTrackMetadata
|
||||||
|
bestScore := -1 << 30
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
track := &tracks[i]
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
score += 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
||||||
|
score += 10000
|
||||||
|
}
|
||||||
|
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
||||||
|
score += 400
|
||||||
|
}
|
||||||
|
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
||||||
|
score += 320
|
||||||
|
}
|
||||||
|
if currentAlbum != "" && track.AlbumName != "" {
|
||||||
|
switch {
|
||||||
|
case titlesMatch(currentAlbum, track.AlbumName):
|
||||||
|
score += 120
|
||||||
|
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||||
|
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DurationMs > 0 && track.DurationMS > 0 {
|
||||||
|
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff <= 10 {
|
||||||
|
score += 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.ReleaseDate != "" {
|
||||||
|
score += 70
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if track.ISRC != "" {
|
||||||
|
score += 40
|
||||||
|
}
|
||||||
|
|
||||||
|
if best == nil || score > bestScore {
|
||||||
|
best = track
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
||||||
|
if track == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
||||||
|
return &ExtTrackMetadata{
|
||||||
|
ID: track.SpotifyID,
|
||||||
|
Name: track.Name,
|
||||||
|
Artists: track.Artists,
|
||||||
|
AlbumName: track.AlbumName,
|
||||||
|
AlbumArtist: track.AlbumArtist,
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
CoverURL: track.Images,
|
||||||
|
Images: track.Images,
|
||||||
|
ReleaseDate: track.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
ProviderID: providerID,
|
||||||
|
DeezerID: deezerID,
|
||||||
|
SpotifyID: track.SpotifyID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
downloadReq := reEnrichDownloadRequest(req)
|
||||||
|
|
||||||
|
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
||||||
|
cancel()
|
||||||
|
if err == nil && track != nil {
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
||||||
|
if sourceTrackID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
||||||
|
if deezerID == sourceTrackID {
|
||||||
|
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
||||||
|
}
|
||||||
|
if deezerID == "" {
|
||||||
|
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
||||||
|
if spotifyID != "" {
|
||||||
|
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
||||||
|
if err == nil {
|
||||||
|
deezerID = strings.TrimSpace(resolvedDeezerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deezerID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
|
if err != nil || trackResp == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &trackResp.Track
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func preferredReleaseMetadata(
|
func preferredReleaseMetadata(
|
||||||
req DownloadRequest,
|
req DownloadRequest,
|
||||||
album string,
|
album string,
|
||||||
@@ -864,6 +1154,26 @@ func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileMod
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||||
|
cuePath,
|
||||||
|
audioDir,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EditFileMetadata writes metadata to an audio file.
|
// EditFileMetadata writes metadata to an audio file.
|
||||||
// For FLAC files, uses native Go FLAC library.
|
// For FLAC files, uses native Go FLAC library.
|
||||||
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
||||||
@@ -888,19 +1198,20 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
meta := Metadata{
|
meta := Metadata{
|
||||||
Title: fields["title"],
|
Title: fields["title"],
|
||||||
Artist: fields["artist"],
|
Artist: fields["artist"],
|
||||||
Album: fields["album"],
|
Album: fields["album"],
|
||||||
AlbumArtist: fields["album_artist"],
|
AlbumArtist: fields["album_artist"],
|
||||||
Date: fields["date"],
|
ArtistTagMode: fields["artist_tag_mode"],
|
||||||
TrackNumber: trackNum,
|
Date: fields["date"],
|
||||||
DiscNumber: discNum,
|
TrackNumber: trackNum,
|
||||||
ISRC: fields["isrc"],
|
DiscNumber: discNum,
|
||||||
Genre: fields["genre"],
|
ISRC: fields["isrc"],
|
||||||
Label: fields["label"],
|
Genre: fields["genre"],
|
||||||
Copyright: fields["copyright"],
|
Label: fields["label"],
|
||||||
Composer: fields["composer"],
|
Copyright: fields["copyright"],
|
||||||
Comment: fields["comment"],
|
Composer: fields["composer"],
|
||||||
|
Comment: fields["comment"],
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
|
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)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
|
||||||
jsonBytes, err := json.Marshal(spotFetchData)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
|
|
||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
|
||||||
if parseErr != nil {
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
|
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldTrySpotFetchFallback(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if errors.Is(err, ErrNoSpotifyCredentials) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
indicators := []string{
|
|
||||||
"429",
|
|
||||||
"rate",
|
|
||||||
"limit",
|
|
||||||
"403",
|
|
||||||
"forbidden",
|
|
||||||
"401",
|
|
||||||
"unauthorized",
|
|
||||||
"timeout",
|
|
||||||
"connection",
|
|
||||||
"spotify error",
|
|
||||||
"access token",
|
|
||||||
"client token",
|
|
||||||
"eof",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, indicator := range indicators {
|
|
||||||
if strings.Contains(errStr, indicator) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
@@ -1801,26 +2046,7 @@ func GetLyricsFetchOptionsJSON() (string, error) {
|
|||||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||||
// complete metadata from the internet before embedding.
|
// complete metadata from the internet before embedding.
|
||||||
func ReEnrichFile(requestJSON string) (string, error) {
|
func ReEnrichFile(requestJSON string) (string, error) {
|
||||||
var req struct {
|
var req reEnrichRequest
|
||||||
FilePath string `json:"file_path"`
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
MaxQuality bool `json:"max_quality"`
|
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
|
||||||
SpotifyID string `json:"spotify_id"`
|
|
||||||
TrackName string `json:"track_name"`
|
|
||||||
ArtistName string `json:"artist_name"`
|
|
||||||
AlbumName string `json:"album_name"`
|
|
||||||
AlbumArtist string `json:"album_artist"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
Genre string `json:"genre"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Copyright string `json:"copyright"`
|
|
||||||
DurationMs int64 `json:"duration_ms"`
|
|
||||||
SearchOnline bool `json:"search_online"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse request: %w", err)
|
return "", fmt.Errorf("failed to parse request: %w", err)
|
||||||
@@ -1842,42 +2068,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
|
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||||
|
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
|
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||||
|
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
if searchErr == nil && len(tracks) > 0 {
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
track := tracks[0]
|
track := selectBestReEnrichTrack(req, tracks)
|
||||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
if track != nil {
|
||||||
if track.SpotifyID != "" {
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
req.SpotifyID = track.SpotifyID
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||||
} else if track.DeezerID != "" {
|
applyReEnrichTrackMetadata(&req, *track)
|
||||||
req.SpotifyID = "deezer:" + track.DeezerID
|
found = true
|
||||||
} else if track.QobuzID != "" {
|
|
||||||
req.SpotifyID = "qobuz:" + track.QobuzID
|
|
||||||
} else if track.TidalID != "" {
|
|
||||||
req.SpotifyID = "tidal:" + track.TidalID
|
|
||||||
} else {
|
|
||||||
req.SpotifyID = track.ID
|
|
||||||
}
|
}
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
coverURL := track.ResolvedCoverURL()
|
|
||||||
if coverURL != "" {
|
|
||||||
req.CoverURL = coverURL
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
if track.Genre != "" {
|
|
||||||
req.Genre = track.Genre
|
|
||||||
}
|
|
||||||
if track.Label != "" {
|
|
||||||
req.Label = track.Label
|
|
||||||
}
|
|
||||||
if track.Copyright != "" {
|
|
||||||
req.Copyright = track.Copyright
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
} else if searchErr != nil {
|
} else if searchErr != nil {
|
||||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
}
|
||||||
@@ -2008,18 +2214,19 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
if isFlac {
|
if isFlac {
|
||||||
// Native Go FLAC metadata embedding
|
// Native Go FLAC metadata embedding
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
TrackNumber: req.TrackNumber,
|
Date: req.ReleaseDate,
|
||||||
DiscNumber: req.DiscNumber,
|
TrackNumber: req.TrackNumber,
|
||||||
ISRC: req.ISRC,
|
DiscNumber: req.DiscNumber,
|
||||||
Genre: req.Genre,
|
ISRC: req.ISRC,
|
||||||
Label: req.Label,
|
Genre: req.Genre,
|
||||||
Copyright: req.Copyright,
|
Label: req.Label,
|
||||||
Lyrics: lyricsLRC,
|
Copyright: req.Copyright,
|
||||||
|
Lyrics: lyricsLRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(coverDataBytes) > 0 {
|
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
|
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||||
cleanupCover = false
|
cleanupCover = false
|
||||||
|
ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"method": "ffmpeg",
|
"method": "ffmpeg",
|
||||||
"cover_path": coverTempPath,
|
"cover_path": coverTempPath,
|
||||||
"lyrics": lyricsLRC,
|
"lyrics": lyricsLRC,
|
||||||
"enriched_metadata": enrichedMeta,
|
"enriched_metadata": enrichedMeta,
|
||||||
"metadata": map[string]string{
|
"metadata": ffmpegMetadata,
|
||||||
"TITLE": req.TrackName,
|
|
||||||
"ARTIST": req.ArtistName,
|
|
||||||
"ALBUM": req.AlbumName,
|
|
||||||
"ALBUMARTIST": req.AlbumArtist,
|
|
||||||
"DATE": req.ReleaseDate,
|
|
||||||
"ISRC": req.ISRC,
|
|
||||||
"GENRE": req.Genre,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if req.TrackNumber > 0 {
|
|
||||||
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
|
||||||
}
|
|
||||||
if req.DiscNumber > 0 {
|
|
||||||
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
|
||||||
}
|
|
||||||
if req.Label != "" {
|
|
||||||
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
|
|
||||||
}
|
|
||||||
if req.Copyright != "" {
|
|
||||||
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
|
|
||||||
}
|
|
||||||
if lyricsLRC != "" {
|
|
||||||
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
|
|
||||||
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(result)
|
jsonBytes, _ := json.Marshal(result)
|
||||||
@@ -3328,3 +3513,7 @@ func ReadAudioMetadataJSON(filePath string) (string, error) {
|
|||||||
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||||
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,3 +113,112 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
|||||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
AlbumName: "Original Album",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
ISRC: "REQ123",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
AlbumName: "Resolved Album",
|
||||||
|
ReleaseDate: "",
|
||||||
|
ISRC: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if req.AlbumName != "Resolved Album" {
|
||||||
|
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.ISRC != "REQ123" {
|
||||||
|
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
ReleaseDate: "",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ProviderID: "spotify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected a selected track")
|
||||||
|
}
|
||||||
|
if best.ID != "second" {
|
||||||
|
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "",
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: 0,
|
||||||
|
DiscNumber: 0,
|
||||||
|
ISRC: "",
|
||||||
|
Genre: "",
|
||||||
|
Label: "",
|
||||||
|
Copyright: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(req, "")
|
||||||
|
|
||||||
|
if metadata["TITLE"] != "Song" {
|
||||||
|
t.Fatalf("title = %q", metadata["TITLE"])
|
||||||
|
}
|
||||||
|
if metadata["ARTIST"] != "Artist" {
|
||||||
|
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||||
|
}
|
||||||
|
if metadata["ALBUM"] != "Album" {
|
||||||
|
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
"ALBUMARTIST",
|
||||||
|
"DATE",
|
||||||
|
"TRACKNUMBER",
|
||||||
|
"DISCNUMBER",
|
||||||
|
"ISRC",
|
||||||
|
"GENRE",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"COPYRIGHT",
|
||||||
|
"LYRICS",
|
||||||
|
"UNSYNCEDLYRICS",
|
||||||
|
} {
|
||||||
|
if _, exists := metadata[key]; exists {
|
||||||
|
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+62
-34
@@ -13,25 +13,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LibraryScanResult struct {
|
type LibraryScanResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackName string `json:"trackName"`
|
TrackName string `json:"trackName"`
|
||||||
ArtistName string `json:"artistName"`
|
ArtistName string `json:"artistName"`
|
||||||
AlbumName string `json:"albumName"`
|
AlbumName string `json:"albumName"`
|
||||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
CoverPath string `json:"coverPath,omitempty"`
|
CoverPath string `json:"coverPath,omitempty"`
|
||||||
ScannedAt string `json:"scannedAt"`
|
ScannedAt string `json:"scannedAt"`
|
||||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
TrackNumber int `json:"trackNumber,omitempty"`
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
DiscNumber int `json:"discNumber,omitempty"`
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
|
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryScanProgress struct {
|
type LibraryScanProgress struct {
|
||||||
@@ -219,6 +220,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
cueInfo.audioPath,
|
cueInfo.audioPath,
|
||||||
"",
|
"",
|
||||||
fileInfo.modTime,
|
fileInfo.modTime,
|
||||||
|
"",
|
||||||
scanTime,
|
scanTime,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -269,10 +271,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
result := &LibraryScanResult{
|
||||||
@@ -292,7 +298,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
|||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" {
|
if coverCacheDir != "" {
|
||||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -300,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return scanFLACFile(filePath, result)
|
return scanFLACFile(filePath, result, displayNameHint)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
return scanM4AFile(filePath, result)
|
return scanM4AFile(filePath, result, displayNameHint)
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result)
|
return scanMP3File(filePath, result, displayNameHint)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result, displayNameHint)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
@@ -340,10 +351,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, "", result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -365,14 +376,19 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, "", result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadM4ATags(filePath)
|
metadata, err := ReadM4ATags(filePath)
|
||||||
if err == nil && metadata != nil {
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata != nil {
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
result.ArtistName = metadata.Artist
|
result.ArtistName = metadata.Artist
|
||||||
result.AlbumName = metadata.Album
|
result.AlbumName = metadata.Album
|
||||||
@@ -393,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, "", result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadID3Tags(filePath)
|
metadata, err := ReadID3Tags(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, "", result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -428,7 +444,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, "", result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -466,6 +482,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
result.MetadataFromFilename = true
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
@@ -541,8 +558,18 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
0,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -746,6 +773,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
cueInfo.audioPath,
|
cueInfo.audioPath,
|
||||||
"",
|
"",
|
||||||
f.modTime,
|
f.modTime,
|
||||||
|
"",
|
||||||
scanTime,
|
scanTime,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ type LogBuffer struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLogBufferSize = 500
|
defaultLogBufferSize = 500
|
||||||
maxLogMessageLength = 500
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateLogMessage(message string) string {
|
|
||||||
runes := []rune(message)
|
|
||||||
if len(runes) <= maxLogMessageLength {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message = sanitizeSensitiveLogText(message)
|
message = sanitizeSensitiveLogText(message)
|
||||||
message = truncateLogMessage(message)
|
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Timestamp: time.Now().Format("15:04:05.000"),
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
|
|||||||
@@ -982,7 +982,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
|||||||
+128
-25
@@ -11,6 +11,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ import (
|
|||||||
"github.com/go-flac/go-flac/v2"
|
"github.com/go-flac/go-flac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const artistTagModeSplitVorbis = "split_vorbis"
|
||||||
|
|
||||||
|
var artistTagSplitPattern = regexp.MustCompile(`\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*`)
|
||||||
|
|
||||||
func detectCoverMIME(coverPath string, coverData []byte) string {
|
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||||
// Prefer magic-byte detection over file extension.
|
// Prefer magic-byte detection over file extension.
|
||||||
// Some providers return non-JPEG data behind .jpg URLs.
|
// Some providers return non-JPEG data behind .jpg URLs.
|
||||||
@@ -96,22 +101,23 @@ func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
AlbumArtist string
|
AlbumArtist string
|
||||||
Date string
|
ArtistTagMode string
|
||||||
TrackNumber int
|
Date string
|
||||||
TotalTracks int
|
TrackNumber int
|
||||||
DiscNumber int
|
TotalTracks int
|
||||||
ISRC string
|
DiscNumber int
|
||||||
Description string
|
ISRC string
|
||||||
Lyrics string
|
Description string
|
||||||
Genre string
|
Lyrics string
|
||||||
Label string
|
Genre string
|
||||||
Copyright string
|
Label string
|
||||||
Composer string
|
Copyright string
|
||||||
Comment string
|
Composer string
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
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, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setArtistComments(
|
||||||
|
cmt,
|
||||||
|
"ALBUMARTIST",
|
||||||
|
metadata.AlbumArtist,
|
||||||
|
metadata.ArtistTagMode,
|
||||||
|
)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
@@ -248,9 +259,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setArtistComments(
|
||||||
|
cmt,
|
||||||
|
"ALBUMARTIST",
|
||||||
|
metadata.AlbumArtist,
|
||||||
|
metadata.ArtistTagMode,
|
||||||
|
)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
@@ -339,9 +355,9 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata.Title = getComment(cmt, "TITLE")
|
metadata.Title = getComment(cmt, "TITLE")
|
||||||
metadata.Artist = getComment(cmt, "ARTIST")
|
metadata.Artist = getJoinedComment(cmt, "ARTIST")
|
||||||
metadata.Album = getComment(cmt, "ALBUM")
|
metadata.Album = getComment(cmt, "ALBUM")
|
||||||
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST")
|
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
|
||||||
metadata.Date = getComment(cmt, "DATE")
|
metadata.Date = getComment(cmt, "DATE")
|
||||||
metadata.ISRC = getComment(cmt, "ISRC")
|
metadata.ISRC = getComment(cmt, "ISRC")
|
||||||
metadata.Description = getComment(cmt, "DESCRIPTION")
|
metadata.Description = getComment(cmt, "DESCRIPTION")
|
||||||
@@ -394,6 +410,28 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
removeCommentKey(cmt, key)
|
||||||
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
|
||||||
|
values := []string{value}
|
||||||
|
if shouldSplitVorbisArtistTags(mode) {
|
||||||
|
values = splitArtistTagValues(value)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeCommentKey(cmt, key)
|
||||||
|
for _, artist := range values {
|
||||||
|
if strings.TrimSpace(artist) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmt.Comments = append(cmt.Comments, key+"="+artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
|
||||||
keyUpper := strings.ToUpper(key)
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
comment := cmt.Comments[i]
|
comment := cmt.Comments[i]
|
||||||
@@ -405,20 +443,85 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
values := getCommentValues(cmt, key)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJoinedComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
return joinVorbisCommentValues(getCommentValues(cmt, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommentValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) []string {
|
||||||
keyUpper := strings.ToUpper(key) + "="
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
|
values := make([]string, 0, 1)
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key) {
|
if len(comment) > len(key) {
|
||||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
if commentUpper == keyUpper {
|
if commentUpper == keyUpper {
|
||||||
return comment[len(key)+1:]
|
values = append(values, comment[len(key)+1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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 {
|
func fileExists(path string) bool {
|
||||||
|
|||||||
@@ -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
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -1597,21 +1598,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool {
|
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
|
||||||
if track == nil {
|
if track == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
track.ISRC != "" &&
|
||||||
logPrefix, source, req.ArtistName, track.Performer.Name)
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
|
if !exactISRCMatch && !skipNameVerification {
|
||||||
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
logPrefix, source, req.TrackName, track.Title)
|
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
||||||
return false
|
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
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
@@ -1644,7 +1651,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
@@ -1658,6 +1666,234 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
|
|||||||
return result.Tracks.Items, nil
|
return result.Tracks.Items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type qobuzTrackSearchCandidate struct {
|
||||||
|
score int
|
||||||
|
track QobuzTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzNormalizedSearchText(value string) string {
|
||||||
|
return normalizeLooseArtistName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzSearchTokens(value string) []string {
|
||||||
|
normalized := qobuzNormalizedSearchText(value)
|
||||||
|
if normalized == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(normalized)
|
||||||
|
tokens := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if len(part) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[part]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[part] = struct{}{}
|
||||||
|
tokens = append(tokens, part)
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int {
|
||||||
|
if track == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
queryNorm := qobuzNormalizedSearchText(query)
|
||||||
|
if queryNorm == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
titleNorm := qobuzNormalizedSearchText(track.Title)
|
||||||
|
displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track))
|
||||||
|
artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track))
|
||||||
|
albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title))
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) {
|
||||||
|
score += 900
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case queryNorm == titleNorm, queryNorm == displayNorm:
|
||||||
|
score += 1200
|
||||||
|
case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) ||
|
||||||
|
(displayNorm != "" && strings.Contains(displayNorm, queryNorm)):
|
||||||
|
score += 420
|
||||||
|
case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) ||
|
||||||
|
(displayNorm != "" && strings.Contains(queryNorm, displayNorm)):
|
||||||
|
score += 260
|
||||||
|
}
|
||||||
|
|
||||||
|
if artistNorm != "" && strings.Contains(queryNorm, artistNorm) {
|
||||||
|
score += 180
|
||||||
|
}
|
||||||
|
if albumNorm != "" && strings.Contains(queryNorm, albumNorm) {
|
||||||
|
score += 100
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range qobuzSearchTokens(query) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token):
|
||||||
|
score += 180
|
||||||
|
case strings.Contains(artistNorm, token):
|
||||||
|
score += 70
|
||||||
|
case strings.Contains(albumNorm, token):
|
||||||
|
score += 35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.ISRC != "" {
|
||||||
|
score += 15
|
||||||
|
}
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if track.MaximumSamplingRate >= 88.2 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
query string,
|
||||||
|
limit int,
|
||||||
|
albumSummaries []qobuzAlbumDetails,
|
||||||
|
loadAlbum func(string) (*qobuzAlbumDetails, error),
|
||||||
|
) ([]QobuzTrack, error) {
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return nil, fmt.Errorf("empty qobuz album-search fallback query")
|
||||||
|
}
|
||||||
|
if len(albumSummaries) == 0 {
|
||||||
|
return nil, fmt.Errorf("album search returned no albums")
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make([]qobuzTrackSearchCandidate, 0, limit)
|
||||||
|
seenTrackIDs := make(map[int64]struct{})
|
||||||
|
|
||||||
|
for _, summary := range albumSummaries {
|
||||||
|
albumID := strings.TrimSpace(summary.ID)
|
||||||
|
if albumID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := loadAlbum(albumID)
|
||||||
|
if err != nil || album == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range album.Tracks.Items {
|
||||||
|
track := album.Tracks.Items[i]
|
||||||
|
track.Album.ID = album.ID
|
||||||
|
track.Album.QobuzID = album.QobuzID
|
||||||
|
track.Album.Title = album.Title
|
||||||
|
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
||||||
|
track.Album.TracksCount = album.TracksCount
|
||||||
|
track.Album.ProductType = album.ProductType
|
||||||
|
track.Album.ReleaseType = album.ReleaseType
|
||||||
|
track.Album.Artist.ID = album.Artist.ID
|
||||||
|
track.Album.Artist.Name = album.Artist.Name
|
||||||
|
track.Album.Artists = album.Artists
|
||||||
|
track.Album.Image = album.Image
|
||||||
|
|
||||||
|
if track.ID > 0 {
|
||||||
|
if _, ok := seenTrackIDs[track.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTrackIDs[track.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
score := qobuzScoreTrackSearchCandidate(query, &track)
|
||||||
|
if score <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = append(candidates, qobuzTrackSearchCandidate{
|
||||||
|
score: score,
|
||||||
|
track: track,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("album-search fallback returned no scored track candidates")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].score != candidates[j].score {
|
||||||
|
return candidates[i].score > candidates[j].score
|
||||||
|
}
|
||||||
|
if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth {
|
||||||
|
return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth
|
||||||
|
}
|
||||||
|
return candidates[i].track.ID < candidates[j].track.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
if limit > 0 && len(candidates) > limit {
|
||||||
|
candidates = candidates[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]QobuzTrack, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
tracks = append(tracks, candidate.track)
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) {
|
||||||
|
albumLimit := limit
|
||||||
|
if albumLimit < 3 {
|
||||||
|
albumLimit = 3
|
||||||
|
}
|
||||||
|
if albumLimit > 8 {
|
||||||
|
albumLimit = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf(
|
||||||
|
"https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||||
|
url.QueryEscape(strings.TrimSpace(query)),
|
||||||
|
albumLimit,
|
||||||
|
q.appID,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumResp struct {
|
||||||
|
Albums struct {
|
||||||
|
Items []qobuzAlbumDetails `json:"items"`
|
||||||
|
} `json:"albums"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
albumResp.Albums.Items,
|
||||||
|
q.getAlbumDetails,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
|
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
|
||||||
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
|
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
@@ -1735,9 +1971,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
|
|||||||
if len(apiTracks) > 0 {
|
if len(apiTracks) > 0 {
|
||||||
return apiTracks, nil
|
return apiTracks, nil
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query)
|
GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr)
|
GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit)
|
||||||
|
if albumErr == nil && len(albumTracks) > 0 {
|
||||||
|
GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query)
|
||||||
|
return albumTracks, nil
|
||||||
|
}
|
||||||
|
if albumErr != nil {
|
||||||
|
GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
|
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
|
||||||
@@ -1746,10 +1991,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
|
|||||||
return storeTracks, nil
|
return storeTracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiErr != nil && storeErr != nil {
|
if apiErr != nil && albumErr != nil && storeErr != nil {
|
||||||
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr)
|
return nil, fmt.Errorf(
|
||||||
|
"api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)",
|
||||||
|
apiErr,
|
||||||
|
albumErr,
|
||||||
|
storeErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if albumErr == nil && len(albumTracks) == 0 && storeErr != nil {
|
||||||
|
return nil, storeErr
|
||||||
}
|
}
|
||||||
if storeErr != nil {
|
if storeErr != nil {
|
||||||
|
if albumErr != nil {
|
||||||
|
return nil, albumErr
|
||||||
|
}
|
||||||
return nil, storeErr
|
return nil, storeErr
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("no tracks found for query: %s", query)
|
return nil, fmt.Errorf("no tracks found for query: %s", query)
|
||||||
@@ -2125,7 +2381,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
|
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil {
|
} else if track != nil {
|
||||||
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") {
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
|
||||||
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||||
} else {
|
} else {
|
||||||
track = nil
|
track = nil
|
||||||
@@ -2142,7 +2398,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") {
|
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2162,7 +2418,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
|
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil {
|
} else if track != nil {
|
||||||
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") {
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
|
||||||
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
@@ -2179,7 +2435,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
||||||
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
|
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
|
||||||
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2188,7 +2444,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if track == nil {
|
if track == nil {
|
||||||
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
||||||
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2253,7 +2509,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
qobuzQuality = "6"
|
qobuzQuality = "6"
|
||||||
case "HI_RES":
|
case "HI_RES":
|
||||||
qobuzQuality = "7"
|
qobuzQuality = "7"
|
||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS", "", "DEFAULT":
|
||||||
qobuzQuality = "27"
|
qobuzQuality = "27"
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
@@ -2329,18 +2585,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: req.ArtistName,
|
||||||
Album: albumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: releaseDate,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
TrackNumber: actualTrackNumber,
|
Date: releaseDate,
|
||||||
TotalTracks: req.TotalTracks,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: req.DiscNumber,
|
TotalTracks: req.TotalTracks,
|
||||||
ISRC: track.ISRC,
|
DiscNumber: req.DiscNumber,
|
||||||
Genre: req.Genre,
|
ISRC: track.ISRC,
|
||||||
Label: req.Label,
|
Genre: req.Genre,
|
||||||
Copyright: req.Copyright,
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
@@ -2405,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
req.DiscNumber,
|
req.DiscNumber,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Prefer the cover URL the frontend sent (user-selected album) over the
|
||||||
|
// track's default album cover returned by the Qobuz track/get API, which
|
||||||
|
// may belong to a different album when the same track appears on multiple
|
||||||
|
// releases.
|
||||||
|
resultCoverURL := strings.TrimSpace(req.CoverURL)
|
||||||
|
if resultCoverURL == "" {
|
||||||
|
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
||||||
|
}
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
@@ -2416,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
TrackNumber: resultTrackNumber,
|
TrackNumber: resultTrackNumber,
|
||||||
DiscNumber: resultDiscNumber,
|
DiscNumber: resultDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
|
CoverURL: resultCoverURL,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
|
||||||
|
album := &qobuzAlbumDetails{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
ReleaseDateOriginal: "2013-05-20",
|
||||||
|
TracksCount: len(tracks),
|
||||||
|
ProductType: "album",
|
||||||
|
ReleaseType: "album",
|
||||||
|
}
|
||||||
|
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
|
||||||
|
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
|
||||||
|
album.Tracks.Items = tracks
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseQobuzURL(t *testing.T) {
|
func TestParseQobuzURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -276,6 +291,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
|||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
|
||||||
|
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
|
||||||
|
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
3,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
t.Fatal("expected tracks, got none")
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 1 {
|
||||||
|
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
5,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) != 1 {
|
||||||
|
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 42 {
|
||||||
|
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
@@ -436,3 +513,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
|
|||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &QobuzTrack{
|
||||||
|
Title: "Different Title",
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
track.Performer.Name = "Different Artist"
|
||||||
|
|
||||||
|
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||||
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
|
|||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: strings.TrimSpace(track.Title),
|
Title: strings.TrimSpace(track.Title),
|
||||||
ArtistName: tidalTrackArtistsDisplay(track),
|
ArtistName: tidalTrackArtistsDisplay(track),
|
||||||
|
ISRC: strings.TrimSpace(track.ISRC),
|
||||||
Duration: track.Duration,
|
Duration: track.Duration,
|
||||||
}
|
}
|
||||||
if trackMatchesRequest(req, resolved, "Tidal search") {
|
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||||
@@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
var trackID int64
|
var trackID int64
|
||||||
var gotTidalID bool
|
var gotTidalID bool
|
||||||
|
var resolvedViaSongLink bool
|
||||||
|
|
||||||
if req.TidalID != "" {
|
if req.TidalID != "" {
|
||||||
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
||||||
@@ -2094,6 +2096,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
trackID = parsedTrackID
|
trackID = parsedTrackID
|
||||||
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
if idErr == nil && trackID > 0 {
|
if idErr == nil && trackID > 0 {
|
||||||
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2157,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
providerArtist = actualTrack.Artists[0].Name
|
providerArtist = actualTrack.Artists[0].Name
|
||||||
}
|
}
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: actualTrack.Title,
|
Title: actualTrack.Title,
|
||||||
ArtistName: providerArtist,
|
ArtistName: providerArtist,
|
||||||
Duration: actualTrack.Duration,
|
ISRC: strings.TrimSpace(actualTrack.ISRC),
|
||||||
|
Duration: actualTrack.Duration,
|
||||||
|
SkipNameVerification: resolvedViaSongLink,
|
||||||
}
|
}
|
||||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||||
// Invalidate the cached ID so future requests don't reuse it.
|
// Invalidate the cached ID so future requests don't reuse it.
|
||||||
@@ -2206,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
if quality == "" {
|
if quality == "" || quality == "DEFAULT" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2348,18 +2354,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: releaseDate,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
TrackNumber: actualTrackNumber,
|
Date: releaseDate,
|
||||||
TotalTracks: req.TotalTracks,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: actualDiscNumber,
|
TotalTracks: req.TotalTracks,
|
||||||
ISRC: track.ISRC,
|
DiscNumber: actualDiscNumber,
|
||||||
Genre: req.Genre,
|
ISRC: track.ISRC,
|
||||||
Label: req.Label,
|
Genre: req.Genre,
|
||||||
Copyright: req.Copyright,
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||||
|
switch r {
|
||||||
|
case 'đ':
|
||||||
|
b.WriteString("dj")
|
||||||
|
case 'ß':
|
||||||
|
b.WriteString("ss")
|
||||||
|
case 'æ':
|
||||||
|
b.WriteString("ae")
|
||||||
|
case 'œ':
|
||||||
|
b.WriteString("oe")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||||
func normalizeLooseTitle(title string) string {
|
func normalizeLooseTitle(title string) string {
|
||||||
@@ -51,7 +66,7 @@ func normalizeLooseArtistName(name string) string {
|
|||||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||||
continue
|
continue
|
||||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||||
b.WriteRune(r)
|
writeNormalizedArtistRune(&b, r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
@@ -101,26 +116,34 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
type resolvedTrackInfo struct {
|
type resolvedTrackInfo struct {
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
Duration int
|
ISRC string
|
||||||
|
Duration int
|
||||||
|
SkipNameVerification bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
// the original download request. Returns true if the track is a plausible match.
|
// the original download request. Returns true if the track is a plausible match.
|
||||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
resolved.ISRC != "" &&
|
||||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
||||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && resolved.Title != "" &&
|
if !exactISRCMatch && !resolved.SkipNameVerification {
|
||||||
!titlesMatch(req.TrackName, resolved.Title) {
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
logPrefix, req.TrackName, resolved.Title)
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
return false
|
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
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
Duration: 240,
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||||
|
|||||||
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
|
|||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "parseSpotifyUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendParseSpotifyURL(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadataWithFallback":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailabilityFromDeezerID":
|
case "checkAvailabilityFromDeezerID":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let deezerTrackId = args["deezer_track_id"] as! String
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '4.1.1';
|
static const String version = '4.1.3';
|
||||||
static const String buildNumber = '118';
|
static const String buildNumber = '120';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ abstract class AppLocalizations {
|
|||||||
/// Bottom navigation - Extension store tab
|
/// Bottom navigation - Extension store tab
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Store'**
|
/// **'Repo'**
|
||||||
String get navStore;
|
String get navStore;
|
||||||
|
|
||||||
/// Home screen title
|
/// Home screen title
|
||||||
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
|
|||||||
/// Subtitle shown below search box
|
/// Subtitle shown below search box
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Paste a Spotify link or search by name'**
|
/// **'Paste a supported URL or search by name'**
|
||||||
String get homeSubtitle;
|
String get homeSubtitle;
|
||||||
|
|
||||||
/// Info text about supported URL types
|
/// Info text about supported URL types
|
||||||
@@ -400,6 +400,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Download highest resolution cover art'**
|
/// **'Download highest resolution cover art'**
|
||||||
String get optionsMaxQualityCoverSubtitle;
|
String get optionsMaxQualityCoverSubtitle;
|
||||||
|
|
||||||
|
/// Setting title for how artist metadata is written into files
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist Tag Mode'**
|
||||||
|
String get optionsArtistTagMode;
|
||||||
|
|
||||||
|
/// Bottom-sheet description for artist tag mode setting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose how multiple artists are written into embedded tags.'**
|
||||||
|
String get optionsArtistTagModeDescription;
|
||||||
|
|
||||||
|
/// Artist tag mode option that joins multiple artists into one value
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Single joined value'**
|
||||||
|
String get optionsArtistTagModeJoined;
|
||||||
|
|
||||||
|
/// Subtitle for joined artist tag mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'**
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle;
|
||||||
|
|
||||||
|
/// Artist tag mode option that writes repeated ARTIST tags for Vorbis formats
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Split tags for FLAC/Opus'**
|
||||||
|
String get optionsArtistTagModeSplitVorbis;
|
||||||
|
|
||||||
|
/// Subtitle for split Vorbis artist tag mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle;
|
||||||
|
|
||||||
/// Number of parallel downloads
|
/// Number of parallel downloads
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -427,13 +463,13 @@ abstract class AppLocalizations {
|
|||||||
/// Show/hide store tab
|
/// Show/hide store tab
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Extension Store'**
|
/// **'Extension Repo'**
|
||||||
String get optionsExtensionStore;
|
String get optionsExtensionStore;
|
||||||
|
|
||||||
/// Subtitle for extension store toggle
|
/// Subtitle for extension store toggle
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Show Store tab in navigation'**
|
/// **'Show Repo tab in navigation'**
|
||||||
String get optionsExtensionStoreSubtitle;
|
String get optionsExtensionStoreSubtitle;
|
||||||
|
|
||||||
/// Auto update check toggle
|
/// Auto update check toggle
|
||||||
@@ -565,7 +601,7 @@ abstract class AppLocalizations {
|
|||||||
/// Store screen title
|
/// Store screen title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Extension Store'**
|
/// **'Extension Repo'**
|
||||||
String get storeTitle;
|
String get storeTitle;
|
||||||
|
|
||||||
/// Store search placeholder
|
/// Store search placeholder
|
||||||
@@ -2365,7 +2401,7 @@ abstract class AppLocalizations {
|
|||||||
/// Error heading when the store cannot be loaded
|
/// Error heading when the store cannot be loaded
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Failed to load store'**
|
/// **'Failed to load repository'**
|
||||||
String get storeLoadError;
|
String get storeLoadError;
|
||||||
|
|
||||||
/// Message when store has no extensions
|
/// Message when store has no extensions
|
||||||
@@ -3334,6 +3370,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'{count, plural, =1{track} other{tracks}}'**
|
/// **'{count, plural, =1{track} other{tracks}}'**
|
||||||
String libraryTracksUnit(int count);
|
String libraryTracksUnit(int count);
|
||||||
|
|
||||||
|
/// Unit label for files count during library scanning
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{file} other{files}}'**
|
||||||
|
String libraryFilesUnit(int count);
|
||||||
|
|
||||||
/// Last scan time display
|
/// Last scan time display
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3352,6 +3394,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Scanning...'**
|
/// **'Scanning...'**
|
||||||
String get libraryScanning;
|
String get libraryScanning;
|
||||||
|
|
||||||
|
/// Status shown after file scanning finishes but library persistence is still running
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finalizing library...'**
|
||||||
|
String get libraryScanFinalizing;
|
||||||
|
|
||||||
/// Scan progress display
|
/// Scan progress display
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3478,6 +3526,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Format'**
|
/// **'Format'**
|
||||||
String get libraryFilterFormat;
|
String get libraryFilterFormat;
|
||||||
|
|
||||||
|
/// Filter section - metadata completeness
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Metadata'**
|
||||||
|
String get libraryFilterMetadata;
|
||||||
|
|
||||||
|
/// Filter option - items with complete metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete metadata'**
|
||||||
|
String get libraryFilterMetadataComplete;
|
||||||
|
|
||||||
|
/// Filter option - items missing any tracked metadata field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Missing any metadata'**
|
||||||
|
String get libraryFilterMetadataMissingAny;
|
||||||
|
|
||||||
|
/// Filter option - items missing release year/date
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Missing year'**
|
||||||
|
String get libraryFilterMetadataMissingYear;
|
||||||
|
|
||||||
|
/// Filter option - items missing genre
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Missing genre'**
|
||||||
|
String get libraryFilterMetadataMissingGenre;
|
||||||
|
|
||||||
|
/// Filter option - items missing album artist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Missing album artist'**
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist;
|
||||||
|
|
||||||
/// Filter section - sort order
|
/// Filter section - sort order
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3496,6 +3580,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Oldest'**
|
/// **'Oldest'**
|
||||||
String get libraryFilterSortOldest;
|
String get libraryFilterSortOldest;
|
||||||
|
|
||||||
|
/// Sort option - album ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album (A-Z)'**
|
||||||
|
String get libraryFilterSortAlbumAsc;
|
||||||
|
|
||||||
|
/// Sort option - album descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album (Z-A)'**
|
||||||
|
String get libraryFilterSortAlbumDesc;
|
||||||
|
|
||||||
|
/// Sort option - genre ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Genre (A-Z)'**
|
||||||
|
String get libraryFilterSortGenreAsc;
|
||||||
|
|
||||||
|
/// Sort option - genre descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Genre (Z-A)'**
|
||||||
|
String get libraryFilterSortGenreDesc;
|
||||||
|
|
||||||
/// Relative time - less than a minute ago
|
/// Relative time - less than a minute ago
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3613,7 +3721,7 @@ abstract class AppLocalizations {
|
|||||||
/// Tutorial extensions tip 1
|
/// Tutorial extensions tip 1
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Browse the Store tab to discover useful extensions'**
|
/// **'Browse the Repo tab to discover useful extensions'**
|
||||||
String get tutorialExtensionsTip1;
|
String get tutorialExtensionsTip1;
|
||||||
|
|
||||||
/// Tutorial extensions tip 2
|
/// Tutorial extensions tip 2
|
||||||
@@ -5300,6 +5408,298 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Samples'**
|
/// **'Samples'**
|
||||||
String get audioAnalysisSamples;
|
String get audioAnalysisSamples;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for built-in search provider option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search with {providerName}'**
|
||||||
|
String extensionsSearchWith(String providerName);
|
||||||
|
|
||||||
|
/// Extensions page - label for home feed provider selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Home Feed Provider'**
|
||||||
|
String get extensionsHomeFeedProvider;
|
||||||
|
|
||||||
|
/// Extensions page - description for home feed provider picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose which extension provides the home feed on the main screen'**
|
||||||
|
String get extensionsHomeFeedDescription;
|
||||||
|
|
||||||
|
/// Extensions page - home feed provider option: auto
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto'**
|
||||||
|
String get extensionsHomeFeedAuto;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for auto home feed option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Automatically select the best available'**
|
||||||
|
String get extensionsHomeFeedAutoSubtitle;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for a specific extension home feed option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use {extensionName} home feed'**
|
||||||
|
String extensionsHomeFeedUse(String extensionName);
|
||||||
|
|
||||||
|
/// Extensions page - shown when no installed extension has home feed
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No extensions with home feed'**
|
||||||
|
String get extensionsNoHomeFeedExtensions;
|
||||||
|
|
||||||
|
/// Sort option - alphabetical ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A-Z'**
|
||||||
|
String get sortAlphaAsc;
|
||||||
|
|
||||||
|
/// Sort option - alphabetical descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Z-A'**
|
||||||
|
String get sortAlphaDesc;
|
||||||
|
|
||||||
|
/// Dialog title when confirming cancellation of an active download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cancel download?'**
|
||||||
|
String get cancelDownloadTitle;
|
||||||
|
|
||||||
|
/// Dialog body when confirming cancellation of an active download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will cancel the active download for \"{trackName}\".'**
|
||||||
|
String cancelDownloadContent(String trackName);
|
||||||
|
|
||||||
|
/// Dialog button - keep the active download (do not cancel)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep'**
|
||||||
|
String get cancelDownloadKeep;
|
||||||
|
|
||||||
|
/// Snackbar error when FFmpeg fails to write metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to save metadata via FFmpeg'**
|
||||||
|
String get metadataSaveFailedFfmpeg;
|
||||||
|
|
||||||
|
/// Snackbar error when writing metadata file back to storage fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to write metadata back to storage'**
|
||||||
|
String get metadataSaveFailedStorage;
|
||||||
|
|
||||||
|
/// Snackbar shown when folder picker fails to open
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to open folder picker: {error}'**
|
||||||
|
String snackbarFolderPickerFailed(String error);
|
||||||
|
|
||||||
|
/// Error state shown when album fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load album'**
|
||||||
|
String get errorLoadAlbum;
|
||||||
|
|
||||||
|
/// Error state shown when playlist fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load playlist'**
|
||||||
|
String get errorLoadPlaylist;
|
||||||
|
|
||||||
|
/// Error state shown when artist fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load artist'**
|
||||||
|
String get errorLoadArtist;
|
||||||
|
|
||||||
|
/// Android notification channel name for download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Progress'**
|
||||||
|
String get notifChannelDownloadName;
|
||||||
|
|
||||||
|
/// Android notification channel description for download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shows download progress for tracks'**
|
||||||
|
String get notifChannelDownloadDesc;
|
||||||
|
|
||||||
|
/// Android notification channel name for library scan
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library Scan'**
|
||||||
|
String get notifChannelLibraryScanName;
|
||||||
|
|
||||||
|
/// Android notification channel description for library scan
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shows local library scan progress'**
|
||||||
|
String get notifChannelLibraryScanDesc;
|
||||||
|
|
||||||
|
/// Notification title while downloading a track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloading {trackName}'**
|
||||||
|
String notifDownloadingTrack(String trackName);
|
||||||
|
|
||||||
|
/// Notification title while finalizing (embedding metadata) a track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finalizing {trackName}'**
|
||||||
|
String notifFinalizingTrack(String trackName);
|
||||||
|
|
||||||
|
/// Notification body while embedding metadata into a downloaded track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embedding metadata...'**
|
||||||
|
String get notifEmbeddingMetadata;
|
||||||
|
|
||||||
|
/// Notification title when track is already in library, with count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Already in Library ({completed}/{total})'**
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total);
|
||||||
|
|
||||||
|
/// Notification title when track is already in library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Already in Library'**
|
||||||
|
String get notifAlreadyInLibrary;
|
||||||
|
|
||||||
|
/// Notification title when download is complete, with count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Complete ({completed}/{total})'**
|
||||||
|
String notifDownloadCompleteCount(int completed, int total);
|
||||||
|
|
||||||
|
/// Notification title when a single download is complete
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Complete'**
|
||||||
|
String get notifDownloadComplete;
|
||||||
|
|
||||||
|
/// Notification title when queue finishes with some failures
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloads Finished ({completed} done, {failed} failed)'**
|
||||||
|
String notifDownloadsFinished(int completed, int failed);
|
||||||
|
|
||||||
|
/// Notification title when all downloads finish successfully
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Downloads Complete'**
|
||||||
|
String get notifAllDownloadsComplete;
|
||||||
|
|
||||||
|
/// Notification body for queue complete - how many tracks were downloaded
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks downloaded successfully'**
|
||||||
|
String notifTracksDownloadedSuccess(int count);
|
||||||
|
|
||||||
|
/// Notification title while scanning local library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Scanning local library'**
|
||||||
|
String get notifScanningLibrary;
|
||||||
|
|
||||||
|
/// Notification body for library scan progress when total is known
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{scanned}/{total} files • {percentage}%'**
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Notification body for library scan progress when total is unknown
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{scanned} files scanned • {percentage}%'**
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage);
|
||||||
|
|
||||||
|
/// Notification title when library scan finishes
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan complete'**
|
||||||
|
String get notifLibraryScanComplete;
|
||||||
|
|
||||||
|
/// Notification body for library scan complete - number of indexed tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks indexed'**
|
||||||
|
String notifLibraryScanCompleteBody(int count);
|
||||||
|
|
||||||
|
/// Library scan complete suffix - excluded track count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} excluded'**
|
||||||
|
String notifLibraryScanExcluded(int count);
|
||||||
|
|
||||||
|
/// Library scan complete suffix - error count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} errors'**
|
||||||
|
String notifLibraryScanErrors(int count);
|
||||||
|
|
||||||
|
/// Notification title when library scan fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan failed'**
|
||||||
|
String get notifLibraryScanFailed;
|
||||||
|
|
||||||
|
/// Notification title when library scan is cancelled by the user
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan cancelled'**
|
||||||
|
String get notifLibraryScanCancelled;
|
||||||
|
|
||||||
|
/// Notification body when library scan is cancelled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Scan stopped before completion.'**
|
||||||
|
String get notifLibraryScanStopped;
|
||||||
|
|
||||||
|
/// Notification title while downloading an app update
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloading SpotiFLAC v{version}'**
|
||||||
|
String notifDownloadingUpdate(String version);
|
||||||
|
|
||||||
|
/// Notification body showing update download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{received} / {total} MB • {percentage}%'**
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage);
|
||||||
|
|
||||||
|
/// Notification title when app update download is complete
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update Ready'**
|
||||||
|
String get notifUpdateReady;
|
||||||
|
|
||||||
|
/// Notification body when app update is ready to install
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
|
||||||
|
String notifUpdateReadyBody(String version);
|
||||||
|
|
||||||
|
/// Notification title when app update download fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update Failed'**
|
||||||
|
String get notifUpdateFailed;
|
||||||
|
|
||||||
|
/// Notification body when app update download fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not download update. Try again later.'**
|
||||||
|
String get notifUpdateFailedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -158,6 +158,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Cover in höchster Auflösung herunterladen';
|
'Cover in höchster Auflösung herunterladen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
||||||
|
|
||||||
@@ -1281,7 +1302,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1851,6 +1872,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Zuletzt gescannt: $time';
|
return 'Zuletzt gescannt: $time';
|
||||||
@@ -1862,6 +1894,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scannen...';
|
String get libraryScanning => 'Scannen...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% von $total Dateien';
|
return '$progress% von $total Dateien';
|
||||||
@@ -1930,6 +1965,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sortieren';
|
String get libraryFilterSort => 'Sortieren';
|
||||||
|
|
||||||
@@ -1939,6 +1992,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Älteste';
|
String get libraryFilterSortOldest => 'Älteste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Gerade eben';
|
String get timeJustNow => 'Gerade eben';
|
||||||
|
|
||||||
@@ -3124,4 +3189,192 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Settings';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Home';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||||
@@ -154,6 +154,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -170,10 +191,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
'Parallel downloads may trigger rate limiting';
|
'Parallel downloads may trigger rate limiting';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Check for Updates';
|
String get optionsCheckUpdates => 'Check for Updates';
|
||||||
@@ -250,7 +271,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Uninstall';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Search extensions...';
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1997,7 +2062,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -3092,4 +3157,192 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1997,7 +2062,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -3092,6 +3157,194 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|||||||
@@ -156,6 +156,27 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1263,7 +1284,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1825,6 +1846,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1836,6 +1868,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1904,6 +1939,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1913,6 +1966,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3093,4 +3158,192 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3091,4 +3156,192 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get navSettings => 'Pengaturan';
|
String get navSettings => 'Pengaturan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Toko';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Beranda';
|
String get homeTitle => 'Beranda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama';
|
String get homeSubtitle =>
|
||||||
|
'Tempel URL yang didukung atau cari berdasarkan nama';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
||||||
@@ -157,6 +158,27 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Unduh cover art resolusi tertinggi';
|
'Unduh cover art resolusi tertinggi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
||||||
|
|
||||||
@@ -173,10 +195,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Unduhan paralel dapat memicu pembatasan rate';
|
'Unduhan paralel dapat memicu pembatasan rate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Toko Ekstensi';
|
String get optionsExtensionStore => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi';
|
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
||||||
@@ -252,7 +274,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Copot';
|
String get extensionsUninstall => 'Copot';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Toko Ekstensi';
|
String get storeTitle => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Cari ekstensi...';
|
String get storeSearch => 'Cari ekstensi...';
|
||||||
@@ -1267,7 +1289,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Gagal memuat repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1832,6 +1854,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1843,6 +1876,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1911,6 +1947,24 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1920,6 +1974,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -2006,7 +2072,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Buka tab Repo untuk menemukan ekstensi yang berguna';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -3101,4 +3167,192 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,27 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => '同時ダウンロード';
|
String get optionsConcurrentDownloads => '同時ダウンロード';
|
||||||
|
|
||||||
@@ -1255,7 +1276,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1810,6 +1831,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return '最終スキャン: $time';
|
return '最終スキャン: $time';
|
||||||
@@ -1821,6 +1853,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'スキャン中...';
|
String get libraryScanning => 'スキャン中...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1889,6 +1924,24 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => '形式';
|
String get libraryFilterFormat => '形式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1898,6 +1951,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3078,4 +3143,192 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,27 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => '동시 다운로드';
|
String get optionsConcurrentDownloads => '동시 다운로드';
|
||||||
|
|
||||||
@@ -1241,7 +1262,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1803,6 +1824,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1814,6 +1846,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1882,6 +1917,24 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1891,6 +1944,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3071,4 +3136,192 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3091,4 +3156,192 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1997,7 +2062,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -3092,6 +3157,194 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|||||||
@@ -159,6 +159,27 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Скачивать обложку в макс. разрешении';
|
'Скачивать обложку в макс. разрешении';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Одновременные загрузки';
|
String get optionsConcurrentDownloads => 'Одновременные загрузки';
|
||||||
|
|
||||||
@@ -1282,7 +1303,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1861,6 +1882,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Последнее сканирование: $time';
|
return 'Последнее сканирование: $time';
|
||||||
@@ -1872,6 +1904,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Сканирование...';
|
String get libraryScanning => 'Сканирование...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% из $total файлов';
|
return '$progress% из $total файлов';
|
||||||
@@ -1948,6 +1983,24 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Формат';
|
String get libraryFilterFormat => 'Формат';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Сортировка';
|
String get libraryFilterSort => 'Сортировка';
|
||||||
|
|
||||||
@@ -1957,6 +2010,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Старые';
|
String get libraryFilterSortOldest => 'Старые';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Только что';
|
String get timeJustNow => 'Только что';
|
||||||
|
|
||||||
@@ -3151,4 +3216,192 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,27 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'En yüksek kalitedeki albüm kapaklarını indir';
|
'En yüksek kalitedeki albüm kapaklarını indir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
|
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
|
||||||
|
|
||||||
@@ -1267,7 +1288,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1829,6 +1850,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1840,6 +1872,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1908,6 +1943,24 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1917,6 +1970,18 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -3097,4 +3162,192 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -1261,7 +1282,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1823,6 +1844,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1834,6 +1866,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1902,6 +1937,24 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1911,6 +1964,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1997,7 +2062,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -3092,6 +3157,194 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get audioAnalysisSamples => 'Samples';
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|||||||
+396
-7
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Store",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
"homeSubtitle": "Paste a supported URL or search by name",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -190,6 +190,30 @@
|
|||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
|
"optionsArtistTagMode": "Artist Tag Mode",
|
||||||
|
"@optionsArtistTagMode": {
|
||||||
|
"description": "Setting title for how artist metadata is written into files"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
|
||||||
|
"@optionsArtistTagModeDescription": {
|
||||||
|
"description": "Bottom-sheet description for artist tag mode setting"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeJoined": "Single joined value",
|
||||||
|
"@optionsArtistTagModeJoined": {
|
||||||
|
"description": "Artist tag mode option that joins multiple artists into one value"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
|
||||||
|
"@optionsArtistTagModeJoinedSubtitle": {
|
||||||
|
"description": "Subtitle for joined artist tag mode"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
|
||||||
|
"@optionsArtistTagModeSplitVorbis": {
|
||||||
|
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
|
||||||
|
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||||
|
"description": "Subtitle for split Vorbis artist tag mode"
|
||||||
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
@@ -211,11 +235,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Repo",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,7 +342,7 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "Extension Repo",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
@@ -1654,7 +1678,7 @@
|
|||||||
"@storeNewRepoUrlLabel": {
|
"@storeNewRepoUrlLabel": {
|
||||||
"description": "Label for the new repository URL field inside the dialog"
|
"description": "Label for the new repository URL field inside the dialog"
|
||||||
},
|
},
|
||||||
"storeLoadError": "Failed to load store",
|
"storeLoadError": "Failed to load repository",
|
||||||
"@storeLoadError": {
|
"@storeLoadError": {
|
||||||
"description": "Error heading when the store cannot be loaded"
|
"description": "Error heading when the store cannot be loaded"
|
||||||
},
|
},
|
||||||
@@ -2399,6 +2423,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
|
||||||
|
"@libraryFilesUnit": {
|
||||||
|
"description": "Unit label for files count during library scanning",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2416,6 +2449,10 @@
|
|||||||
"@libraryScanning": {
|
"@libraryScanning": {
|
||||||
"description": "Status during scan"
|
"description": "Status during scan"
|
||||||
},
|
},
|
||||||
|
"libraryScanFinalizing": "Finalizing library...",
|
||||||
|
"@libraryScanFinalizing": {
|
||||||
|
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||||
|
},
|
||||||
"libraryScanProgress": "{progress}% of {total} files",
|
"libraryScanProgress": "{progress}% of {total} files",
|
||||||
"@libraryScanProgress": {
|
"@libraryScanProgress": {
|
||||||
"description": "Scan progress display",
|
"description": "Scan progress display",
|
||||||
@@ -2513,6 +2550,30 @@
|
|||||||
"@libraryFilterFormat": {
|
"@libraryFilterFormat": {
|
||||||
"description": "Filter section - file format"
|
"description": "Filter section - file format"
|
||||||
},
|
},
|
||||||
|
"libraryFilterMetadata": "Metadata",
|
||||||
|
"@libraryFilterMetadata": {
|
||||||
|
"description": "Filter section - metadata completeness"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataComplete": "Complete metadata",
|
||||||
|
"@libraryFilterMetadataComplete": {
|
||||||
|
"description": "Filter option - items with complete metadata"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingAny": "Missing any metadata",
|
||||||
|
"@libraryFilterMetadataMissingAny": {
|
||||||
|
"description": "Filter option - items missing any tracked metadata field"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingYear": "Missing year",
|
||||||
|
"@libraryFilterMetadataMissingYear": {
|
||||||
|
"description": "Filter option - items missing release year/date"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingGenre": "Missing genre",
|
||||||
|
"@libraryFilterMetadataMissingGenre": {
|
||||||
|
"description": "Filter option - items missing genre"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
|
||||||
|
"@libraryFilterMetadataMissingAlbumArtist": {
|
||||||
|
"description": "Filter option - items missing album artist"
|
||||||
|
},
|
||||||
"libraryFilterSort": "Sort",
|
"libraryFilterSort": "Sort",
|
||||||
"@libraryFilterSort": {
|
"@libraryFilterSort": {
|
||||||
"description": "Filter section - sort order"
|
"description": "Filter section - sort order"
|
||||||
@@ -2525,6 +2586,22 @@
|
|||||||
"@libraryFilterSortOldest": {
|
"@libraryFilterSortOldest": {
|
||||||
"description": "Sort option - oldest first"
|
"description": "Sort option - oldest first"
|
||||||
},
|
},
|
||||||
|
"libraryFilterSortAlbumAsc": "Album (A-Z)",
|
||||||
|
"@libraryFilterSortAlbumAsc": {
|
||||||
|
"description": "Sort option - album ascending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortAlbumDesc": "Album (Z-A)",
|
||||||
|
"@libraryFilterSortAlbumDesc": {
|
||||||
|
"description": "Sort option - album descending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortGenreAsc": "Genre (A-Z)",
|
||||||
|
"@libraryFilterSortGenreAsc": {
|
||||||
|
"description": "Sort option - genre ascending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortGenreDesc": "Genre (Z-A)",
|
||||||
|
"@libraryFilterSortGenreDesc": {
|
||||||
|
"description": "Sort option - genre descending"
|
||||||
|
},
|
||||||
"timeJustNow": "Just now",
|
"timeJustNow": "Just now",
|
||||||
"@timeJustNow": {
|
"@timeJustNow": {
|
||||||
"description": "Relative time - less than a minute ago"
|
"description": "Relative time - less than a minute ago"
|
||||||
@@ -2611,7 +2688,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -4063,5 +4140,317 @@
|
|||||||
"audioAnalysisSamples": "Samples",
|
"audioAnalysisSamples": "Samples",
|
||||||
"@audioAnalysisSamples": {
|
"@audioAnalysisSamples": {
|
||||||
"description": "Total samples metric label"
|
"description": "Total samples metric label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
|
"@extensionsSearchWith": {
|
||||||
|
"description": "Extensions page - subtitle for built-in search provider option",
|
||||||
|
"placeholders": {
|
||||||
|
"providerName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedProvider": "Home Feed Provider",
|
||||||
|
"@extensionsHomeFeedProvider": {
|
||||||
|
"description": "Extensions page - label for home feed provider selector"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
|
||||||
|
"@extensionsHomeFeedDescription": {
|
||||||
|
"description": "Extensions page - description for home feed provider picker"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAuto": "Auto",
|
||||||
|
"@extensionsHomeFeedAuto": {
|
||||||
|
"description": "Extensions page - home feed provider option: auto"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
|
||||||
|
"@extensionsHomeFeedAutoSubtitle": {
|
||||||
|
"description": "Extensions page - subtitle for auto home feed option"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedUse": "Use {extensionName} home feed",
|
||||||
|
"@extensionsHomeFeedUse": {
|
||||||
|
"description": "Extensions page - subtitle for a specific extension home feed option",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
|
||||||
|
"@extensionsNoHomeFeedExtensions": {
|
||||||
|
"description": "Extensions page - shown when no installed extension has home feed"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sortAlphaAsc": "A-Z",
|
||||||
|
"@sortAlphaAsc": {
|
||||||
|
"description": "Sort option - alphabetical ascending"
|
||||||
|
},
|
||||||
|
"sortAlphaDesc": "Z-A",
|
||||||
|
"@sortAlphaDesc": {
|
||||||
|
"description": "Sort option - alphabetical descending"
|
||||||
|
},
|
||||||
|
"cancelDownloadTitle": "Cancel download?",
|
||||||
|
"@cancelDownloadTitle": {
|
||||||
|
"description": "Dialog title when confirming cancellation of an active download"
|
||||||
|
},
|
||||||
|
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
|
||||||
|
"@cancelDownloadContent": {
|
||||||
|
"description": "Dialog body when confirming cancellation of an active download",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelDownloadKeep": "Keep",
|
||||||
|
"@cancelDownloadKeep": {
|
||||||
|
"description": "Dialog button - keep the active download (do not cancel)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
|
||||||
|
"@metadataSaveFailedFfmpeg": {
|
||||||
|
"description": "Snackbar error when FFmpeg fails to write metadata"
|
||||||
|
},
|
||||||
|
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
|
||||||
|
"@metadataSaveFailedStorage": {
|
||||||
|
"description": "Snackbar error when writing metadata file back to storage fails"
|
||||||
|
},
|
||||||
|
|
||||||
|
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
|
||||||
|
"@snackbarFolderPickerFailed": {
|
||||||
|
"description": "Snackbar shown when folder picker fails to open",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"errorLoadAlbum": "Failed to load album",
|
||||||
|
"@errorLoadAlbum": {
|
||||||
|
"description": "Error state shown when album fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadPlaylist": "Failed to load playlist",
|
||||||
|
"@errorLoadPlaylist": {
|
||||||
|
"description": "Error state shown when playlist fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadArtist": "Failed to load artist",
|
||||||
|
"@errorLoadArtist": {
|
||||||
|
"description": "Error state shown when artist fails to load"
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifChannelDownloadName": "Download Progress",
|
||||||
|
"@notifChannelDownloadName": {
|
||||||
|
"description": "Android notification channel name for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelDownloadDesc": "Shows download progress for tracks",
|
||||||
|
"@notifChannelDownloadDesc": {
|
||||||
|
"description": "Android notification channel description for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanName": "Library Scan",
|
||||||
|
"@notifChannelLibraryScanName": {
|
||||||
|
"description": "Android notification channel name for library scan"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanDesc": "Shows local library scan progress",
|
||||||
|
"@notifChannelLibraryScanDesc": {
|
||||||
|
"description": "Android notification channel description for library scan"
|
||||||
|
},
|
||||||
|
"notifDownloadingTrack": "Downloading {trackName}",
|
||||||
|
"@notifDownloadingTrack": {
|
||||||
|
"description": "Notification title while downloading a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifFinalizingTrack": "Finalizing {trackName}",
|
||||||
|
"@notifFinalizingTrack": {
|
||||||
|
"description": "Notification title while finalizing (embedding metadata) a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifEmbeddingMetadata": "Embedding metadata...",
|
||||||
|
"@notifEmbeddingMetadata": {
|
||||||
|
"description": "Notification body while embedding metadata into a downloaded track"
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
|
||||||
|
"@notifAlreadyInLibraryCount": {
|
||||||
|
"description": "Notification title when track is already in library, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibrary": "Already in Library",
|
||||||
|
"@notifAlreadyInLibrary": {
|
||||||
|
"description": "Notification title when track is already in library"
|
||||||
|
},
|
||||||
|
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
|
||||||
|
"@notifDownloadCompleteCount": {
|
||||||
|
"description": "Notification title when download is complete, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifDownloadComplete": "Download Complete",
|
||||||
|
"@notifDownloadComplete": {
|
||||||
|
"description": "Notification title when a single download is complete"
|
||||||
|
},
|
||||||
|
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
|
||||||
|
"@notifDownloadsFinished": {
|
||||||
|
"description": "Notification title when queue finishes with some failures",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAllDownloadsComplete": "All Downloads Complete",
|
||||||
|
"@notifAllDownloadsComplete": {
|
||||||
|
"description": "Notification title when all downloads finish successfully"
|
||||||
|
},
|
||||||
|
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
|
||||||
|
"@notifTracksDownloadedSuccess": {
|
||||||
|
"description": "Notification body for queue complete - how many tracks were downloaded",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifScanningLibrary": "Scanning local library",
|
||||||
|
"@notifScanningLibrary": {
|
||||||
|
"description": "Notification title while scanning local library"
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressWithTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is known",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressNoTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is unknown",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanComplete": "Library scan complete",
|
||||||
|
"@notifLibraryScanComplete": {
|
||||||
|
"description": "Notification title when library scan finishes"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCompleteBody": "{count} tracks indexed",
|
||||||
|
"@notifLibraryScanCompleteBody": {
|
||||||
|
"description": "Notification body for library scan complete - number of indexed tracks",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanExcluded": "{count} excluded",
|
||||||
|
"@notifLibraryScanExcluded": {
|
||||||
|
"description": "Library scan complete suffix - excluded track count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanErrors": "{count} errors",
|
||||||
|
"@notifLibraryScanErrors": {
|
||||||
|
"description": "Library scan complete suffix - error count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanFailed": "Library scan failed",
|
||||||
|
"@notifLibraryScanFailed": {
|
||||||
|
"description": "Notification title when library scan fails"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCancelled": "Library scan cancelled",
|
||||||
|
"@notifLibraryScanCancelled": {
|
||||||
|
"description": "Notification title when library scan is cancelled by the user"
|
||||||
|
},
|
||||||
|
"notifLibraryScanStopped": "Scan stopped before completion.",
|
||||||
|
"@notifLibraryScanStopped": {
|
||||||
|
"description": "Notification body when library scan is cancelled"
|
||||||
|
},
|
||||||
|
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
|
||||||
|
"@notifDownloadingUpdate": {
|
||||||
|
"description": "Notification title while downloading an app update",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
|
||||||
|
"@notifUpdateProgress": {
|
||||||
|
"description": "Notification body showing update download progress",
|
||||||
|
"placeholders": {
|
||||||
|
"received": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateReady": "Update Ready",
|
||||||
|
"@notifUpdateReady": {
|
||||||
|
"description": "Notification title when app update download is complete"
|
||||||
|
},
|
||||||
|
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
|
||||||
|
"@notifUpdateReadyBody": {
|
||||||
|
"description": "Notification body when app update is ready to install",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateFailed": "Update Failed",
|
||||||
|
"@notifUpdateFailed": {
|
||||||
|
"description": "Notification title when app update download fails"
|
||||||
|
},
|
||||||
|
"notifUpdateFailedBody": "Could not download update. Try again later.",
|
||||||
|
"@notifUpdateFailedBody": {
|
||||||
|
"description": "Notification body when app update download fails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Toko",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
|
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Toko Ekstensi",
|
"optionsExtensionStore": "Repo Ekstensi",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,10 +318,14 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Toko Ekstensi",
|
"storeTitle": "Repo Ekstensi",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
|
"storeLoadError": "Gagal memuat repo",
|
||||||
|
"@storeLoadError": {
|
||||||
|
"description": "Error heading when the store cannot be loaded"
|
||||||
|
},
|
||||||
"storeSearch": "Cari ekstensi...",
|
"storeSearch": "Cari ekstensi...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
@@ -2459,7 +2463,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
|
|
||||||
part 'settings.g.dart';
|
part 'settings.g.dart';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ class AppSettings {
|
|||||||
final String downloadTreeUri; // SAF persistable tree URI
|
final String downloadTreeUri; // SAF persistable tree URI
|
||||||
final bool autoFallback;
|
final bool autoFallback;
|
||||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
||||||
|
final String
|
||||||
|
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
@@ -88,6 +91,7 @@ class AppSettings {
|
|||||||
this.downloadTreeUri = '',
|
this.downloadTreeUri = '',
|
||||||
this.autoFallback = true,
|
this.autoFallback = true,
|
||||||
this.embedMetadata = true,
|
this.embedMetadata = true,
|
||||||
|
this.artistTagMode = artistTagModeJoined,
|
||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
@@ -152,6 +156,7 @@ class AppSettings {
|
|||||||
String? downloadTreeUri,
|
String? downloadTreeUri,
|
||||||
bool? autoFallback,
|
bool? autoFallback,
|
||||||
bool? embedMetadata,
|
bool? embedMetadata,
|
||||||
|
String? artistTagMode,
|
||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
@@ -210,6 +215,7 @@ class AppSettings {
|
|||||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||||
autoFallback: autoFallback ?? this.autoFallback,
|
autoFallback: autoFallback ?? this.autoFallback,
|
||||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||||
|
artistTagMode: artistTagMode ?? this.artistTagMode,
|
||||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||||
|
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
|
||||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
@@ -93,6 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'downloadTreeUri': instance.downloadTreeUri,
|
'downloadTreeUri': instance.downloadTreeUri,
|
||||||
'autoFallback': instance.autoFallback,
|
'autoFallback': instance.autoFallback,
|
||||||
'embedMetadata': instance.embedMetadata,
|
'embedMetadata': instance.embedMetadata,
|
||||||
|
'artistTagMode': instance.artistTagMode,
|
||||||
'embedLyrics': instance.embedLyrics,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
|
|||||||
@@ -1559,7 +1559,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
if (status == 'finalizing' && bytesTotal > 0) {
|
if (status == 'finalizing') {
|
||||||
progressUpdates[itemId] = const _ProgressUpdate(
|
progressUpdates[itemId] = const _ProgressUpdate(
|
||||||
status: DownloadStatus.finalizing,
|
status: DownloadStatus.finalizing,
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
@@ -2996,6 +2996,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? coverPath
|
? coverPath
|
||||||
: null,
|
: null,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
@@ -3328,6 +3329,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? coverPath
|
? coverPath
|
||||||
: null,
|
: null,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
@@ -3802,6 +3804,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
var quality = item.qualityOverride ?? state.audioQuality;
|
var quality = item.qualityOverride ?? state.audioQuality;
|
||||||
|
if (quality == 'DEFAULT') quality = state.audioQuality;
|
||||||
final isSafMode = _isSafMode(settings);
|
final isSafMode = _isSafMode(settings);
|
||||||
final relativeOutputDir = isSafMode
|
final relativeOutputDir = isSafMode
|
||||||
? await _buildRelativeOutputDir(
|
? await _buildRelativeOutputDir(
|
||||||
@@ -4214,6 +4217,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
embedMetadata: metadataEmbeddingEnabled,
|
embedMetadata: metadataEmbeddingEnabled,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
|
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
|
||||||
embedMaxQualityCover:
|
embedMaxQualityCover:
|
||||||
metadataEmbeddingEnabled && settings.maxQualityCover,
|
metadataEmbeddingEnabled && settings.maxQualityCover,
|
||||||
@@ -4358,7 +4362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
|
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
|
||||||
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
|
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9);
|
||||||
|
|
||||||
if (effectiveSafMode && isContentUri(filePath)) {
|
if (effectiveSafMode && isContentUri(filePath)) {
|
||||||
final currentFilePath = filePath;
|
final currentFilePath = filePath;
|
||||||
@@ -4503,7 +4507,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.95,
|
progress: 0.95,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4524,7 +4528,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Embedding metadata to $format...');
|
_log.i('Embedding metadata to $format...');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.99,
|
progress: 0.99,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4608,7 +4612,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} else {
|
} else {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.95,
|
progress: 0.95,
|
||||||
);
|
);
|
||||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||||
@@ -4684,7 +4688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.95,
|
progress: 0.95,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4711,7 +4715,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Embedding metadata to $format...');
|
_log.i('Embedding metadata to $format...');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.99,
|
progress: 0.99,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4765,7 +4769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} else {
|
} else {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.95,
|
progress: 0.95,
|
||||||
);
|
);
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
final flacPath = await FFmpegService.convertM4aToFlac(
|
||||||
@@ -4849,7 +4853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.99,
|
progress: 0.99,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4930,7 +4934,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.finalizing,
|
||||||
progress: 0.99,
|
progress: 0.99,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance();
|
|||||||
class LocalLibraryState {
|
class LocalLibraryState {
|
||||||
final List<LocalLibraryItem> items;
|
final List<LocalLibraryItem> items;
|
||||||
final bool isScanning;
|
final bool isScanning;
|
||||||
|
final bool scanIsFinalizing;
|
||||||
final double scanProgress;
|
final double scanProgress;
|
||||||
final String? scanCurrentFile;
|
final String? scanCurrentFile;
|
||||||
final int scanTotalFiles;
|
final int scanTotalFiles;
|
||||||
@@ -35,6 +36,7 @@ class LocalLibraryState {
|
|||||||
LocalLibraryState({
|
LocalLibraryState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
this.isScanning = false,
|
this.isScanning = false,
|
||||||
|
this.scanIsFinalizing = false,
|
||||||
this.scanProgress = 0,
|
this.scanProgress = 0,
|
||||||
this.scanCurrentFile,
|
this.scanCurrentFile,
|
||||||
this.scanTotalFiles = 0,
|
this.scanTotalFiles = 0,
|
||||||
@@ -85,6 +87,7 @@ class LocalLibraryState {
|
|||||||
LocalLibraryState copyWith({
|
LocalLibraryState copyWith({
|
||||||
List<LocalLibraryItem>? items,
|
List<LocalLibraryItem>? items,
|
||||||
bool? isScanning,
|
bool? isScanning,
|
||||||
|
bool? scanIsFinalizing,
|
||||||
double? scanProgress,
|
double? scanProgress,
|
||||||
String? scanCurrentFile,
|
String? scanCurrentFile,
|
||||||
int? scanTotalFiles,
|
int? scanTotalFiles,
|
||||||
@@ -100,6 +103,7 @@ class LocalLibraryState {
|
|||||||
return LocalLibraryState(
|
return LocalLibraryState(
|
||||||
items: nextItems,
|
items: nextItems,
|
||||||
isScanning: isScanning ?? this.isScanning,
|
isScanning: isScanning ?? this.isScanning,
|
||||||
|
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
|
||||||
scanProgress: scanProgress ?? this.scanProgress,
|
scanProgress: scanProgress ?? this.scanProgress,
|
||||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||||
@@ -120,7 +124,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
static const _progressPollingInterval = Duration(milliseconds: 1200);
|
static const _progressPollingInterval = Duration(milliseconds: 350);
|
||||||
|
static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
Timer? _progressStreamBootstrapTimer;
|
Timer? _progressStreamBootstrapTimer;
|
||||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||||
@@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isScanning: true,
|
isScanning: true,
|
||||||
|
scanIsFinalizing: false,
|
||||||
scanProgress: 0,
|
scanProgress: 0,
|
||||||
scanCurrentFile: null,
|
scanCurrentFile: null,
|
||||||
scanTotalFiles: 0,
|
scanTotalFiles: 0,
|
||||||
@@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(
|
||||||
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
|
scanWasCancelled: true,
|
||||||
|
);
|
||||||
await _showScanCancelledNotification();
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
scanIsFinalizing: true,
|
||||||
|
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||||
|
scanCurrentFile: null,
|
||||||
|
);
|
||||||
|
|
||||||
final items = <LocalLibraryItem>[];
|
final items = <LocalLibraryItem>[];
|
||||||
int skippedDownloads = 0;
|
int skippedDownloads = 0;
|
||||||
for (final json in results) {
|
for (final json in results) {
|
||||||
@@ -334,11 +350,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: persistedItems,
|
items: persistedItems,
|
||||||
isScanning: false,
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
scanProgress: 100,
|
scanProgress: 100,
|
||||||
lastScannedAt: now,
|
lastScannedAt: now,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
excludedDownloadedCount: skippedDownloads,
|
excludedDownloadedCount: skippedDownloads,
|
||||||
);
|
);
|
||||||
|
await _pruneLibraryCoverCache(persistedItems);
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||||
@@ -403,11 +421,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(
|
||||||
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
|
scanWasCancelled: true,
|
||||||
|
);
|
||||||
await _showScanCancelledNotification();
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
scanIsFinalizing: true,
|
||||||
|
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||||
|
scanCurrentFile: null,
|
||||||
|
);
|
||||||
|
|
||||||
final scannedList =
|
final scannedList =
|
||||||
(result['files'] as List<dynamic>?) ??
|
(result['files'] as List<dynamic>?) ??
|
||||||
(result['scanned'] as List<dynamic>?) ??
|
(result['scanned'] as List<dynamic>?) ??
|
||||||
@@ -497,6 +525,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: items,
|
items: items,
|
||||||
isScanning: false,
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
scanProgress: 100,
|
scanProgress: 100,
|
||||||
lastScannedAt: now,
|
lastScannedAt: now,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
@@ -516,7 +545,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Library scan failed: $e', e, stack);
|
_log.e('Library scan failed: $e', e, stack);
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
state = state.copyWith(
|
||||||
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
|
scanWasCancelled: false,
|
||||||
|
);
|
||||||
await _showScanFailedNotification(e.toString());
|
await _showScanFailedNotification(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (didStartSecurityAccess) {
|
if (didStartSecurityAccess) {
|
||||||
@@ -573,16 +606,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
cancelOnError: false,
|
cancelOnError: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
|
Future<void>.microtask(_requestProgressSnapshot);
|
||||||
if (_hasReceivedProgressStreamEvent) {
|
|
||||||
return;
|
_progressStreamBootstrapTimer = Timer(
|
||||||
}
|
_progressStreamBootstrapTimeout,
|
||||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
() {
|
||||||
_progressStreamSub?.cancel();
|
if (_hasReceivedProgressStreamEvent) {
|
||||||
_progressStreamSub = null;
|
return;
|
||||||
_usingProgressStream = false;
|
}
|
||||||
_startProgressPollingTimer();
|
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||||
});
|
_progressStreamSub?.cancel();
|
||||||
|
_progressStreamSub = null;
|
||||||
|
_usingProgressStream = false;
|
||||||
|
_startProgressPollingTimer();
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,20 +647,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _requestProgressSnapshot() async {
|
||||||
|
if (_isProgressPollingInFlight) return;
|
||||||
|
_isProgressPollingInFlight = true;
|
||||||
|
try {
|
||||||
|
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||||
|
await _handleLibraryScanProgress(progress);
|
||||||
|
_progressPollingErrorCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
_progressPollingErrorCount++;
|
||||||
|
if (_progressPollingErrorCount <= 3) {
|
||||||
|
_log.w('Initial library scan progress fetch failed: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||||
0.0,
|
0.0,
|
||||||
100.0,
|
100.0,
|
||||||
);
|
);
|
||||||
|
final isComplete = progress['is_complete'] == true;
|
||||||
|
final displayProgress = isComplete
|
||||||
|
? 99.0
|
||||||
|
: (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress);
|
||||||
final currentFile = progress['current_file'] as String?;
|
final currentFile = progress['current_file'] as String?;
|
||||||
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
||||||
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
||||||
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
||||||
final isComplete = progress['is_complete'] == true;
|
|
||||||
|
|
||||||
final shouldUpdateState =
|
final shouldUpdateState =
|
||||||
state.scanProgress != normalizedProgress ||
|
state.scanProgress != displayProgress ||
|
||||||
|
state.scanIsFinalizing != isComplete ||
|
||||||
state.scanCurrentFile != currentFile ||
|
state.scanCurrentFile != currentFile ||
|
||||||
state.scanTotalFiles != totalFiles ||
|
state.scanTotalFiles != totalFiles ||
|
||||||
state.scannedFiles != scannedFiles ||
|
state.scannedFiles != scannedFiles ||
|
||||||
@@ -630,8 +689,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
if (shouldUpdateState) {
|
if (shouldUpdateState) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
scanProgress: normalizedProgress,
|
scanIsFinalizing: isComplete,
|
||||||
scanCurrentFile: currentFile,
|
scanProgress: displayProgress,
|
||||||
|
scanCurrentFile: isComplete ? null : currentFile,
|
||||||
scanTotalFiles: totalFiles,
|
scanTotalFiles: totalFiles,
|
||||||
scannedFiles: scannedFiles,
|
scannedFiles: scannedFiles,
|
||||||
scanErrorCount: errorCount,
|
scanErrorCount: errorCount,
|
||||||
@@ -704,7 +764,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Cancelling library scan');
|
_log.i('Cancelling library scan');
|
||||||
_scanCancelRequested = true;
|
_scanCancelRequested = true;
|
||||||
await PlatformBridge.cancelLibraryScan();
|
await PlatformBridge.cancelLibraryScan();
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(
|
||||||
|
isScanning: false,
|
||||||
|
scanIsFinalizing: false,
|
||||||
|
scanWasCancelled: true,
|
||||||
|
);
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
await _showScanCancelledNotification();
|
await _showScanCancelledNotification();
|
||||||
}
|
}
|
||||||
@@ -815,6 +879,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Library cleared');
|
_log.i('Library cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> items) async {
|
||||||
|
try {
|
||||||
|
final appSupportDir = await getApplicationSupportDirectory();
|
||||||
|
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||||
|
if (!await libraryCoverDir.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final referencedCoverPaths = items
|
||||||
|
.map((item) => item.coverPath)
|
||||||
|
.whereType<String>()
|
||||||
|
.where((path) => path.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
var deletedCount = 0;
|
||||||
|
await for (final entity in libraryCoverDir.list(
|
||||||
|
recursive: true,
|
||||||
|
followLinks: false,
|
||||||
|
)) {
|
||||||
|
if (entity is! File || referencedCoverPaths.contains(entity.path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await entity.delete();
|
||||||
|
deletedCount++;
|
||||||
|
} catch (e) {
|
||||||
|
_log.w(
|
||||||
|
'Failed deleting stale library cover cache ${entity.path}: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
_log.i('Pruned $deletedCount stale library cover cache files');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed pruning library cover cache: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> removeItem(String id) async {
|
Future<void> removeItem(String id) async {
|
||||||
await _db.delete(id);
|
await _db.delete(id);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
@@ -260,6 +261,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setArtistTagMode(String mode) {
|
||||||
|
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
|
||||||
|
state = state.copyWith(artistTagMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setLyricsMode(String mode) {
|
void setLyricsMode(String mode) {
|
||||||
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||||
state = state.copyWith(lyricsMode: mode);
|
state = state.copyWith(lyricsMode: mode);
|
||||||
|
|||||||
@@ -538,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isSpotifyUrl =
|
state = TrackState(
|
||||||
url.contains('open.spotify.com') ||
|
isLoading: false,
|
||||||
url.contains('spotify.link') ||
|
error: 'url_not_recognized',
|
||||||
url.startsWith('spotify:');
|
hasSearchText: state.hasSearchText,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
|
|||||||
@@ -174,42 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
|
||||||
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata(
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
'album',
|
'album',
|
||||||
deezerAlbumId,
|
deezerAlbumId,
|
||||||
);
|
);
|
||||||
|
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:')) {
|
} else if (widget.albumId.startsWith('qobuz:')) {
|
||||||
final qobuzAlbumId = widget.albumId.replaceFirst('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:')) {
|
} else if (widget.albumId.startsWith('tidal:')) {
|
||||||
final tidalAlbumId = widget.albumId.replaceFirst('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 {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
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 trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = result['album'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -343,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
headerImage = artistData['header_image'] as String?;
|
headerImage = artistData['header_image'] as String?;
|
||||||
listeners = artistData['listeners'] as int?;
|
listeners = artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
|
throw StateError('Failed to load artist metadata from extension');
|
||||||
url,
|
|
||||||
);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList
|
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct Spotify metadata
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
||||||
if (metadata['tracks'] != null) {
|
|
||||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
|
||||||
return tracksList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
@@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
@@ -1230,6 +1235,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1335,6 +1341,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
+11
-12
@@ -2313,7 +2313,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
error.contains('429') ||
|
error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
error.toLowerCase().contains('too many requests');
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
final isUrlNotRecognized = error == 'url_not_recognized';
|
final isUrlNotRecognized = error == 'url_not_recognized';
|
||||||
|
|
||||||
if (isRateLimit) {
|
if (isRateLimit) {
|
||||||
@@ -3087,7 +3086,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
|
|
||||||
if (!extState.isInitialized) {
|
if (!extState.isInitialized) {
|
||||||
return 'Paste Spotify URL or search...';
|
return 'Paste supported URL or search...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||||
@@ -3108,7 +3107,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return 'Search with ${ext.displayName}...';
|
return 'Search with ${ext.displayName}...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'Paste Spotify URL or search...';
|
return 'Paste supported URL or search...';
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchFilterBar(
|
Widget _buildSearchFilterBar(
|
||||||
@@ -3125,7 +3124,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
label: const Text('All'),
|
label: Text(context.l10n.historyFilterAll),
|
||||||
selected: selectedFilter == null,
|
selected: selectedFilter == null,
|
||||||
onSelected: (_) {
|
onSelected: (_) {
|
||||||
ref.read(trackProvider.notifier).setSearchFilter(null);
|
ref.read(trackProvider.notifier).setSearchFilter(null);
|
||||||
@@ -4213,7 +4212,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Failed to load album';
|
_error = context.l10n.errorLoadAlbum;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -4222,7 +4221,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
final trackList = result['tracks'] as List<dynamic>?;
|
final trackList = result['tracks'] as List<dynamic>?;
|
||||||
if (trackList == null) {
|
if (trackList == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'No tracks found';
|
_error = context.l10n.errorNoTracksFound;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -4244,7 +4243,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Error: $e';
|
_error = context.l10n.snackbarError(e.toString());
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4377,7 +4376,7 @@ class _ExtensionPlaylistScreenState
|
|||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Failed to load playlist';
|
_error = context.l10n.errorLoadPlaylist;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -4386,7 +4385,7 @@ class _ExtensionPlaylistScreenState
|
|||||||
final trackList = result['tracks'] as List<dynamic>?;
|
final trackList = result['tracks'] as List<dynamic>?;
|
||||||
if (trackList == null) {
|
if (trackList == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'No tracks found';
|
_error = context.l10n.errorNoTracksFound;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -4403,7 +4402,7 @@ class _ExtensionPlaylistScreenState
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Error: $e';
|
_error = context.l10n.snackbarError(e.toString());
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4529,7 +4528,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Failed to load artist';
|
_error = context.l10n.errorLoadArtist;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -4563,7 +4562,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Error: $e';
|
_error = context.l10n.snackbarError(e.toString());
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -1236,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
trailing: isSelectionMode
|
trailing: isSelectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: historyItem != null || localItem != null
|
||||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
? IconButton(
|
||||||
icon: Icon(
|
tooltip: context.l10n.tooltipPlay,
|
||||||
Icons.more_vert,
|
onPressed: () {
|
||||||
color: colorScheme.onSurfaceVariant,
|
ref
|
||||||
size: 20,
|
.read(playbackProvider.notifier)
|
||||||
),
|
.playTrackList([track]);
|
||||||
onPressed: () => _showTrackOptionsSheet(context, ref),
|
},
|
||||||
),
|
icon: Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onTap: isSelectionMode
|
onTap: isSelectionMode
|
||||||
? onTap
|
? onTap
|
||||||
: () {
|
: () {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
@@ -816,6 +817,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
lowerPath.endsWith('.opus') ||
|
lowerPath.endsWith('.opus') ||
|
||||||
lowerPath.endsWith('.ogg');
|
lowerPath.endsWith('.ogg');
|
||||||
|
|
||||||
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||||
String? ffmpegResult;
|
String? ffmpegResult;
|
||||||
if (isMp3) {
|
if (isMp3) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||||
@@ -834,6 +836,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
opusPath: ffmpegTarget,
|
opusPath: ffmpegTarget,
|
||||||
coverPath: effectiveCoverPath,
|
coverPath: effectiveCoverPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,11 +869,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
||||||
final durationMs = (item.duration ?? 0) * 1000;
|
final durationMs = (item.duration ?? 0) * 1000;
|
||||||
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||||
final request = <String, dynamic>{
|
final request = <String, dynamic>{
|
||||||
'file_path': item.filePath,
|
'file_path': item.filePath,
|
||||||
'cover_url': '',
|
'cover_url': '',
|
||||||
'max_quality': true,
|
'max_quality': true,
|
||||||
'embed_lyrics': true,
|
'embed_lyrics': true,
|
||||||
|
'artist_tag_mode': artistTagMode,
|
||||||
'spotify_id': '',
|
'spotify_id': '',
|
||||||
'track_name': item.trackName,
|
'track_name': item.trackName,
|
||||||
'artist_name': item.artistName,
|
'artist_name': item.artistName,
|
||||||
@@ -957,16 +962,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
var skippedCount = 0;
|
var skippedCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
|
|
||||||
for (var i = 0; i < total; i++) {
|
var cancelled = false;
|
||||||
if (!mounted) break;
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.queueFlacAction,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
for (var i = 0; i < total; i++) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!mounted || cancelled) break;
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||||
@@ -987,7 +998,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedTracks.isEmpty) {
|
if (matchedTracks.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -1063,18 +1076,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
var successCount = 0;
|
var successCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackReEnrichProgress,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.auto_fix_high,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (var i = 0; i < total; i++) {
|
for (var i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
current: i + 1,
|
||||||
SnackBar(
|
detail: '${item.trackName} - ${item.artistName}',
|
||||||
content: Text(
|
|
||||||
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1114,6 +1134,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
final failedCount = total - successCount;
|
final failedCount = total - successCount;
|
||||||
final summary = failedCount <= 0
|
final summary = failedCount <= 0
|
||||||
@@ -1422,19 +1445,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
@@ -1487,6 +1514,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
|
artistTagMode: settings.artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1621,6 +1649,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
+37
-29
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
import 'package:spotiflac_android/screens/repo_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/update_checker.dart';
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
@@ -44,8 +45,14 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
ShellNavigationService.homeTabNavigatorKey;
|
ShellNavigationService.homeTabNavigatorKey;
|
||||||
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
||||||
ShellNavigationService.libraryTabNavigatorKey;
|
ShellNavigationService.libraryTabNavigatorKey;
|
||||||
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
|
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
|
||||||
ShellNavigationService.storeTabNavigatorKey;
|
ShellNavigationService.repoTabNavigatorKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
NotificationService().updateStrings(context.l10n);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -58,7 +65,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: false,
|
showRepoTab: false,
|
||||||
);
|
);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
@@ -268,7 +275,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
// Jump directly when skipping intermediate tabs to avoid
|
// Jump directly when skipping intermediate tabs to avoid
|
||||||
@@ -295,17 +302,17 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleBackPress() {
|
Future<void> _handleBackPress() async {
|
||||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||||
if (rootNavigator.canPop()) {
|
final handledByRootNavigator = await rootNavigator.maybePop();
|
||||||
_log.i('Back: step 1 - root navigator pop');
|
if (handledByRootNavigator) {
|
||||||
rootNavigator.pop();
|
_log.i('Back: step 1 - root navigator handled back');
|
||||||
_lastBackPress = null;
|
_lastBackPress = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
settingsProvider.select((s) => s.showExtensionStore),
|
settingsProvider.select((s) => s.showExtensionStore),
|
||||||
);
|
);
|
||||||
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
||||||
if (currentNavigator != null && currentNavigator.canPop()) {
|
final handledByCurrentNavigator =
|
||||||
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
|
await currentNavigator?.maybePop() ?? false;
|
||||||
currentNavigator.pop();
|
if (handledByCurrentNavigator) {
|
||||||
|
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
|
||||||
_lastBackPress = null;
|
_lastBackPress = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -413,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
||||||
if (index == 0) return _homeTabNavigatorKey.currentState;
|
if (index == 0) return _homeTabNavigatorKey.currentState;
|
||||||
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
||||||
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
|
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
final storeUpdatesCount = ref.watch(
|
final repoUpdatesCount = ref.watch(
|
||||||
storeProvider.select((s) => s.updatesAvailableCount),
|
storeProvider.select((s) => s.updatesAvailableCount),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -446,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
_TabNavigator(
|
_TabNavigator(
|
||||||
key: const ValueKey('tab-store'),
|
key: const ValueKey('tab-repo'),
|
||||||
navigatorKey: _storeTabNavigatorKey,
|
navigatorKey: _repoTabNavigatorKey,
|
||||||
child: const StoreTab(),
|
child: const RepoTab(),
|
||||||
),
|
),
|
||||||
const SettingsTab(),
|
const SettingsTab(),
|
||||||
];
|
];
|
||||||
@@ -484,20 +492,20 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
if (showStore)
|
if (showStore)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: AnimatedBadge(
|
icon: AnimatedBadge(
|
||||||
count: storeUpdatesCount,
|
count: repoUpdatesCount,
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
isLabelVisible: repoUpdatesCount > 0,
|
||||||
label: Text('$storeUpdatesCount'),
|
label: Text('$repoUpdatesCount'),
|
||||||
child: const Icon(Icons.store_outlined),
|
child: const Icon(Icons.extension_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selectedIcon: SwingIcon(
|
selectedIcon: BouncingIcon(
|
||||||
child: AnimatedBadge(
|
child: AnimatedBadge(
|
||||||
count: storeUpdatesCount,
|
count: repoUpdatesCount,
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
isLabelVisible: repoUpdatesCount > 0,
|
||||||
label: Text('$storeUpdatesCount'),
|
label: Text('$repoUpdatesCount'),
|
||||||
child: const Icon(Icons.store),
|
child: const Icon(Icons.extension),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -522,7 +530,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
|||||||
|
|
||||||
return BackButtonListener(
|
return BackButtonListener(
|
||||||
onBackButtonPressed: () async {
|
onBackButtonPressed: () async {
|
||||||
_handleBackPress();
|
await _handleBackPress();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|||||||
+740
-64
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,14 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|||||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
class StoreTab extends ConsumerStatefulWidget {
|
class RepoTab extends ConsumerStatefulWidget {
|
||||||
const StoreTab({super.key});
|
const RepoTab({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<StoreTab> createState() => _StoreTabState();
|
ConsumerState<RepoTab> createState() => _RepoTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreTabState extends ConsumerState<StoreTab> {
|
class _RepoTabState extends ConsumerState<RepoTab> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
final _repoUrlController = TextEditingController();
|
final _repoUrlController = TextEditingController();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -323,7 +323,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.store_outlined,
|
Icons.extension_outlined,
|
||||||
size: 72,
|
size: 72,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -1359,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
if (ctx.mounted) {
|
if (ctx.mounted) {
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to open folder picker: $e'),
|
content: Text(
|
||||||
|
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
|
||||||
|
),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
(entry) => ListTile(
|
(entry) => ListTile(
|
||||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||||
title: Text(entry.value),
|
title: Text(entry.value),
|
||||||
subtitle: Text('Search with ${entry.value}'),
|
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
|
||||||
trailing: settings.searchProvider == entry.key
|
trailing: settings.searchProvider == entry.key
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
@@ -791,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
|
|
||||||
final hasAnyProvider = homeFeedProviders.isNotEmpty;
|
final hasAnyProvider = homeFeedProviders.isNotEmpty;
|
||||||
|
|
||||||
String currentProviderName = 'Auto';
|
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
|
||||||
if (settings.homeFeedProvider != null &&
|
if (settings.homeFeedProvider != null &&
|
||||||
settings.homeFeedProvider!.isNotEmpty) {
|
settings.homeFeedProvider!.isNotEmpty) {
|
||||||
final ext = homeFeedProviders
|
final ext = homeFeedProviders
|
||||||
@@ -828,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Home Feed Provider',
|
context.l10n.extensionsHomeFeedProvider,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: !hasAnyProvider ? colorScheme.outline : null,
|
color: !hasAnyProvider ? colorScheme.outline : null,
|
||||||
),
|
),
|
||||||
@@ -836,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
!hasAnyProvider
|
!hasAnyProvider
|
||||||
? 'No extensions with home feed'
|
? context.l10n.extensionsNoHomeFeedExtensions
|
||||||
: currentProviderName,
|
: currentProviderName,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -883,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Home Feed Provider',
|
ctx.l10n.extensionsHomeFeedProvider,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
@@ -892,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Choose which extension provides the home feed on the main screen',
|
ctx.l10n.extensionsHomeFeedDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -900,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
|
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
|
||||||
title: const Text('Auto'),
|
title: Text(ctx.l10n.extensionsHomeFeedAuto),
|
||||||
subtitle: const Text('Automatically select the best available'),
|
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
|
||||||
trailing:
|
trailing:
|
||||||
(settings.homeFeedProvider == null ||
|
(settings.homeFeedProvider == null ||
|
||||||
settings.homeFeedProvider!.isEmpty)
|
settings.homeFeedProvider!.isEmpty)
|
||||||
@@ -917,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
(ext) => ListTile(
|
(ext) => ListTile(
|
||||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||||
title: Text(ext.displayName),
|
title: Text(ext.displayName),
|
||||||
subtitle: Text('Use ${ext.displayName} home feed'),
|
subtitle: Text(
|
||||||
|
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
|
||||||
|
),
|
||||||
trailing: settings.homeFeedProvider == ext.id
|
trailing: settings.homeFeedProvider == ext.id
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
itemCount: libraryState.items.length,
|
itemCount: libraryState.items.length,
|
||||||
excludedDownloadedCount: libraryState.excludedDownloadedCount,
|
excludedDownloadedCount: libraryState.excludedDownloadedCount,
|
||||||
isScanning: libraryState.isScanning,
|
isScanning: libraryState.isScanning,
|
||||||
|
scanIsFinalizing: libraryState.scanIsFinalizing,
|
||||||
scanProgress: libraryState.scanProgress,
|
scanProgress: libraryState.scanProgress,
|
||||||
scanCurrentFile: libraryState.scanCurrentFile,
|
scanCurrentFile: libraryState.scanCurrentFile,
|
||||||
scanTotalFiles: libraryState.scanTotalFiles,
|
scanTotalFiles: libraryState.scanTotalFiles,
|
||||||
@@ -528,8 +529,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
children: [
|
children: [
|
||||||
if (libraryState.isScanning)
|
if (libraryState.isScanning)
|
||||||
_ScanProgressTile(
|
_ScanProgressTile(
|
||||||
|
isFinalizing: libraryState.scanIsFinalizing,
|
||||||
progress: libraryState.scanProgress,
|
progress: libraryState.scanProgress,
|
||||||
currentFile: libraryState.scanCurrentFile,
|
currentFile: libraryState.scanCurrentFile,
|
||||||
|
scannedFiles: libraryState.scannedFiles,
|
||||||
totalFiles: libraryState.scanTotalFiles,
|
totalFiles: libraryState.scanTotalFiles,
|
||||||
onCancel: _cancelScan,
|
onCancel: _cancelScan,
|
||||||
)
|
)
|
||||||
@@ -646,6 +649,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
final int itemCount;
|
final int itemCount;
|
||||||
final int excludedDownloadedCount;
|
final int excludedDownloadedCount;
|
||||||
final bool isScanning;
|
final bool isScanning;
|
||||||
|
final bool scanIsFinalizing;
|
||||||
final double scanProgress;
|
final double scanProgress;
|
||||||
final String? scanCurrentFile;
|
final String? scanCurrentFile;
|
||||||
final int scanTotalFiles;
|
final int scanTotalFiles;
|
||||||
@@ -656,6 +660,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
required this.itemCount,
|
required this.itemCount,
|
||||||
required this.excludedDownloadedCount,
|
required this.excludedDownloadedCount,
|
||||||
required this.isScanning,
|
required this.isScanning,
|
||||||
|
required this.scanIsFinalizing,
|
||||||
required this.scanProgress,
|
required this.scanProgress,
|
||||||
this.scanCurrentFile,
|
this.scanCurrentFile,
|
||||||
required this.scanTotalFiles,
|
required this.scanTotalFiles,
|
||||||
@@ -680,6 +685,11 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final showIndeterminateProgress =
|
||||||
|
isScanning &&
|
||||||
|
(scanIsFinalizing ||
|
||||||
|
scanTotalFiles <= 0 ||
|
||||||
|
(scannedFiles <= 0 && scanProgress <= 0));
|
||||||
final displayCount = isScanning
|
final displayCount = isScanning
|
||||||
? scannedFiles
|
? scannedFiles
|
||||||
: itemCount + excludedDownloadedCount;
|
: itemCount + excludedDownloadedCount;
|
||||||
@@ -798,7 +808,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
isScanning
|
isScanning
|
||||||
? context.l10n.libraryTracksUnit(scannedFiles)
|
? context.l10n.libraryFilesUnit(scannedFiles)
|
||||||
: context.l10n.libraryTracksUnit(displayCount),
|
: context.l10n.libraryTracksUnit(displayCount),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -821,14 +831,49 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isScanning && scanCurrentFile != null) ...[
|
if (isScanning) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: scanProgress / 100,
|
value: showIndeterminateProgress
|
||||||
|
? null
|
||||||
|
: scanProgress / 100,
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
scanIsFinalizing
|
||||||
|
? context.l10n.libraryScanFinalizing
|
||||||
|
: scanTotalFiles > 0
|
||||||
|
? context.l10n.libraryScanProgress(
|
||||||
|
scanProgress.toStringAsFixed(0),
|
||||||
|
scanTotalFiles,
|
||||||
|
)
|
||||||
|
: context.l10n.libraryScanning,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!scanIsFinalizing &&
|
||||||
|
scanCurrentFile != null &&
|
||||||
|
scanCurrentFile!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
scanCurrentFile!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
] else ...[
|
] else ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -865,14 +910,18 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ScanProgressTile extends StatelessWidget {
|
class _ScanProgressTile extends StatelessWidget {
|
||||||
|
final bool isFinalizing;
|
||||||
final double progress;
|
final double progress;
|
||||||
final String? currentFile;
|
final String? currentFile;
|
||||||
|
final int scannedFiles;
|
||||||
final int totalFiles;
|
final int totalFiles;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
const _ScanProgressTile({
|
const _ScanProgressTile({
|
||||||
|
required this.isFinalizing,
|
||||||
required this.progress,
|
required this.progress,
|
||||||
this.currentFile,
|
this.currentFile,
|
||||||
|
required this.scannedFiles,
|
||||||
required this.totalFiles,
|
required this.totalFiles,
|
||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
@@ -880,6 +929,8 @@ class _ScanProgressTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final showIndeterminateProgress =
|
||||||
|
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
@@ -901,10 +952,14 @@ class _ScanProgressTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.libraryScanProgress(
|
isFinalizing
|
||||||
progress.toStringAsFixed(0),
|
? context.l10n.libraryScanFinalizing
|
||||||
totalFiles,
|
: totalFiles > 0
|
||||||
),
|
? context.l10n.libraryScanProgress(
|
||||||
|
progress.toStringAsFixed(0),
|
||||||
|
totalFiles,
|
||||||
|
)
|
||||||
|
: context.l10n.libraryScanning,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -920,12 +975,14 @@ class _ScanProgressTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: progress / 100,
|
value: showIndeterminateProgress ? null : progress / 100,
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
if (currentFile != null) ...[
|
if (!isFinalizing &&
|
||||||
|
currentFile != null &&
|
||||||
|
currentFile!.trim().isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
currentFile!,
|
currentFile!,
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
entry.message,
|
entry.previewMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (entry.error != null) ...[
|
if (entry.previewError != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
entry.error!,
|
entry.previewError!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class OptionsSettingsPage extends ConsumerWidget {
|
class OptionsSettingsPage extends ConsumerWidget {
|
||||||
@@ -115,7 +116,22 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
value: settings.embedMetadata,
|
value: settings.embedMetadata,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||||
|
showDivider: settings.embedMetadata,
|
||||||
),
|
),
|
||||||
|
if (settings.embedMetadata)
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.people_alt_outlined,
|
||||||
|
title: context.l10n.optionsArtistTagMode,
|
||||||
|
subtitle: _getArtistTagModeLabel(
|
||||||
|
context,
|
||||||
|
settings.artistTagMode,
|
||||||
|
),
|
||||||
|
onTap: () => _showArtistTagModePicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.artistTagMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.image,
|
icon: Icons.image,
|
||||||
title: context.l10n.optionsMaxQualityCover,
|
title: context.l10n.optionsMaxQualityCover,
|
||||||
@@ -158,7 +174,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.store,
|
icon: Icons.extension,
|
||||||
title: context.l10n.optionsExtensionStore,
|
title: context.l10n.optionsExtensionStore,
|
||||||
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||||
value: settings.showExtensionStore,
|
value: settings.showExtensionStore,
|
||||||
@@ -236,6 +252,88 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getArtistTagModeLabel(BuildContext context, String mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case artistTagModeSplitVorbis:
|
||||||
|
return context.l10n.optionsArtistTagModeSplitVorbis;
|
||||||
|
default:
|
||||||
|
return context.l10n.optionsArtistTagModeJoined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showArtistTagModePicker(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String currentMode,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.optionsArtistTagMode,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.optionsArtistTagModeDescription,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.segment_outlined),
|
||||||
|
title: Text(context.l10n.optionsArtistTagModeJoined),
|
||||||
|
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
|
||||||
|
trailing: currentMode == artistTagModeJoined
|
||||||
|
? const Icon(Icons.check)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setArtistTagMode(artistTagModeJoined);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.library_music_outlined),
|
||||||
|
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
|
||||||
|
subtitle: Text(
|
||||||
|
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
|
||||||
|
),
|
||||||
|
trailing: currentMode == artistTagModeSplitVorbis
|
||||||
|
? const Icon(Icons.check)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setArtistTagMode(artistTagModeSplitVorbis);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showClearHistoryDialog(
|
void _showClearHistoryDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -307,9 +405,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context); // Close loading dialog
|
Navigator.pop(context); // Close loading dialog
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to open folder picker: $e'),
|
content: Text(
|
||||||
|
context.l10n.snackbarFolderPickerFailed(e.toString()),
|
||||||
|
),
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (mounted) context.go('/tutorial');
|
if (mounted) context.go('/tutorial');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
|||||||
@@ -1838,6 +1838,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||||
String? ffmpegResult;
|
String? ffmpegResult;
|
||||||
if (isMp3) {
|
if (isMp3) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||||
@@ -1856,6 +1857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
opusPath: workingPath,
|
opusPath: workingPath,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2228,6 +2230,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (!_fileExists) return;
|
if (!_fileExists) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
||||||
);
|
);
|
||||||
@@ -2238,6 +2241,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
'cover_url': _coverUrl ?? '',
|
'cover_url': _coverUrl ?? '',
|
||||||
'max_quality': true,
|
'max_quality': true,
|
||||||
'embed_lyrics': true,
|
'embed_lyrics': true,
|
||||||
|
'artist_tag_mode': artistTagMode,
|
||||||
'spotify_id': _spotifyId ?? '',
|
'spotify_id': _spotifyId ?? '',
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
@@ -2340,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
opusPath: ffmpegTarget,
|
opusPath: ffmpegTarget,
|
||||||
coverPath: effectiveCoverPath,
|
coverPath: effectiveCoverPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3554,6 +3559,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
|
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3768,6 +3774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
initialValues: initialValues,
|
initialValues: initialValues,
|
||||||
filePath: cleanFilePath,
|
filePath: cleanFilePath,
|
||||||
sourceTrackId: _spotifyId,
|
sourceTrackId: _spotifyId,
|
||||||
|
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3989,12 +3996,14 @@ class _EditMetadataSheet extends StatefulWidget {
|
|||||||
final Map<String, String> initialValues;
|
final Map<String, String> initialValues;
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String? sourceTrackId;
|
final String? sourceTrackId;
|
||||||
|
final String artistTagMode;
|
||||||
|
|
||||||
const _EditMetadataSheet({
|
const _EditMetadataSheet({
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.initialValues,
|
required this.initialValues,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
this.sourceTrackId,
|
this.sourceTrackId,
|
||||||
|
required this.artistTagMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -4875,6 +4884,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
'composer': _composerCtrl.text,
|
'composer': _composerCtrl.text,
|
||||||
'comment': _commentCtrl.text,
|
'comment': _commentCtrl.text,
|
||||||
'cover_path': _selectedCoverPath ?? '',
|
'cover_path': _selectedCoverPath ?? '',
|
||||||
|
'artist_tag_mode': widget.artistTagMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -5005,6 +5015,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
opusPath: ffmpegTarget,
|
opusPath: ffmpegTarget,
|
||||||
coverPath: existingCoverPath,
|
coverPath: existingCoverPath,
|
||||||
metadata: vorbisMap,
|
metadata: vorbisMap,
|
||||||
|
artistTagMode: widget.artistTagMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5025,9 +5036,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
if (ffmpegResult == null) {
|
if (ffmpegResult == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
|
||||||
content: Text('Failed to save metadata via FFmpeg'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
@@ -5038,9 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||||
if (!ok && mounted) {
|
if (!ok && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
|
||||||
content: Text('Failed to write metadata back to storage'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
return;
|
return;
|
||||||
@@ -5094,7 +5101,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Edit Metadata',
|
context.l10n.trackEditMetadata,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -5107,7 +5114,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(
|
||||||
|
onPressed: _save,
|
||||||
|
child: Text(context.l10n.dialogSave),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
title: l10n.tutorialExtensionsTitle,
|
title: l10n.tutorialExtensionsTitle,
|
||||||
description: l10n.tutorialExtensionsDesc,
|
description: l10n.tutorialExtensionsDesc,
|
||||||
content: _buildFeatureList(context, [
|
content: _buildFeatureList(context, [
|
||||||
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
|
(Icons.extension_rounded, l10n.tutorialExtensionsTip1),
|
||||||
(
|
(
|
||||||
Icons.add_circle_outline_rounded,
|
Icons.add_circle_outline_rounded,
|
||||||
l10n.tutorialExtensionsTip2,
|
l10n.tutorialExtensionsTip2,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class DownloadRequestPayload {
|
|||||||
final String filenameFormat;
|
final String filenameFormat;
|
||||||
final String quality;
|
final String quality;
|
||||||
final bool embedMetadata;
|
final bool embedMetadata;
|
||||||
|
final String artistTagMode;
|
||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
final bool embedMaxQualityCover;
|
final bool embedMaxQualityCover;
|
||||||
final int trackNumber;
|
final int trackNumber;
|
||||||
@@ -49,6 +50,7 @@ class DownloadRequestPayload {
|
|||||||
required this.filenameFormat,
|
required this.filenameFormat,
|
||||||
this.quality = 'LOSSLESS',
|
this.quality = 'LOSSLESS',
|
||||||
this.embedMetadata = true,
|
this.embedMetadata = true,
|
||||||
|
this.artistTagMode = 'joined',
|
||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
this.embedMaxQualityCover = true,
|
this.embedMaxQualityCover = true,
|
||||||
this.trackNumber = 1,
|
this.trackNumber = 1,
|
||||||
@@ -89,6 +91,7 @@ class DownloadRequestPayload {
|
|||||||
'filename_format': filenameFormat,
|
'filename_format': filenameFormat,
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
'embed_metadata': embedMetadata,
|
'embed_metadata': embedMetadata,
|
||||||
|
'artist_tag_mode': artistTagMode,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
@@ -133,6 +136,7 @@ class DownloadRequestPayload {
|
|||||||
filenameFormat: filenameFormat,
|
filenameFormat: filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
embedMetadata: embedMetadata,
|
embedMetadata: embedMetadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
embedLyrics: embedLyrics,
|
embedLyrics: embedLyrics,
|
||||||
embedMaxQualityCover: embedMaxQualityCover,
|
embedMaxQualityCover: embedMaxQualityCover,
|
||||||
trackNumber: trackNumber,
|
trackNumber: trackNumber,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
|||||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
@@ -887,6 +888,7 @@ class FFmpegService {
|
|||||||
required String flacPath,
|
required String flacPath,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
Map<String, String>? metadata,
|
Map<String, String>? metadata,
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
}) async {
|
}) async {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
|
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
|
||||||
@@ -911,10 +913,11 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-c:a copy ');
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
metadata.forEach((key, value) {
|
_appendVorbisMetadataToCommandBuffer(
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
cmdBuffer,
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
metadata,
|
||||||
});
|
artistTagMode: artistTagMode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdBuffer.write('"$tempOutput" -y');
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
@@ -1046,6 +1049,7 @@ class FFmpegService {
|
|||||||
required String opusPath,
|
required String opusPath,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
Map<String, String>? metadata,
|
Map<String, String>? metadata,
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
}) async {
|
}) async {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
|
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
|
||||||
@@ -1063,11 +1067,11 @@ class FFmpegService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
metadata.forEach((key, value) {
|
_appendVorbisMetadataToArguments(
|
||||||
arguments
|
arguments,
|
||||||
..add('-metadata')
|
metadata,
|
||||||
..add('$key=$value');
|
artistTagMode: artistTagMode,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -1154,8 +1158,11 @@ class FFmpegService {
|
|||||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
||||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
||||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
||||||
|
// Force the mp4 muxer when cover art is present because the default ipod
|
||||||
|
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
|
||||||
|
// causing "codec not currently supported in container" on FFmpeg 8.0+.
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
cmdBuffer.write('-c:a copy ');
|
||||||
@@ -1326,6 +1333,7 @@ class FFmpegService {
|
|||||||
required String bitrate,
|
required String bitrate,
|
||||||
required Map<String, String> metadata,
|
required Map<String, String> metadata,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final format = targetFormat.toLowerCase();
|
final format = targetFormat.toLowerCase();
|
||||||
@@ -1348,6 +1356,7 @@ class FFmpegService {
|
|||||||
inputPath: inputPath,
|
inputPath: inputPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1400,7 @@ class FFmpegService {
|
|||||||
opusPath: outputPath,
|
opusPath: outputPath,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,10 +1456,10 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
cmdBuffer.write('-map 0:a ');
|
||||||
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
|
|
||||||
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
|
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
}
|
}
|
||||||
cmdBuffer.write('-c:a alac ');
|
cmdBuffer.write('-c:a alac ');
|
||||||
cmdBuffer.write('-map_metadata -1 ');
|
cmdBuffer.write('-map_metadata -1 ');
|
||||||
@@ -1491,6 +1501,7 @@ class FFmpegService {
|
|||||||
required String inputPath,
|
required String inputPath,
|
||||||
required Map<String, String> metadata,
|
required Map<String, String> metadata,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
@@ -1515,11 +1526,11 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
cmdBuffer.write('-c:a flac -compression_level 8 ');
|
||||||
cmdBuffer.write('-map_metadata 0 ');
|
cmdBuffer.write('-map_metadata 0 ');
|
||||||
|
|
||||||
final vorbisComments = _normalizeToVorbisComments(metadata);
|
_appendVorbisMetadataToCommandBuffer(
|
||||||
for (final entry in vorbisComments.entries) {
|
cmdBuffer,
|
||||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
metadata,
|
||||||
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
artistTagMode: artistTagMode,
|
||||||
}
|
);
|
||||||
|
|
||||||
cmdBuffer.write('"$outputPath" -y');
|
cmdBuffer.write('"$outputPath" -y');
|
||||||
|
|
||||||
@@ -1617,6 +1628,86 @@ class FFmpegService {
|
|||||||
return vorbis;
|
return vorbis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void _appendVorbisMetadataToCommandBuffer(
|
||||||
|
StringBuffer cmdBuffer,
|
||||||
|
Map<String, String> metadata, {
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
|
}) {
|
||||||
|
for (final entry in _buildVorbisMetadataEntries(
|
||||||
|
metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
|
)) {
|
||||||
|
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _appendVorbisMetadataToArguments(
|
||||||
|
List<String> arguments,
|
||||||
|
Map<String, String> metadata, {
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
|
}) {
|
||||||
|
for (final entry in _buildVorbisMetadataEntries(
|
||||||
|
metadata,
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
|
)) {
|
||||||
|
arguments
|
||||||
|
..add('-metadata')
|
||||||
|
..add('${entry.key}=${entry.value}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
|
||||||
|
Map<String, String> metadata, {
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
|
}) {
|
||||||
|
final vorbis = _normalizeToVorbisComments(metadata);
|
||||||
|
final entries = <MapEntry<String, String>>[];
|
||||||
|
|
||||||
|
for (final entry in vorbis.entries) {
|
||||||
|
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
_appendVorbisArtistEntries(
|
||||||
|
entries,
|
||||||
|
'ARTIST',
|
||||||
|
vorbis['ARTIST'],
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
|
);
|
||||||
|
_appendVorbisArtistEntries(
|
||||||
|
entries,
|
||||||
|
'ALBUMARTIST',
|
||||||
|
vorbis['ALBUMARTIST'],
|
||||||
|
artistTagMode: artistTagMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _appendVorbisArtistEntries(
|
||||||
|
List<MapEntry<String, String>> entries,
|
||||||
|
String key,
|
||||||
|
String? rawValue, {
|
||||||
|
String artistTagMode = artistTagModeJoined,
|
||||||
|
}) {
|
||||||
|
final value = rawValue?.trim() ?? '';
|
||||||
|
if (value.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
|
||||||
|
entries.add(MapEntry(key, value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final artist in splitArtistTagValues(value)) {
|
||||||
|
entries.add(MapEntry(key, artist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||||
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
||||||
final m4aMap = <String, String>{};
|
final m4aMap = <String, String>{};
|
||||||
@@ -1691,6 +1782,9 @@ class FFmpegService {
|
|||||||
final key = entry.key.toUpperCase();
|
final key = entry.key.toUpperCase();
|
||||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
|
if (value.trim().isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (normalizedKey) {
|
switch (normalizedKey) {
|
||||||
case 'TITLE':
|
case 'TITLE':
|
||||||
@@ -1708,12 +1802,16 @@ class FFmpegService {
|
|||||||
case 'TRACKNUMBER':
|
case 'TRACKNUMBER':
|
||||||
case 'TRACK':
|
case 'TRACK':
|
||||||
case 'TRCK':
|
case 'TRCK':
|
||||||
id3Map['track'] = value;
|
if (value != '0') {
|
||||||
|
id3Map['track'] = value;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'DISCNUMBER':
|
case 'DISCNUMBER':
|
||||||
case 'DISC':
|
case 'DISC':
|
||||||
case 'TPOS':
|
case 'TPOS':
|
||||||
id3Map['disc'] = value;
|
if (value != '0') {
|
||||||
|
id3Map['disc'] = value;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
case 'YEAR':
|
case 'YEAR':
|
||||||
@@ -1836,8 +1934,7 @@ class FFmpegService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
|
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {}
|
||||||
}
|
|
||||||
|
|
||||||
outputPaths.add(outputPath);
|
outputPaths.add(outputPath);
|
||||||
_log.i('CUE split: track ${track.number} -> $outputFileName');
|
_log.i('CUE split: track ${track.number} -> $outputFileName');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
static final NotificationService _instance = NotificationService._internal();
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
@@ -13,6 +14,13 @@ class NotificationService {
|
|||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _notificationPermissionRequested = false;
|
bool _notificationPermissionRequested = false;
|
||||||
|
AppLocalizations? _l10n;
|
||||||
|
|
||||||
|
/// Call this from the widget tree (e.g. didChangeDependencies) whenever the
|
||||||
|
/// app locale changes so that notification strings stay in sync.
|
||||||
|
void updateStrings(AppLocalizations l10n) {
|
||||||
|
_l10n = l10n;
|
||||||
|
}
|
||||||
|
|
||||||
static const int downloadProgressId = 1;
|
static const int downloadProgressId = 1;
|
||||||
static const int updateDownloadId = 2;
|
static const int updateDownloadId = 2;
|
||||||
@@ -165,7 +173,8 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: 'Downloading $trackName',
|
title:
|
||||||
|
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
|
||||||
body: '$artistName • $percentage%',
|
body: '$artistName • $percentage%',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
@@ -208,8 +217,9 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: 'Finalizing $trackName',
|
title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
|
||||||
body: '$artistName • Embedding metadata...',
|
body:
|
||||||
|
'$artistName • ${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -226,12 +236,14 @@ class NotificationService {
|
|||||||
String title;
|
String title;
|
||||||
if (alreadyInLibrary) {
|
if (alreadyInLibrary) {
|
||||||
title = completedCount != null && totalCount != null
|
title = completedCount != null && totalCount != null
|
||||||
? 'Already in Library ($completedCount/$totalCount)'
|
? (_l10n?.notifAlreadyInLibraryCount(completedCount, totalCount) ??
|
||||||
: 'Already in Library';
|
'Already in Library ($completedCount/$totalCount)')
|
||||||
|
: (_l10n?.notifAlreadyInLibrary ?? 'Already in Library');
|
||||||
} else {
|
} else {
|
||||||
title = completedCount != null && totalCount != null
|
title = completedCount != null && totalCount != null
|
||||||
? 'Download Complete ($completedCount/$totalCount)'
|
? (_l10n?.notifDownloadCompleteCount(completedCount, totalCount) ??
|
||||||
: 'Download Complete';
|
'Download Complete ($completedCount/$totalCount)')
|
||||||
|
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
@@ -271,8 +283,9 @@ class NotificationService {
|
|||||||
if (!_isInitialized) await initialize();
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
final title = failedCount > 0
|
final title = failedCount > 0
|
||||||
? 'Downloads Finished ($completedCount done, $failedCount failed)'
|
? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
|
||||||
: 'All Downloads Complete';
|
'Downloads Finished ($completedCount done, $failedCount failed)')
|
||||||
|
: (_l10n?.notifAllDownloadsComplete ?? 'All Downloads Complete');
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
channelId,
|
channelId,
|
||||||
@@ -299,7 +312,9 @@ class NotificationService {
|
|||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: title,
|
title: title,
|
||||||
body: '$completedCount tracks downloaded successfully',
|
body:
|
||||||
|
_l10n?.notifTracksDownloadedSuccess(completedCount) ??
|
||||||
|
'$completedCount tracks downloaded successfully',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,8 +334,14 @@ class NotificationService {
|
|||||||
final clampedProgress = progress.clamp(0.0, 100.0);
|
final clampedProgress = progress.clamp(0.0, 100.0);
|
||||||
final percentage = clampedProgress.round();
|
final percentage = clampedProgress.round();
|
||||||
final progressBody = totalFiles > 0
|
final progressBody = totalFiles > 0
|
||||||
? '$scannedFiles/$totalFiles files • $percentage%'
|
? (_l10n?.notifLibraryScanProgressWithTotal(
|
||||||
: '$scannedFiles files scanned • $percentage%';
|
scannedFiles,
|
||||||
|
totalFiles,
|
||||||
|
percentage,
|
||||||
|
) ??
|
||||||
|
'$scannedFiles/$totalFiles files • $percentage%')
|
||||||
|
: (_l10n?.notifLibraryScanProgressNoTotal(scannedFiles, percentage) ??
|
||||||
|
'$scannedFiles files scanned • $percentage%');
|
||||||
final body = (currentFile != null && currentFile.isNotEmpty)
|
final body = (currentFile != null && currentFile.isNotEmpty)
|
||||||
? '$progressBody\n$currentFile'
|
? '$progressBody\n$currentFile'
|
||||||
: progressBody;
|
: progressBody;
|
||||||
@@ -355,7 +376,7 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: libraryScanId,
|
id: libraryScanId,
|
||||||
title: 'Scanning local library',
|
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
|
||||||
body: body,
|
body: body,
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
@@ -370,10 +391,15 @@ class NotificationService {
|
|||||||
|
|
||||||
final extras = <String>[];
|
final extras = <String>[];
|
||||||
if (excludedDownloadedCount > 0) {
|
if (excludedDownloadedCount > 0) {
|
||||||
extras.add('$excludedDownloadedCount excluded');
|
extras.add(
|
||||||
|
_l10n?.notifLibraryScanExcluded(excludedDownloadedCount) ??
|
||||||
|
'$excludedDownloadedCount excluded',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
extras.add('$errorCount errors');
|
extras.add(
|
||||||
|
_l10n?.notifLibraryScanErrors(errorCount) ?? '$errorCount errors',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
|
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
|
||||||
|
|
||||||
@@ -401,8 +427,9 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: libraryScanId,
|
id: libraryScanId,
|
||||||
title: 'Library scan complete',
|
title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
|
||||||
body: '$totalTracks tracks indexed$suffix',
|
body:
|
||||||
|
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -434,7 +461,7 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: libraryScanId,
|
id: libraryScanId,
|
||||||
title: 'Library scan failed',
|
title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
|
||||||
body: message,
|
body: message,
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
@@ -467,8 +494,8 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: libraryScanId,
|
id: libraryScanId,
|
||||||
title: 'Library scan cancelled',
|
title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
|
||||||
body: 'Scan stopped before completion.',
|
body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,8 +545,12 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Downloading SpotiFLAC v$version',
|
title:
|
||||||
body: '$receivedMB / $totalMB MB • $percentage%',
|
_l10n?.notifDownloadingUpdate(version) ??
|
||||||
|
'Downloading SpotiFLAC v$version',
|
||||||
|
body:
|
||||||
|
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
|
||||||
|
'$receivedMB / $totalMB MB • $percentage%',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -551,8 +582,10 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Update Ready',
|
title: _l10n?.notifUpdateReady ?? 'Update Ready',
|
||||||
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
body:
|
||||||
|
_l10n?.notifUpdateReadyBody(version) ??
|
||||||
|
'SpotiFLAC v$version downloaded. Tap to install.',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -583,8 +616,10 @@ class NotificationService {
|
|||||||
|
|
||||||
await _showSafely(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Update Failed',
|
title: _l10n?.notifUpdateFailed ?? 'Update Failed',
|
||||||
body: 'Could not download update. Try again later.',
|
body:
|
||||||
|
_l10n?.notifUpdateFailedBody ??
|
||||||
|
'Could not download update. Try again later.',
|
||||||
details: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ class PlatformBridge {
|
|||||||
static bool get supportsExtensionSystem =>
|
static bool get supportsExtensionSystem =>
|
||||||
Platform.isAndroid || Platform.isIOS;
|
Platform.isAndroid || Platform.isIOS;
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
|
||||||
_log.d('parseSpotifyUrl: $url');
|
|
||||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> checkAvailability(
|
static Future<Map<String, dynamic>> checkAvailability(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String isrc,
|
String isrc,
|
||||||
@@ -654,16 +648,6 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(
|
|
||||||
String url,
|
|
||||||
) async {
|
|
||||||
final result = await _channel.invokeMethod(
|
|
||||||
'getSpotifyMetadataWithFallback',
|
|
||||||
{'url': url},
|
|
||||||
);
|
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||||
final result = await _channel.invokeMethod('getLogs');
|
final result = await _channel.invokeMethod('getLogs');
|
||||||
final logs = jsonDecode(result as String) as List<dynamic>;
|
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
|||||||
@@ -5,25 +5,25 @@ class ShellNavigationService {
|
|||||||
GlobalKey<NavigatorState>();
|
GlobalKey<NavigatorState>();
|
||||||
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
|
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
|
||||||
GlobalKey<NavigatorState>();
|
GlobalKey<NavigatorState>();
|
||||||
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
|
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
|
||||||
GlobalKey<NavigatorState>();
|
GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
static int _currentTabIndex = 0;
|
static int _currentTabIndex = 0;
|
||||||
static bool _showStoreTab = false;
|
static bool _showRepoTab = false;
|
||||||
|
|
||||||
static void syncState({
|
static void syncState({
|
||||||
required int currentTabIndex,
|
required int currentTabIndex,
|
||||||
required bool showStoreTab,
|
required bool showRepoTab,
|
||||||
}) {
|
}) {
|
||||||
_currentTabIndex = currentTabIndex;
|
_currentTabIndex = currentTabIndex;
|
||||||
_showStoreTab = showStoreTab;
|
_showRepoTab = showRepoTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
static NavigatorState? activeTabNavigator() {
|
static NavigatorState? activeTabNavigator() {
|
||||||
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
|
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
|
||||||
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
|
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
|
||||||
if (_showStoreTab && _currentTabIndex == 2) {
|
if (_showRepoTab && _currentTabIndex == 2) {
|
||||||
return storeTabNavigatorKey.currentState;
|
return repoTabNavigatorKey.currentState;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ final RegExp _artistNameSplitPattern = RegExp(
|
|||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const artistTagModeJoined = 'joined';
|
||||||
|
const artistTagModeSplitVorbis = 'split_vorbis';
|
||||||
|
|
||||||
List<String> splitArtistNames(String rawArtists) {
|
List<String> splitArtistNames(String rawArtists) {
|
||||||
final raw = rawArtists.trim();
|
final raw = rawArtists.trim();
|
||||||
if (raw.isEmpty) return const [];
|
if (raw.isEmpty) return const [];
|
||||||
@@ -13,3 +16,25 @@ List<String> splitArtistNames(String rawArtists) {
|
|||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool shouldSplitVorbisArtistTags(String mode) {
|
||||||
|
return mode == artistTagModeSplitVorbis;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> splitArtistTagValues(String rawArtists) {
|
||||||
|
final seen = <String>{};
|
||||||
|
final values = <String>[];
|
||||||
|
for (final part in splitArtistNames(rawArtists)) {
|
||||||
|
final key = part.toLowerCase();
|
||||||
|
if (seen.add(key)) {
|
||||||
|
values.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.isNotEmpty) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmed = rawArtists.trim();
|
||||||
|
return trimmed.isEmpty ? const [] : <String>[trimmed];
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ class LogEntry {
|
|||||||
return '$h:$m:$s.$ms';
|
return '$h:$m:$s.$ms';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get previewMessage => _truncateLogText(message);
|
||||||
|
|
||||||
|
String? get previewError => error == null ? null : _truncateLogText(error!);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final errorPart = error != null ? ' | $error' : '';
|
final errorPart = error != null ? ' | $error' : '';
|
||||||
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sanitizedMessage = _truncateLogText(
|
final sanitizedMessage = _redactSensitiveText(entry.message);
|
||||||
_redactSensitiveText(entry.message),
|
|
||||||
);
|
|
||||||
final sanitizedError = entry.error != null
|
final sanitizedError = entry.error != null
|
||||||
? _truncateLogText(_redactSensitiveText(entry.error!))
|
? _redactSensitiveText(entry.error!)
|
||||||
: null;
|
: null;
|
||||||
final sanitizedEntry =
|
final sanitizedEntry =
|
||||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||||
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final level = _levelToString(event.level);
|
final level = _levelToString(event.level);
|
||||||
final message = _truncateLogText(
|
final message = _redactSensitiveText(event.lines.join('\n'));
|
||||||
_redactSensitiveText(event.lines.join('\n')),
|
|
||||||
);
|
|
||||||
|
|
||||||
LogBuffer().add(
|
LogBuffer().add(
|
||||||
LogEntry(
|
LogEntry(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,13 +161,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
return ext.qualityOptions;
|
return ext.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
// Extensions without quality options use Tidal's options as default
|
||||||
const QualityOption(
|
// since the download will fall back to built-in providers anyway.
|
||||||
id: 'DEFAULT',
|
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
|
||||||
label: 'Default Quality',
|
|
||||||
description: 'Best available',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 4.1.1+118
|
version: 4.1.3+120
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user