Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 012dcdc2dd | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 2a2d817314 | |||
| 7845ac8be5 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 939407675b | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b |
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -57,7 +58,6 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
@@ -67,7 +67,10 @@ AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,10 +28,10 @@
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -772,6 +772,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return when {
|
||||
name.endsWith(".m4a") -> ".m4a"
|
||||
name.endsWith(".mp4") -> ".mp4"
|
||||
name.endsWith(".aac") -> ".aac"
|
||||
name.endsWith(".mp3") -> ".mp3"
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
@@ -783,6 +784,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private fun extFromMimeType(mime: String?): String {
|
||||
return when (mime) {
|
||||
"audio/mp4" -> ".m4a"
|
||||
"audio/aac" -> ".aac"
|
||||
"audio/eac3" -> ".m4a"
|
||||
"audio/ac3" -> ".m4a"
|
||||
"audio/ac4" -> ".m4a"
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
@@ -1063,7 +1068,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
private val cueSiblingAudioExtensions = listOf(
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
||||
)
|
||||
|
||||
private fun getSafChildFileLookup(
|
||||
@@ -1135,7 +1140,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1435,7 +1440,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
@@ -3475,7 +3480,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} catch (_: Exception) { "" }
|
||||
val cueBaseName = cueName.substringBeforeLast('.')
|
||||
if (cueBaseName.isNotBlank()) {
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
|
||||
for (ext in commonExts) {
|
||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||
if (audioDoc != null) break
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
@@ -29,7 +30,7 @@ object NativeDownloadFinalizer {
|
||||
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
||||
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
||||
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
||||
private const val HISTORY_SCHEMA_VERSION = 8
|
||||
private const val HISTORY_SCHEMA_VERSION = 9
|
||||
private val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val activeFFmpegSessionLock = Any()
|
||||
@@ -72,6 +73,8 @@ object NativeDownloadFinalizer {
|
||||
"quality",
|
||||
"bit_depth",
|
||||
"sample_rate",
|
||||
"bitrate",
|
||||
"format",
|
||||
"genre",
|
||||
"composer",
|
||||
"label",
|
||||
@@ -95,6 +98,7 @@ object NativeDownloadFinalizer {
|
||||
".ogg",
|
||||
".wav",
|
||||
".aac",
|
||||
".mp4",
|
||||
)
|
||||
|
||||
private data class FinalizeInput(
|
||||
@@ -112,6 +116,7 @@ object NativeDownloadFinalizer {
|
||||
var bitDepth: Int?,
|
||||
var sampleRate: Int?,
|
||||
var bitrateKbps: Int? = null,
|
||||
var audioCodec: String? = null,
|
||||
var pendingExternalLrc: String? = null,
|
||||
var pendingExternalLrcFileName: String? = null,
|
||||
)
|
||||
@@ -174,6 +179,9 @@ object NativeDownloadFinalizer {
|
||||
sampleRate = optPositiveInt(result, "actual_sample_rate"),
|
||||
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
|
||||
?: optPositiveBitrateKbps(result, "actual_bitrate"),
|
||||
audioCodec = normalizeAudioCodec(
|
||||
result.optString("audio_codec", "").ifBlank { result.optString("format", "") },
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -214,6 +222,7 @@ object NativeDownloadFinalizer {
|
||||
|
||||
result.put("file_path", state.filePath)
|
||||
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
|
||||
if (state.quality.isNotBlank()) result.put("quality", state.quality)
|
||||
result.put("native_finalized", true)
|
||||
result.put("history_written", true)
|
||||
result.put("history_item", historyToJson(history))
|
||||
@@ -419,7 +428,13 @@ object NativeDownloadFinalizer {
|
||||
for ((candidateOutput, mapAudioOnly) in attempts) {
|
||||
try {
|
||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y"
|
||||
// Force the flac muxer when the target extension is
|
||||
// .flac. Without this override FFmpeg keeps the ISO-BMFF
|
||||
// stream layout, producing FLAC-in-MP4 under a .flac
|
||||
// filename which downstream native FLAC tag writers
|
||||
// cannot read.
|
||||
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
|
||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
lastOutput = result.second
|
||||
if (result.first && File(candidateOutput).exists()) {
|
||||
@@ -461,13 +476,23 @@ object NativeDownloadFinalizer {
|
||||
if (!looksLikeM4a(state.filePath, state.fileName)) return
|
||||
|
||||
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
|
||||
val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3"
|
||||
val format = when {
|
||||
tidalHighFormat.startsWith("opus") -> "opus"
|
||||
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
|
||||
else -> "mp3"
|
||||
}
|
||||
val metadataFormat = if (format == "aac") "m4a" else format
|
||||
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
|
||||
val bitrate = if (tidalHighFormat.contains("_")) {
|
||||
"${tidalHighFormat.substringAfterLast("_")}k"
|
||||
} else {
|
||||
if (format == "opus") "128k" else "320k"
|
||||
}
|
||||
val ext = if (format == "opus") ".opus" else ".mp3"
|
||||
val ext = when (format) {
|
||||
"opus" -> ".opus"
|
||||
"aac" -> ".m4a"
|
||||
else -> ".mp3"
|
||||
}
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ext)
|
||||
@@ -475,6 +500,8 @@ object NativeDownloadFinalizer {
|
||||
try {
|
||||
val command = if (format == "opus") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
|
||||
} else if (format == "aac") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
|
||||
} else {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
|
||||
}
|
||||
@@ -482,14 +509,14 @@ object NativeDownloadFinalizer {
|
||||
if (!result.first || !File(output).exists()) {
|
||||
throw IllegalStateException("HIGH conversion failed: ${result.second}")
|
||||
}
|
||||
embedBasicMetadata(context, output, input, format)
|
||||
embedBasicMetadata(context, output, input, metadataFormat)
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
if (!adoptedOutput) File(output).delete()
|
||||
if (deleteLocalInput) File(localInput).delete()
|
||||
}
|
||||
state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps"
|
||||
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
|
||||
state.bitDepth = null
|
||||
state.sampleRate = null
|
||||
}
|
||||
@@ -501,13 +528,37 @@ object NativeDownloadFinalizer {
|
||||
shouldCancel: () -> Boolean,
|
||||
) {
|
||||
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
|
||||
if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return
|
||||
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
|
||||
val forceContainerConversion = shouldForceContainerConversion(input, state)
|
||||
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
|
||||
val mayNeedContainerConversion = forceContainerConversion ||
|
||||
looksLikeM4a(state.filePath, state.fileName) ||
|
||||
state.filePath.startsWith("content://")
|
||||
if (!mayNeedContainerConversion) return
|
||||
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ".flac")
|
||||
var adoptedOutput = false
|
||||
try {
|
||||
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
|
||||
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
|
||||
if (!isLosslessAudioCodec(codec)) {
|
||||
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
|
||||
return
|
||||
}
|
||||
if (isAlreadyNativeFlac) {
|
||||
Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata")
|
||||
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
|
||||
localInput
|
||||
} else {
|
||||
File(localInput).copyTo(File(output), overwrite = true).absolutePath
|
||||
}
|
||||
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
|
||||
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
return
|
||||
}
|
||||
val result = runFFmpeg(
|
||||
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
|
||||
shouldCancel,
|
||||
@@ -633,6 +684,17 @@ object NativeDownloadFinalizer {
|
||||
|
||||
val bitDepth = optPositiveInt(metadata, "bit_depth")
|
||||
val sampleRate = optPositiveInt(metadata, "sample_rate")
|
||||
val probedCodec = normalizeAudioCodec(
|
||||
metadata.optString("audio_codec", "").ifBlank {
|
||||
metadata.optString("codec", "").ifBlank {
|
||||
metadata.optString("format", "")
|
||||
}
|
||||
}
|
||||
)
|
||||
if (probedCodec != null) {
|
||||
state.audioCodec = probedCodec
|
||||
result.put("audio_codec", probedCodec)
|
||||
}
|
||||
if (bitDepth != null) {
|
||||
state.bitDepth = bitDepth
|
||||
result.put("actual_bit_depth", bitDepth)
|
||||
@@ -643,7 +705,7 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
|
||||
?: optPositiveBitrateKbps(metadata, "bit_rate")
|
||||
if (bitrateKbps != null) {
|
||||
if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) {
|
||||
state.bitrateKbps = bitrateKbps
|
||||
result.put("bitrate", bitrateKbps)
|
||||
}
|
||||
@@ -654,6 +716,7 @@ object NativeDownloadFinalizer {
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
audioCodec = state.audioCodec,
|
||||
storedQuality = state.quality,
|
||||
)
|
||||
if (displayQuality != null) {
|
||||
@@ -691,15 +754,19 @@ object NativeDownloadFinalizer {
|
||||
bitDepth: Int?,
|
||||
sampleRate: Int?,
|
||||
bitrateKbps: Int?,
|
||||
audioCodec: String? = null,
|
||||
storedQuality: String?,
|
||||
): String? {
|
||||
val format = audioFormatForPath(filePath, fileName)
|
||||
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
|
||||
if (format == "OPUS" ||
|
||||
format == "MP3" ||
|
||||
format == "AAC" ||
|
||||
format == "EAC3" ||
|
||||
format == "AC3" ||
|
||||
format == "AC4" ||
|
||||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
||||
) {
|
||||
return if (bitrateKbps != null && bitrateKbps > 0) {
|
||||
return if (bitrateKbps != null && bitrateKbps >= 16) {
|
||||
"$format ${bitrateKbps}kbps"
|
||||
} else {
|
||||
nonPlaceholderQuality(storedQuality) ?: format
|
||||
@@ -715,6 +782,43 @@ object NativeDownloadFinalizer {
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
private fun audioFormatForCodec(codec: String?): String? {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"flac" -> "FLAC"
|
||||
"alac" -> "ALAC"
|
||||
"aac" -> "AAC"
|
||||
"eac3" -> "EAC3"
|
||||
"ac3" -> "AC3"
|
||||
"ac4" -> "AC4"
|
||||
"mp3" -> "MP3"
|
||||
"opus" -> "OPUS"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLossyAudioCodec(codec: String?): Boolean {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeAudioCodec(codec: String?): String? {
|
||||
val normalized = normalizeOptional(codec)
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
?: return null
|
||||
return when (normalized) {
|
||||
"mp4a" -> "aac"
|
||||
"ec_3" -> "eac3"
|
||||
"ac_3" -> "ac3"
|
||||
"ac_4" -> "ac4"
|
||||
"mp4" -> "m4a"
|
||||
"ogg" -> "opus"
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
||||
for (candidate in listOf(filePath, fileName)) {
|
||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
||||
@@ -730,6 +834,11 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun nonPlaceholderQuality(quality: String?): String? {
|
||||
val normalized = normalizeOptional(quality) ?: return null
|
||||
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
|
||||
if (bitrateMatch != null) {
|
||||
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
|
||||
if (bitrate != null && bitrate < 16) return null
|
||||
}
|
||||
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
||||
val placeholders = setOf(
|
||||
"best",
|
||||
@@ -1146,7 +1255,7 @@ object NativeDownloadFinalizer {
|
||||
return when (normalizeExt(File(path).extension)) {
|
||||
".mp3" -> "mp3"
|
||||
".opus", ".ogg" -> "opus"
|
||||
".m4a", ".mp4" -> "m4a"
|
||||
".m4a", ".mp4", ".aac" -> "m4a"
|
||||
else -> "flac"
|
||||
}
|
||||
}
|
||||
@@ -1294,7 +1403,7 @@ object NativeDownloadFinalizer {
|
||||
val rawName = input.request.optString("saf_file_name", "")
|
||||
.ifBlank { state.fileName }
|
||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
var base = rawName.trim()
|
||||
val lower = base.lowercase(Locale.ROOT)
|
||||
for (knownExt in knownExts) {
|
||||
@@ -1315,19 +1424,66 @@ object NativeDownloadFinalizer {
|
||||
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
|
||||
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
||||
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
val actualExt = normalizeExt(
|
||||
input.result.optString("actual_extension", "")
|
||||
.ifBlank { input.result.optString("output_extension", "") }
|
||||
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
|
||||
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
|
||||
val output = result.second
|
||||
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
|
||||
return match?.groupValues?.getOrNull(1)
|
||||
?.trim()
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the file on [path] starts with the native FLAC magic
|
||||
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
|
||||
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
|
||||
* tag writers require the raw fLaC header, so we must detect that mismatch
|
||||
* before skipping the container conversion step.
|
||||
*/
|
||||
private fun isNativeFlacFile(path: String): Boolean {
|
||||
return try {
|
||||
RandomAccessFile(path, "r").use { raf ->
|
||||
if (raf.length() < 4L) return false
|
||||
val header = ByteArray(4)
|
||||
raf.readFully(header)
|
||||
header[0] == 0x66.toByte() && // 'f'
|
||||
header[1] == 0x4C.toByte() && // 'L'
|
||||
header[2] == 0x61.toByte() && // 'a'
|
||||
header[3] == 0x43.toByte() // 'C'
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLosslessAudioCodec(codec: String): Boolean {
|
||||
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
|
||||
if (normalized.isBlank()) return false
|
||||
if (normalized.startsWith("pcm_")) return true
|
||||
return normalized in setOf(
|
||||
"alac",
|
||||
"flac",
|
||||
"wavpack",
|
||||
"ape",
|
||||
"tta",
|
||||
"mlp",
|
||||
"truehd",
|
||||
"shorten"
|
||||
)
|
||||
if (actualExt == ".m4a" || actualExt == ".mp4") return true
|
||||
}
|
||||
|
||||
val container = input.result.optString("actual_container", "")
|
||||
.ifBlank { input.result.optString("container", "") }
|
||||
.trim()
|
||||
.lowercase(Locale.ROOT)
|
||||
.removePrefix(".")
|
||||
return container == "m4a" || container == "mp4" || container == "mov" || container == "aac"
|
||||
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
|
||||
val descriptor = input.result.optJSONObject("decryption")
|
||||
return normalizeExt(
|
||||
descriptor?.optString("output_extension", "")
|
||||
?.ifBlank { input.result.optString("output_extension", "") }
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateRequestContract(request: JSONObject) {
|
||||
@@ -1541,6 +1697,10 @@ object NativeDownloadFinalizer {
|
||||
values.put("quality", state.quality)
|
||||
state.bitDepth?.let { values.put("bit_depth", it) }
|
||||
state.sampleRate?.let { values.put("sample_rate", it) }
|
||||
state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let {
|
||||
values.put("bitrate", it)
|
||||
}
|
||||
normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) }
|
||||
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
|
||||
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
|
||||
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
|
||||
@@ -1597,6 +1757,8 @@ object NativeDownloadFinalizer {
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
format TEXT,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
@@ -1612,6 +1774,8 @@ object NativeDownloadFinalizer {
|
||||
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
|
||||
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
|
||||
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
|
||||
ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER")
|
||||
ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT")
|
||||
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
|
||||
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
|
||||
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
|
||||
@@ -1983,6 +2147,8 @@ object NativeDownloadFinalizer {
|
||||
putCamel("quality", "quality")
|
||||
putCamel("bit_depth", "bitDepth")
|
||||
putCamel("sample_rate", "sampleRate")
|
||||
putCamel("bitrate", "bitrate")
|
||||
putCamel("format", "format")
|
||||
putCamel("genre", "genre")
|
||||
putCamel("composer", "composer")
|
||||
putCamel("label", "label")
|
||||
@@ -2014,11 +2180,12 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
|
||||
val value = optPositiveInt(obj, key) ?: return null
|
||||
return if (value >= 10000) {
|
||||
val kbps = if (value >= 10000) {
|
||||
Math.round(value / 1000.0).toInt()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
return if (kbps >= 16) kbps else null
|
||||
}
|
||||
|
||||
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.Locale
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
@@ -31,15 +32,15 @@ object SafDownloadHandler {
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
|
||||
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
existingDir.findFile(staleStagedFileName)?.delete()
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
@@ -55,7 +56,7 @@ object SafDownloadHandler {
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
targetDir.findFile(staleStagedFileName)?.delete()
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
@@ -89,7 +90,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
@@ -121,14 +122,14 @@ object SafDownloadHandler {
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName, actualExt)
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
@@ -212,8 +213,9 @@ object SafDownloadHandler {
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
@@ -288,13 +290,17 @@ object SafDownloadHandler {
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
@@ -304,6 +310,19 @@ object SafDownloadHandler {
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.5.0",
|
||||
"versionDate": "2026-05-06",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.0-ios-unsigned.ipa",
|
||||
"version": "4.5.5",
|
||||
"versionDate": "2026-05-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37191956
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -308,16 +308,20 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality.m4a")
|
||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
copy(sampleEntry[0:4], "alac")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
alacConfig := make([]byte, 24)
|
||||
alacConfig[5] = 24
|
||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -327,6 +331,37 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||
}
|
||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||
zeroMvhd := make([]byte, 20)
|
||||
eac3SampleEntry := make([]byte, 32)
|
||||
copy(eac3SampleEntry[0:4], "ec-3")
|
||||
eac3SampleEntry[28] = 0xBB
|
||||
eac3SampleEntry[29] = 0x80
|
||||
mdhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||
eac3QualityFile := append(
|
||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||
buildM4AAtom("moov", append(
|
||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||
eac3SampleEntry...,
|
||||
))...,
|
||||
)
|
||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
|
||||
@@ -313,6 +313,7 @@ type DownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
@@ -342,6 +343,7 @@ type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
AudioCodec string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
@@ -863,6 +865,7 @@ func buildDownloadSuccessResponse(
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
AudioCodec: result.AudioCodec,
|
||||
ActualExtension: result.ActualExtension,
|
||||
ActualContainer: result.ActualContainer,
|
||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||
@@ -920,7 +923,12 @@ func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
result.AudioCodec = quality.Codec
|
||||
if quality.Codec != "" {
|
||||
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1101,7 +1109,7 @@ func CleanupConnections() {
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
@@ -1126,9 +1134,13 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
"composer": "",
|
||||
"comment": "",
|
||||
"duration": 0,
|
||||
"format": "",
|
||||
"audio_codec": "",
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
result["format"] = "flac"
|
||||
result["audio_codec"] = "flac"
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// File may have wrong extension (e.g. opus saved as .flac).
|
||||
@@ -1161,6 +1173,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
@@ -1190,12 +1204,16 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.Codec != "" {
|
||||
result["audio_codec"] = quality.Codec
|
||||
}
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
result["format"] = "m4a"
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1227,8 +1245,17 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
result["audio_codec"] = quality.Codec
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result["format"] = format
|
||||
}
|
||||
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
||||
result["bitrate"] = quality.Bitrate
|
||||
}
|
||||
}
|
||||
} else if isMp3 {
|
||||
result["format"] = "mp3"
|
||||
result["audio_codec"] = "mp3"
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1265,6 +1292,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
} else if isOgg {
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1300,6 +1329,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
} else if isApe || isWv || isMpc {
|
||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||
result["audio_codec"] = result["format"]
|
||||
// APE, WavPack, Musepack: read APEv2 tags
|
||||
apeTag, apeErr := ReadAPETags(filePath)
|
||||
if apeErr == nil && apeTag != nil {
|
||||
@@ -1400,6 +1431,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
if err := EditFlacFields(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
@@ -1510,19 +1554,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
@@ -1532,6 +1563,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func isMP4ContainerFile(filePath string) bool {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
n, err := f.Read(header)
|
||||
if err != nil || n < 8 {
|
||||
return false
|
||||
}
|
||||
return string(header[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
||||
allowed := map[string]struct{}{
|
||||
"replaygain_track_gain": {},
|
||||
|
||||
@@ -85,6 +85,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
||||
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
||||
}
|
||||
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
|
||||
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
|
||||
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
|
||||
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
|
||||
}
|
||||
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid metadata JSON")
|
||||
}
|
||||
|
||||
@@ -367,8 +367,8 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
jar, _ := newSimpleCookieJar()
|
||||
runtime.cookieJar = jar
|
||||
}
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult
|
||||
FilePath: strings.TrimSpace(result.FilePath),
|
||||
BitDepth: result.BitDepth,
|
||||
SampleRate: result.SampleRate,
|
||||
AudioCodec: strings.TrimSpace(result.AudioCodec),
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
@@ -420,6 +421,7 @@ type ExtDownloadResult struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
|
||||
@@ -873,6 +875,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||
Title: gojaObjectString(obj, "title"),
|
||||
|
||||
@@ -140,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
||||
|
||||
return runtime
|
||||
}
|
||||
@@ -247,13 +247,18 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
// API calls can use response compression for faster metadata/search loads,
|
||||
// while media downloads keep identity transfer semantics for progress/streaming.
|
||||
transport := sharedTransport
|
||||
if compressResponses {
|
||||
transport = extensionAPITransport
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
var chunkedDownload bool
|
||||
trackItemBytes := true
|
||||
var chunkSize int64
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
@@ -151,6 +152,15 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
if trackBytes, ok := opts["trackItemBytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
}
|
||||
if chunked, ok := opts["chunked"]; ok {
|
||||
switch v := chunked.(type) {
|
||||
case bool:
|
||||
@@ -194,7 +204,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
if chunkedDownload {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
@@ -244,7 +254,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
@@ -301,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if contentLength > 0 {
|
||||
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
|
||||
} else if written > 0 {
|
||||
SetItemBytesReceived(activeItemID, written)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -313,7 +331,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
@@ -383,7 +401,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && totalSize > 0 {
|
||||
SetItemBytesTotal(activeItemID, totalSize)
|
||||
}
|
||||
@@ -526,6 +544,14 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if totalSize > 0 {
|
||||
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
|
||||
} else if totalWritten > 0 {
|
||||
SetItemBytesReceived(activeItemID, totalWritten)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -415,6 +415,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
"duration": quality.Duration,
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,24 @@ var sharedTransport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -131,6 +149,7 @@ func GetDownloadClient() *http.Client {
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
extensionAPITransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
@@ -143,6 +162,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
networkCompatibilityMu.Unlock()
|
||||
|
||||
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||
applyTLSCompatibility(extensionAPITransport, insecureTLS)
|
||||
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||
CloseIdleConnections()
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ var (
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp4": true,
|
||||
".aac": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
@@ -87,6 +89,19 @@ type scannedCueFileInfo struct {
|
||||
audioPath string
|
||||
}
|
||||
|
||||
func isLibraryStagingFile(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
return true
|
||||
}
|
||||
for ext := range supportedAudioFormats {
|
||||
if strings.HasSuffix(name, ".partial"+ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
@@ -104,6 +119,9 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if isLibraryStagingFile(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedAudioFormats[ext] {
|
||||
@@ -314,7 +332,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
case ".m4a", ".mp4", ".aac":
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
@@ -394,7 +412,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
@@ -421,12 +438,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate
|
||||
}
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result.Format = format
|
||||
if isLosslessLibraryFormat(format) {
|
||||
result.Bitrate = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func libraryFormatForM4ACodec(codec string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "alac":
|
||||
return "alac"
|
||||
case "eac3", "ec-3":
|
||||
return "eac3"
|
||||
case "ac3", "ac-3":
|
||||
return "ac3"
|
||||
case "ac4", "ac-4":
|
||||
return "ac4"
|
||||
case "aac", "mp4a":
|
||||
return "m4a"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isLosslessLibraryFormat(format string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "flac", "alac":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,6 +42,14 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
|
||||
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
|
||||
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
|
||||
if err != nil {
|
||||
@@ -50,6 +58,11 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
if len(files) < 4 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.path == legacyPartialPath || file.path == newPartialPath {
|
||||
t.Fatalf("staging file should be ignored: %#v", files)
|
||||
}
|
||||
}
|
||||
cancelCh := make(chan struct{})
|
||||
close(cancelCh)
|
||||
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
|
||||
|
||||
@@ -26,6 +26,11 @@ const (
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
LyricsProviderSpotify = "spotify"
|
||||
LyricsProviderDeezer = "deezer"
|
||||
LyricsProviderYouTube = "youtube"
|
||||
LyricsProviderKugou = "kugou"
|
||||
LyricsProviderGenius = "genius"
|
||||
)
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
@@ -68,6 +73,7 @@ type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
@@ -75,6 +81,7 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
AppleElrcWordSync: false,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
@@ -100,6 +107,11 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
LyricsProviderSpotify: true,
|
||||
LyricsProviderDeezer: true,
|
||||
LyricsProviderYouTube: true,
|
||||
LyricsProviderKugou: true,
|
||||
LyricsProviderGenius: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
@@ -130,10 +142,15 @@ func GetLyricsProviderOrder() []string {
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
|
||||
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
|
||||
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
|
||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,12 +168,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
changed := lyricsFetchOptions != normalized
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
if changed {
|
||||
globalLyricsCache.ClearAll()
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.AppleElrcWordSync,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
@@ -530,9 +553,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
@@ -542,6 +565,53 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderSpotify:
|
||||
spotifyClient := NewSpotifyLyricsClient()
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderDeezer:
|
||||
deezerClient := NewDeezerLyricsClient()
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderYouTube:
|
||||
youtubeClient := NewYouTubeLyricsClient()
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderKugou:
|
||||
kugouClient := NewKugouLyricsClient()
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderGenius:
|
||||
geniusClient := NewGeniusLyricsClient()
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
|
||||
@@ -173,25 +173,25 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
if preserveWordTiming && syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
@@ -204,13 +204,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
if preserveWordTiming && syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
@@ -230,11 +230,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
@@ -253,6 +253,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
preserveWordTiming bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
@@ -267,7 +268,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||
if err != nil {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SpotifyLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type DeezerLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type YouTubeLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type KugouLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type GeniusLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type spotifyLyricsSearchResult struct {
|
||||
TrackID string `json:"trackId"`
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type youtubeLyricsSearchResult struct {
|
||||
VideoID string `json:"videoId"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type kugouLyricsSearchResult struct {
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type geniusSearchResponse struct {
|
||||
Response struct {
|
||||
Sections []struct {
|
||||
Hits []struct {
|
||||
Type string `json:"type"`
|
||||
Result struct {
|
||||
Title string `json:"title"`
|
||||
ArtistNames string `json:"artist_names"`
|
||||
PrimaryArtistNames string `json:"primary_artist_names"`
|
||||
URL string `json:"url"`
|
||||
} `json:"result"`
|
||||
} `json:"hits"`
|
||||
} `json:"sections"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type paxsenixLyricsObject struct {
|
||||
Type string `json:"type"`
|
||||
Content []paxLyrics `json:"content"`
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
LyricsText string `json:"lyrics_text"`
|
||||
PlainLyrics string `json:"plain_lyrics"`
|
||||
}
|
||||
|
||||
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
|
||||
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewDeezerLyricsClient() *DeezerLyricsClient {
|
||||
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewYouTubeLyricsClient() *YouTubeLyricsClient {
|
||||
return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewKugouLyricsClient() *KugouLyricsClient {
|
||||
return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewGeniusLyricsClient() *GeniusLyricsClient {
|
||||
return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
|
||||
fullURL := endpoint
|
||||
if len(params) > 0 {
|
||||
fullURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return nil, fmt.Errorf("%s returned empty lyrics", provider)
|
||||
}
|
||||
return lyricsResponseFromText(lrcPayload, provider), nil
|
||||
}
|
||||
|
||||
var rawObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
|
||||
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
|
||||
var value string
|
||||
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return lyricsResponseFromText(value, provider), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload paxsenixLyricsObject
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
|
||||
switch {
|
||||
case strings.TrimSpace(payload.LyricsText) != "":
|
||||
return lyricsResponseFromText(payload.LyricsText, provider), nil
|
||||
case len(payload.Lyrics) > 0:
|
||||
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
|
||||
case len(payload.Content) > 0:
|
||||
lyricsType := payload.Type
|
||||
if lyricsType == "" {
|
||||
lyricsType = "Syllable"
|
||||
}
|
||||
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
|
||||
case strings.TrimSpace(payload.PlainLyrics) != "":
|
||||
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
|
||||
}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
|
||||
return lyricsResponseFromText(trimmed, provider), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
|
||||
}
|
||||
|
||||
func lyricsResponseFromText(text, provider string) *LyricsResponse {
|
||||
lines := parseSyncedLyrics(text)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(text)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: text,
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
return &LyricsResponse{Provider: provider, Source: provider}
|
||||
}
|
||||
|
||||
func normalizeSpotifyLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
|
||||
parts := strings.Split(raw, ":")
|
||||
raw = parts[len(parts)-1]
|
||||
}
|
||||
if strings.Contains(raw, "spotify.com/track/") {
|
||||
raw = extractSpotifyIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if regexpSpotifyTrackID.MatchString(raw) {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
|
||||
|
||||
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("spotify search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []spotifyLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode spotify search: %w", err)
|
||||
}
|
||||
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.TrackID) == "" {
|
||||
return "", fmt.Errorf("no songs found on spotify")
|
||||
}
|
||||
return strings.TrimSpace(best.TrackID), nil
|
||||
}
|
||||
|
||||
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
trackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if trackID == "" {
|
||||
var err error
|
||||
trackID, err = c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c.FetchLyricsByID(trackID)
|
||||
}
|
||||
|
||||
func normalizeDeezerLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
raw = strings.TrimSpace(raw[len("deezer:"):])
|
||||
}
|
||||
if strings.Contains(raw, "deezer.com/") {
|
||||
raw = extractDeezerIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
deezerID := normalizeDeezerLyricsID(spotifyID)
|
||||
if deezerID == "" {
|
||||
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
|
||||
}
|
||||
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
|
||||
}
|
||||
deezerID = normalizeDeezerLyricsID(resolvedID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, fmt.Errorf("deezer id unavailable")
|
||||
}
|
||||
return c.FetchLyricsByID(deezerID, true)
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("youtube search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []youtubeLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode youtube search: %w", err)
|
||||
}
|
||||
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.VideoID) == "" {
|
||||
return "", fmt.Errorf("no songs found on youtube")
|
||||
}
|
||||
return strings.TrimSpace(best.VideoID), nil
|
||||
}
|
||||
|
||||
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
videoID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", videoID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kugou search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []kugouLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode kugou search: %w", err)
|
||||
}
|
||||
best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.Hash) == "" {
|
||||
return "", fmt.Errorf("no songs found on kugou")
|
||||
}
|
||||
return strings.TrimSpace(best.Hash), nil
|
||||
}
|
||||
|
||||
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
hash, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", hash)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Kugou", false)
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("per_page", "10")
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("genius search failed: %w", err)
|
||||
}
|
||||
|
||||
var results geniusSearchResponse
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode genius search: %w", err)
|
||||
}
|
||||
|
||||
bestURL := ""
|
||||
bestScore := -1
|
||||
for _, section := range results.Response.Sections {
|
||||
for _, hit := range section.Hits {
|
||||
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
artist := hit.Result.PrimaryArtistNames
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
artist = hit.Result.ArtistNames
|
||||
}
|
||||
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestURL = strings.TrimSpace(hit.Result.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestURL == "" {
|
||||
return "", fmt.Errorf("no songs found on genius")
|
||||
}
|
||||
return bestURL, nil
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("url", geniusURL)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genius lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Genius", false)
|
||||
}
|
||||
|
||||
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
|
||||
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
|
||||
|
||||
score := 0
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && candidateDuration > 0 {
|
||||
diff := math.Abs(candidateDuration - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func parseClockDuration(value string) float64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ":")
|
||||
total := 0
|
||||
for _, part := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
total = total*60 + n
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord, true), nil
|
||||
}
|
||||
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
@@ -106,7 +106,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
|
||||
@@ -156,13 +156,27 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if err != nil || !strings.Contains(rawApple, "Syllable") {
|
||||
t.Fatalf("apple raw = %q/%v", rawApple, err)
|
||||
}
|
||||
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true)
|
||||
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true, true)
|
||||
if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" {
|
||||
t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err)
|
||||
}
|
||||
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") {
|
||||
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false, false); err != nil || !strings.Contains(plain, "Plain") {
|
||||
t.Fatalf("direct pax = %q/%v", plain, err)
|
||||
}
|
||||
lineOnly, err := formatPaxLyricsToLRC(paxJSON, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("line-only pax = %v", err)
|
||||
}
|
||||
if strings.Contains(lineOnly, "<00:") {
|
||||
t.Fatalf("line-only pax should not include inline word timing: %q", lineOnly)
|
||||
}
|
||||
elrc, err := formatPaxLyricsToLRC(paxJSON, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("elrc pax = %v", err)
|
||||
}
|
||||
if !strings.Contains(elrc, "<00:") {
|
||||
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
||||
}
|
||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||
t.Fatal("expected empty apple search error")
|
||||
}
|
||||
@@ -233,4 +247,72 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil {
|
||||
t.Fatal("expected empty QQ metadata error")
|
||||
}
|
||||
|
||||
spotify := &SpotifyLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/spotify/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"trackId":"spotify-1","name":"Song","artistName":"Artist","duration":"03:00"}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/spotify/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Spotify"`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
spotifyLyrics, err := spotify.FetchLyrics("", "Song", "Artist", 180)
|
||||
if err != nil || spotifyLyrics.Provider != "Spotify" || spotifyLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("spotify lyrics = %#v/%v", spotifyLyrics, err)
|
||||
}
|
||||
|
||||
deezer := &DeezerLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"Deezer","part":false}]}]}`)), Request: req}, nil
|
||||
})}}
|
||||
deezerLyrics, err := deezer.FetchLyricsByID("123", false)
|
||||
if err != nil || deezerLyrics.Provider != "Deezer" || deezerLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("deezer lyrics = %#v/%v", deezerLyrics, err)
|
||||
}
|
||||
|
||||
youtube := &YouTubeLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/youtube/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"videoId":"yt-1","title":"Song","author":"Artist","duration":"3:00"}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/youtube/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]YouTube"`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
youtubeLyrics, err := youtube.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || youtubeLyrics.Provider != "YouTube" || youtubeLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("youtube lyrics = %#v/%v", youtubeLyrics, err)
|
||||
}
|
||||
|
||||
kugou := &KugouLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/kugou/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"hash":"kg-1","title":"Song","artist":"Artist","duration":180}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/kugou/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics_text":"[00:01.00]Kugou"}`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
kugouLyrics, err := kugou.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err)
|
||||
}
|
||||
|
||||
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/genius/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
geniusLyrics, err := genius.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.SyncType != "UNSYNCED" {
|
||||
t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,7 +872,7 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") {
|
||||
lyrics, err := extractLyricsFromM4A(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
@@ -1578,10 +1578,12 @@ func looksLikeEmbeddedLyrics(value string) bool {
|
||||
}
|
||||
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
Duration int `json:"duration"`
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
Duration int `json:"duration"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams
|
||||
Codec string `json:"codec,omitempty"`
|
||||
}
|
||||
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
@@ -1632,6 +1634,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
Duration: duration,
|
||||
Codec: "flac",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1695,9 +1698,11 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 0
|
||||
codec := normalizeM4AAudioCodec(atomType)
|
||||
|
||||
if atomType == "alac" {
|
||||
bitDepth = int(buf[22])<<8 | int(buf[23])
|
||||
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if alacBitDepth > 0 {
|
||||
bitDepth = alacBitDepth
|
||||
@@ -1706,24 +1711,75 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
sampleRate = alacSampleRate
|
||||
}
|
||||
}
|
||||
} else if atomType == "fLaC" {
|
||||
bitDepth = int(buf[22])<<8 | int(buf[23])
|
||||
if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if flacBitDepth > 0 {
|
||||
bitDepth = flacBitDepth
|
||||
}
|
||||
if flacSampleRate > 0 {
|
||||
sampleRate = flacSampleRate
|
||||
}
|
||||
if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 {
|
||||
duration = int(flacTotalSamples / int64(sampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
bitrate := estimateAudioBitrateKbps(fileSize, duration)
|
||||
if bitrate > 0 && bitrate < 16 {
|
||||
bitrate = 0
|
||||
}
|
||||
return AudioQuality{
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Duration: duration,
|
||||
Bitrate: bitrate,
|
||||
Codec: codec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil
|
||||
func normalizeM4AAudioCodec(atomType string) string {
|
||||
switch atomType {
|
||||
case "mp4a":
|
||||
return "aac"
|
||||
case "alac":
|
||||
return "alac"
|
||||
case "fLaC":
|
||||
return "flac"
|
||||
case "ec-3":
|
||||
return "eac3"
|
||||
case "ac-3":
|
||||
return "ac3"
|
||||
case "ac-4":
|
||||
return "ac4"
|
||||
default:
|
||||
return strings.TrimSpace(atomType)
|
||||
}
|
||||
}
|
||||
|
||||
func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int {
|
||||
if fileSize <= 0 || durationSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0))
|
||||
}
|
||||
|
||||
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
|
||||
childStart := moovHeader.offset + moovHeader.headerSize
|
||||
childSize := moovHeader.size - moovHeader.headerSize
|
||||
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0
|
||||
if err == nil && found {
|
||||
if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize
|
||||
return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
|
||||
}
|
||||
|
||||
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int {
|
||||
payloadOffset := header.offset + header.headerSize
|
||||
versionBuf := make([]byte, 1)
|
||||
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
|
||||
return 0
|
||||
@@ -1754,6 +1810,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
|
||||
return int(math.Round(float64(duration) / float64(timescale)))
|
||||
}
|
||||
|
||||
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
|
||||
childStart := moovHeader.offset + moovHeader.headerSize
|
||||
childSize := moovHeader.size - moovHeader.headerSize
|
||||
bestDuration := 0
|
||||
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
|
||||
if header.typ == "mdhd" {
|
||||
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
|
||||
bestDuration = duration
|
||||
}
|
||||
return false
|
||||
}
|
||||
return header.typ == "trak" || header.typ == "mdia"
|
||||
})
|
||||
return bestDuration
|
||||
}
|
||||
|
||||
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
|
||||
if size <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := start + size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
atomSize := header.size
|
||||
if atomSize == 0 {
|
||||
atomSize = end - pos
|
||||
}
|
||||
if atomSize < header.headerSize {
|
||||
return fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
header.size = atomSize
|
||||
if visit(header) {
|
||||
childStart := header.offset + header.headerSize
|
||||
childSize := header.size - header.headerSize
|
||||
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pos += atomSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, false
|
||||
@@ -1788,6 +1891,79 @@ func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int,
|
||||
return parseALACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
childStart := sampleOffset + 32
|
||||
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
|
||||
if childStart >= childEnd {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
payloadSize := configHeader.size - configHeader.headerSize
|
||||
if payloadSize <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadSize)
|
||||
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
return parseMP4FLACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) {
|
||||
if len(payload) >= 4 && string(payload[:4]) == "fLaC" {
|
||||
payload = payload[4:]
|
||||
} else if len(payload) >= 4 {
|
||||
// FLACSpecificBox starts with a full-box version/flags field.
|
||||
payload = payload[4:]
|
||||
}
|
||||
|
||||
for len(payload) >= 4 {
|
||||
blockType := payload[0] & 0x7F
|
||||
blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
|
||||
if blockLen < 0 || len(payload) < 4+blockLen {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
block := payload[4 : 4+blockLen]
|
||||
if blockType == 0 && len(block) >= 34 {
|
||||
bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34])
|
||||
return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0
|
||||
}
|
||||
payload = payload[4+blockLen:]
|
||||
}
|
||||
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) {
|
||||
if len(streamInfo) < 18 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||
bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1
|
||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||
int64(streamInfo[14])<<24 |
|
||||
int64(streamInfo[15])<<16 |
|
||||
int64(streamInfo[16])<<8 |
|
||||
int64(streamInfo[17])
|
||||
return bitsPerSample, sampleRate, totalSamples
|
||||
}
|
||||
|
||||
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
|
||||
if len(payload) < 24 {
|
||||
return 0, 0, false
|
||||
@@ -1882,8 +2058,14 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
||||
|
||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||
const chunkSize = 64 * 1024
|
||||
patternMP4A := []byte("mp4a")
|
||||
patternALAC := []byte("alac")
|
||||
patterns := [][]byte{
|
||||
[]byte("mp4a"),
|
||||
[]byte("alac"),
|
||||
[]byte("fLaC"),
|
||||
[]byte("ec-3"),
|
||||
[]byte("ac-3"),
|
||||
[]byte("ac-4"),
|
||||
}
|
||||
|
||||
var tail []byte
|
||||
readPos := start
|
||||
@@ -1904,26 +2086,14 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
}
|
||||
|
||||
data := append(tail, buf[:n]...)
|
||||
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||
alacIdx := bytes.Index(data, patternALAC)
|
||||
|
||||
bestIdx := -1
|
||||
bestType := ""
|
||||
switch {
|
||||
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||
if mp4aIdx <= alacIdx {
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
} else {
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
for _, pattern := range patterns {
|
||||
idx := bytes.Index(data, pattern)
|
||||
if idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
|
||||
bestIdx = idx
|
||||
bestType = string(pattern)
|
||||
}
|
||||
case mp4aIdx >= 0:
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
case alacIdx >= 0:
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
|
||||
@@ -47,3 +47,53 @@ func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
|
||||
t.Fatal("expected short ALAC payload to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4ACodecFormatMapping(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"mp4a": "aac",
|
||||
"alac": "alac",
|
||||
"fLaC": "flac",
|
||||
"ec-3": "eac3",
|
||||
"ac-3": "ac3",
|
||||
"ac-4": "ac4",
|
||||
}
|
||||
for atomType, want := range cases {
|
||||
if got := normalizeM4AAudioCodec(atomType); got != want {
|
||||
t.Fatalf("normalizeM4AAudioCodec(%q) = %q, want %q", atomType, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := libraryFormatForM4ACodec("flac"); got != "flac" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(flac) = %q", got)
|
||||
}
|
||||
if got := libraryFormatForM4ACodec("eac3"); got != "eac3" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(eac3) = %q", got)
|
||||
}
|
||||
if got := libraryFormatForM4ACodec("aac"); got != "m4a" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(aac) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMP4FLACSpecificConfig(t *testing.T) {
|
||||
streamInfo := make([]byte, 34)
|
||||
sampleRate := 48000
|
||||
bitsPerSample := 24
|
||||
totalSamples := int64(48000 * 180)
|
||||
streamInfo[10] = byte(sampleRate >> 12)
|
||||
streamInfo[11] = byte(sampleRate >> 4)
|
||||
streamInfo[12] = byte((sampleRate&0x0F)<<4 | ((bitsPerSample-1)>>4)&0x01)
|
||||
streamInfo[13] = byte(((bitsPerSample-1)&0x0F)<<4 | int((totalSamples>>32)&0x0F))
|
||||
streamInfo[14] = byte(totalSamples >> 24)
|
||||
streamInfo[15] = byte(totalSamples >> 16)
|
||||
streamInfo[16] = byte(totalSamples >> 8)
|
||||
streamInfo[17] = byte(totalSamples)
|
||||
|
||||
payload := append([]byte{0, 0, 0, 0, 0, 0, 0, 34}, streamInfo...)
|
||||
bitDepth, parsedRate, parsedSamples, ok := parseMP4FLACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected MP4 FLAC config to parse")
|
||||
}
|
||||
if bitDepth != bitsPerSample || parsedRate != sampleRate || parsedSamples != totalSamples {
|
||||
t.Fatalf("FLAC config = %d/%d/%d", bitDepth, parsedRate, parsedSamples)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.5.1';
|
||||
static const String buildNumber = '128';
|
||||
static const String version = '4.5.5';
|
||||
static const String buildNumber = '132';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
@@ -17,6 +17,8 @@ class AppInfo {
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
static const String remoteConfigApiUrl =
|
||||
'https://api.zarz.moe/v1/spotiflac-mobile/config';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
|
||||
@@ -837,7 +837,7 @@ abstract class AppLocalizations {
|
||||
/// App description in header card
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
|
||||
/// **'Search music metadata, manage extensions, and organize your library.'**
|
||||
String get aboutAppDescription;
|
||||
|
||||
/// Section header for artist albums
|
||||
@@ -2286,6 +2286,12 @@ abstract class AppLocalizations {
|
||||
/// **'Copy lyrics'**
|
||||
String get trackCopyLyrics;
|
||||
|
||||
/// Label showing the lyrics source/provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Source: {source}'**
|
||||
String trackLyricsSource(String source);
|
||||
|
||||
/// Message when lyrics not found
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2838,6 +2844,18 @@ abstract class AppLocalizations {
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - AAC in M4A container at 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'AAC/M4A 320kbps'**
|
||||
String get downloadLossyAac;
|
||||
|
||||
/// Subtitle for AAC/M4A 320kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best mobile compatibility, M4A container'**
|
||||
String get downloadLossyAacSubtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4299,7 +4317,7 @@ abstract class AppLocalizations {
|
||||
/// Subtitle for convert format menu item
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||
/// **'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC'**
|
||||
String get trackConvertFormatSubtitle;
|
||||
|
||||
/// Title of convert bottom sheet
|
||||
@@ -5209,6 +5227,24 @@ abstract class AppLocalizations {
|
||||
/// **'Standard lyrics without speaker labels'**
|
||||
String get downloadAppleQqMultiPersonDisabled;
|
||||
|
||||
/// Setting for preserving Apple Music word-by-word eLRC timestamps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apple Music eLRC Word Sync'**
|
||||
String get downloadAppleElrcWordSync;
|
||||
|
||||
/// Subtitle when Apple Music eLRC word sync is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Raw word-by-word timestamps preserved'**
|
||||
String get downloadAppleElrcWordSyncEnabled;
|
||||
|
||||
/// Subtitle when Apple Music eLRC word sync is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Safer line-by-line Apple Music lyrics'**
|
||||
String get downloadAppleElrcWordSyncDisabled;
|
||||
|
||||
/// Setting for Musixmatch lyrics translation language
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5563,6 +5599,24 @@ abstract class AppLocalizations {
|
||||
/// **'Sample Rate'**
|
||||
String get audioAnalysisSampleRate;
|
||||
|
||||
/// Audio codec metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Codec'**
|
||||
String get audioAnalysisCodec;
|
||||
|
||||
/// Audio container metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Container'**
|
||||
String get audioAnalysisContainer;
|
||||
|
||||
/// Decoded sample format metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Decoded Format'**
|
||||
String get audioAnalysisDecodedFormat;
|
||||
|
||||
/// Bit depth metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5611,12 +5665,60 @@ abstract class AppLocalizations {
|
||||
/// **'RMS'**
|
||||
String get audioAnalysisRms;
|
||||
|
||||
/// Integrated loudness metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LUFS'**
|
||||
String get audioAnalysisLufs;
|
||||
|
||||
/// True peak metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'True Peak'**
|
||||
String get audioAnalysisTruePeak;
|
||||
|
||||
/// Clipping metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clipping'**
|
||||
String get audioAnalysisClipping;
|
||||
|
||||
/// Displayed when no clipped samples were detected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No clipping'**
|
||||
String get audioAnalysisNoClipping;
|
||||
|
||||
/// Estimated spectral cutoff metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spectral Cutoff'**
|
||||
String get audioAnalysisSpectralCutoff;
|
||||
|
||||
/// Per-channel audio analysis section label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Per-channel Stats'**
|
||||
String get audioAnalysisChannelStats;
|
||||
|
||||
/// Total samples metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Samples'**
|
||||
String get audioAnalysisSamples;
|
||||
|
||||
/// Tooltip/label for the button that re-runs the audio analysis, discarding cached results
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-analyze'**
|
||||
String get audioAnalysisRescan;
|
||||
|
||||
/// Loading text while audio is being re-analyzed after an explicit refresh
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-analyzing audio...'**
|
||||
String get audioAnalysisRescanning;
|
||||
|
||||
/// Extensions page - subtitle for built-in search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -6416,6 +6518,470 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which extensions can be used as fallback'**
|
||||
String get downloadFallbackExtensionsSubtitle;
|
||||
|
||||
/// Hint text for the edit metadata date field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YYYY-MM-DD or YYYY'**
|
||||
String get editMetadataFieldDateHint;
|
||||
|
||||
/// Label for total tracks field in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track Total'**
|
||||
String get editMetadataFieldTrackTotal;
|
||||
|
||||
/// Label for total discs field in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disc Total'**
|
||||
String get editMetadataFieldDiscTotal;
|
||||
|
||||
/// Label for composer field in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Composer'**
|
||||
String get editMetadataFieldComposer;
|
||||
|
||||
/// Label for comment field in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Comment'**
|
||||
String get editMetadataFieldComment;
|
||||
|
||||
/// Expandable section label for advanced metadata fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Advanced'**
|
||||
String get editMetadataAdvanced;
|
||||
|
||||
/// Filter option - items missing track number
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing track number'**
|
||||
String get libraryFilterMetadataMissingTrackNumber;
|
||||
|
||||
/// Filter option - items missing disc number
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing disc number'**
|
||||
String get libraryFilterMetadataMissingDiscNumber;
|
||||
|
||||
/// Filter option - items missing artist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing artist'**
|
||||
String get libraryFilterMetadataMissingArtist;
|
||||
|
||||
/// Filter option - items with an invalid ISRC format
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Incorrect ISRC format'**
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat;
|
||||
|
||||
/// Filter option - items missing record label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Missing label'**
|
||||
String get libraryFilterMetadataMissingLabel;
|
||||
|
||||
/// Confirmation message for deleting selected playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete {count} {count, plural, =1{playlist} other{playlists}}?'**
|
||||
String collectionDeletePlaylistsMessage(int count);
|
||||
|
||||
/// Snackbar after deleting selected playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} {count, plural, =1{playlist} other{playlists}} deleted'**
|
||||
String collectionPlaylistsDeleted(int count);
|
||||
|
||||
/// Snackbar after adding multiple tracks to a playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}'**
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName);
|
||||
|
||||
/// Snackbar after adding multiple tracks to a playlist when some were already present
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)'**
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
);
|
||||
|
||||
/// Generic item count label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} {count, plural, =1{item} other{items}}'**
|
||||
String itemCount(int count);
|
||||
|
||||
/// Snackbar summary after batch metadata re-enrichment finishes with failures
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}'**
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
);
|
||||
|
||||
/// Button label for deleting selected tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete {count} {count, plural, =1{track} other{tracks}}'**
|
||||
String selectionDeleteTracksCount(int count);
|
||||
|
||||
/// Queue status while downloading with speed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading - {speed} MB/s'**
|
||||
String queueDownloadSpeedStatus(String speed);
|
||||
|
||||
/// Queue status before download progress is available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Starting...'**
|
||||
String get queueDownloadStarting;
|
||||
|
||||
/// Accessibility label for selecting a track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select track'**
|
||||
String get a11ySelectTrack;
|
||||
|
||||
/// Accessibility label for deselecting a track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deselect track'**
|
||||
String get a11yDeselectTrack;
|
||||
|
||||
/// Accessibility label for playing a local library track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play {trackName} by {artistName}'**
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName);
|
||||
|
||||
/// Store extension result count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} {count, plural, =1{extension} other{extensions}}'**
|
||||
String storeExtensionsCount(int count);
|
||||
|
||||
/// Store compatibility badge for minimum app version
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Requires v{version}+'**
|
||||
String storeRequiresVersion(String version);
|
||||
|
||||
/// Generic action button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Go'**
|
||||
String get actionGo;
|
||||
|
||||
/// Header for log issue analysis summary
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Issue Summary'**
|
||||
String get logIssueSummary;
|
||||
|
||||
/// Total error count in log issue analysis
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total errors: {count}'**
|
||||
String logTotalErrors(int count);
|
||||
|
||||
/// Affected domains in log issue analysis
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Affected: {domains}'**
|
||||
String logAffectedDomains(String domains);
|
||||
|
||||
/// Library scan status when a scan was cancelled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan cancelled'**
|
||||
String get libraryScanCancelled;
|
||||
|
||||
/// Library scan status subtitle after cancellation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can retry the scan when ready.'**
|
||||
String get libraryScanCancelledSubtitle;
|
||||
|
||||
/// Library count note for downloaded history items excluded from the local list
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} from Downloads history (excluded from list)'**
|
||||
String libraryDownloadsHistoryExcluded(int count);
|
||||
|
||||
/// Setting title for Android native download worker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Native download worker'**
|
||||
String get downloadNativeWorker;
|
||||
|
||||
/// Setting subtitle for Android native download worker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Beta Android service worker for extension downloads'**
|
||||
String get downloadNativeWorkerSubtitle;
|
||||
|
||||
/// Badge label for beta features
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BETA'**
|
||||
String get badgeBeta;
|
||||
|
||||
/// Extension detail section header for service status
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service Status'**
|
||||
String get extensionServiceStatus;
|
||||
|
||||
/// Extension capability label for service health checks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service health'**
|
||||
String get extensionServiceHealth;
|
||||
|
||||
/// Extension service health check count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} {count, plural, =1{check} other{checks}} configured'**
|
||||
String extensionHealthChecksConfigured(int count);
|
||||
|
||||
/// Hint for an OAuth login link field before connecting Spotify
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap Connect to Spotify to fill this field.'**
|
||||
String get extensionOauthConnectHint;
|
||||
|
||||
/// Timestamp for the latest extension service health check
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last checked {time}'**
|
||||
String extensionLastChecked(String time);
|
||||
|
||||
/// Tooltip for refreshing extension service health status
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh status'**
|
||||
String get extensionRefreshStatus;
|
||||
|
||||
/// Extension detail section title for custom URL handling
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Custom URL Handling'**
|
||||
String get extensionCustomUrlHandling;
|
||||
|
||||
/// Extension detail subtitle for custom URL handling
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This extension can handle links from these sites'**
|
||||
String get extensionCustomUrlHandlingSubtitle;
|
||||
|
||||
/// Extension detail hint explaining share-to-app URL handling
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.'**
|
||||
String get extensionCustomUrlHandlingShareHint;
|
||||
|
||||
/// Count of settings exposed by an extension quality option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} {count, plural, =1{setting} other{settings}}'**
|
||||
String extensionSettingsCount(int count);
|
||||
|
||||
/// Extension service health status - online
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Online'**
|
||||
String get extensionHealthOnline;
|
||||
|
||||
/// Extension service health status - degraded
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Degraded'**
|
||||
String get extensionHealthDegraded;
|
||||
|
||||
/// Extension service health status - offline
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline'**
|
||||
String get extensionHealthOffline;
|
||||
|
||||
/// Extension service health status - not configured
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not configured'**
|
||||
String get extensionHealthNotConfigured;
|
||||
|
||||
/// Extension service health status - unknown
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown'**
|
||||
String get extensionHealthUnknown;
|
||||
|
||||
/// Label for a required extension service health check
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'required'**
|
||||
String get extensionHealthRequired;
|
||||
|
||||
/// Value shown when an extension setting has no value
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not set'**
|
||||
String get extensionSettingNotSet;
|
||||
|
||||
/// Fallback error when an extension action fails without details
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Action failed'**
|
||||
String get extensionActionFailed;
|
||||
|
||||
/// Hint for editing an extension setting value
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter value'**
|
||||
String get extensionEnterValue;
|
||||
|
||||
/// Tooltip for online extension service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service online'**
|
||||
String get extensionHealthServiceOnline;
|
||||
|
||||
/// Tooltip for degraded extension service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service degraded'**
|
||||
String get extensionHealthServiceDegraded;
|
||||
|
||||
/// Tooltip for offline extension service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service offline'**
|
||||
String get extensionHealthServiceOffline;
|
||||
|
||||
/// Tooltip for unknown extension service health
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service status unknown'**
|
||||
String get extensionHealthServiceUnknown;
|
||||
|
||||
/// Audio channel layout label - stereo
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stereo'**
|
||||
String get audioAnalysisStereo;
|
||||
|
||||
/// Audio channel layout label - mono
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mono'**
|
||||
String get audioAnalysisMono;
|
||||
|
||||
/// Button label to open a track in a named music service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open in {serviceName}'**
|
||||
String trackOpenInService(String serviceName);
|
||||
|
||||
/// Lyrics source label for embedded lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Embedded'**
|
||||
String get trackLyricsEmbeddedSource;
|
||||
|
||||
/// Fallback album name when metadata is missing
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown Album'**
|
||||
String get unknownAlbum;
|
||||
|
||||
/// Fallback artist name when metadata is missing
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown Artist'**
|
||||
String get unknownArtist;
|
||||
|
||||
/// Audio permission type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Audio'**
|
||||
String get permissionAudio;
|
||||
|
||||
/// Storage permission type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage'**
|
||||
String get permissionStorage;
|
||||
|
||||
/// Notification permission type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notification'**
|
||||
String get permissionNotification;
|
||||
|
||||
/// Error when the selected folder is invalid
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invalid folder selected'**
|
||||
String get errorInvalidFolderSelected;
|
||||
|
||||
/// Error when persistent folder access cannot be saved
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not keep access to the selected folder'**
|
||||
String get errorCouldNotKeepFolderAccess;
|
||||
|
||||
/// Store detail value when any app version is accepted
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any'**
|
||||
String get storeAnyVersion;
|
||||
|
||||
/// Store extension category - metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata'**
|
||||
String get storeCategoryMetadata;
|
||||
|
||||
/// Store extension category - download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get storeCategoryDownload;
|
||||
|
||||
/// Store extension category - utility
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Utility'**
|
||||
String get storeCategoryUtility;
|
||||
|
||||
/// Store extension category - lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics'**
|
||||
String get storeCategoryLyrics;
|
||||
|
||||
/// Store extension category - integration
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Integration'**
|
||||
String get storeCategoryIntegration;
|
||||
|
||||
/// Section header for all artist releases
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Releases'**
|
||||
String get artistReleases;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -405,7 +405,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Alben';
|
||||
@@ -1237,6 +1237,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Lyrics kopieren';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Lyrics sind für diesen Titel nicht verfügbar';
|
||||
@@ -1547,6 +1552,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadLossyMp3Subtitle =>
|
||||
'Beste Kompatibilität, ~10MB pro Titel';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3049,6 +3061,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3295,6 +3318,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit-Tiefe';
|
||||
|
||||
@@ -3319,9 +3351,33 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Proben';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3833,4 +3889,337 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2408,7 +2420,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3260,6 +3283,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3284,9 +3316,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3804,4 +3860,337 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3260,6 +3283,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3284,9 +3316,33 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3798,6 +3854,339 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
@@ -4196,7 +4585,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Álbumes';
|
||||
@@ -7049,6 +7438,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
|
||||
@@ -399,7 +399,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1223,6 +1223,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1526,6 +1531,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3018,6 +3030,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3264,6 +3287,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3288,9 +3320,33 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3802,4 +3858,337 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3261,6 +3284,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3285,9 +3317,33 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3799,4 +3855,337 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Album';
|
||||
@@ -1226,6 +1226,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Salin lirik';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
|
||||
|
||||
@@ -1531,6 +1536,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3024,6 +3036,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3270,6 +3293,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3294,9 +3326,33 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3790,4 +3846,337 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'アルバム';
|
||||
@@ -1214,6 +1214,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => '歌詞をコピー';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
|
||||
|
||||
@@ -1513,6 +1518,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3002,6 +3014,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3248,6 +3271,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3272,9 +3304,33 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3786,4 +3842,337 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => '앨범';
|
||||
@@ -1200,6 +1200,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1503,6 +1508,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2995,6 +3007,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3241,6 +3264,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3265,9 +3297,33 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3779,4 +3835,337 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3261,6 +3284,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3285,9 +3317,33 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3799,4 +3855,337 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3260,6 +3283,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3284,9 +3316,33 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3798,6 +3854,339 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
@@ -4195,7 +4584,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Álbuns';
|
||||
@@ -7042,6 +7431,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
|
||||
@@ -403,7 +403,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбомы';
|
||||
@@ -1238,6 +1238,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Копировать текст';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Текст песни недоступен для этого трека';
|
||||
@@ -1547,6 +1552,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3074,6 +3086,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3320,6 +3343,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3344,9 +3376,33 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3858,4 +3914,337 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albümler';
|
||||
@@ -1234,6 +1234,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Şarkı sözlerini kopyala';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Bu parça için şarkı sözü mevcut değil';
|
||||
|
||||
@@ -1540,6 +1545,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get downloadLossyMp3Subtitle =>
|
||||
'En iyi uyumluluk, parça başına ~10 Mb';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3041,6 +3053,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3287,6 +3310,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3311,9 +3343,33 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3825,4 +3881,337 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбоми';
|
||||
@@ -1240,6 +1240,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Скопіювати тексти пісень';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Текст пісні для цього треку недоступний';
|
||||
@@ -1548,6 +1553,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get downloadLossyMp3Subtitle =>
|
||||
'Найкраща сумісність, ~10 МБ на доріжку';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256 кбіт/с';
|
||||
|
||||
@@ -3067,6 +3079,17 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Спрощене послівне форматування';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
|
||||
|
||||
@@ -3316,6 +3339,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Частота дискретизації';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Глибина бітів';
|
||||
|
||||
@@ -3340,9 +3372,33 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Семпли';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Пошук за допомогою$providerName';
|
||||
@@ -3858,4 +3914,337 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1220,6 +1220,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1523,6 +1528,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -3015,6 +3027,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3260,6 +3283,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3284,9 +3316,33 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3798,6 +3854,339 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
@@ -4176,7 +4565,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -7008,6 +7397,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -7651,7 +8046,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -10485,6 +10880,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -1587,6 +1587,15 @@
|
||||
"@trackCopyLyrics": {
|
||||
"description": "Action - copy lyrics to clipboard"
|
||||
},
|
||||
"trackLyricsSource": "Source: {source}",
|
||||
"@trackLyricsSource": {
|
||||
"description": "Label showing the lyrics source/provider",
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackLyricsNotAvailable": "Lyrics not available for this track",
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
@@ -2001,6 +2010,14 @@
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyAac": "AAC/M4A 320kbps",
|
||||
"@downloadLossyAac": {
|
||||
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
|
||||
},
|
||||
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
|
||||
"@downloadLossyAacSubtitle": {
|
||||
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
@@ -3170,7 +3187,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||
"trackConvertFormatSubtitle": "Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -3969,6 +3986,18 @@
|
||||
"@downloadAppleQqMultiPersonDisabled": {
|
||||
"description": "Subtitle when multi-person lyrics is off"
|
||||
},
|
||||
"downloadAppleElrcWordSync": "Apple Music eLRC Word Sync",
|
||||
"@downloadAppleElrcWordSync": {
|
||||
"description": "Setting for preserving Apple Music word-by-word eLRC timestamps"
|
||||
},
|
||||
"downloadAppleElrcWordSyncEnabled": "Raw word-by-word timestamps preserved",
|
||||
"@downloadAppleElrcWordSyncEnabled": {
|
||||
"description": "Subtitle when Apple Music eLRC word sync is enabled"
|
||||
},
|
||||
"downloadAppleElrcWordSyncDisabled": "Safer line-by-line Apple Music lyrics",
|
||||
"@downloadAppleElrcWordSyncDisabled": {
|
||||
"description": "Subtitle when Apple Music eLRC word sync is disabled"
|
||||
},
|
||||
"downloadMusixmatchLanguage": "Musixmatch Language",
|
||||
"@downloadMusixmatchLanguage": {
|
||||
"description": "Setting for Musixmatch lyrics translation language"
|
||||
@@ -4243,6 +4272,18 @@
|
||||
"@audioAnalysisSampleRate": {
|
||||
"description": "Sample rate metric label"
|
||||
},
|
||||
"audioAnalysisCodec": "Codec",
|
||||
"@audioAnalysisCodec": {
|
||||
"description": "Audio codec metric label"
|
||||
},
|
||||
"audioAnalysisContainer": "Container",
|
||||
"@audioAnalysisContainer": {
|
||||
"description": "Audio container metric label"
|
||||
},
|
||||
"audioAnalysisDecodedFormat": "Decoded Format",
|
||||
"@audioAnalysisDecodedFormat": {
|
||||
"description": "Decoded sample format metric label"
|
||||
},
|
||||
"audioAnalysisBitDepth": "Bit Depth",
|
||||
"@audioAnalysisBitDepth": {
|
||||
"description": "Bit depth metric label"
|
||||
@@ -4275,10 +4316,42 @@
|
||||
"@audioAnalysisRms": {
|
||||
"description": "RMS level metric label"
|
||||
},
|
||||
"audioAnalysisLufs": "LUFS",
|
||||
"@audioAnalysisLufs": {
|
||||
"description": "Integrated loudness metric label"
|
||||
},
|
||||
"audioAnalysisTruePeak": "True Peak",
|
||||
"@audioAnalysisTruePeak": {
|
||||
"description": "True peak metric label"
|
||||
},
|
||||
"audioAnalysisClipping": "Clipping",
|
||||
"@audioAnalysisClipping": {
|
||||
"description": "Clipping metric label"
|
||||
},
|
||||
"audioAnalysisNoClipping": "No clipping",
|
||||
"@audioAnalysisNoClipping": {
|
||||
"description": "Displayed when no clipped samples were detected"
|
||||
},
|
||||
"audioAnalysisSpectralCutoff": "Spectral Cutoff",
|
||||
"@audioAnalysisSpectralCutoff": {
|
||||
"description": "Estimated spectral cutoff metric label"
|
||||
},
|
||||
"audioAnalysisChannelStats": "Per-channel Stats",
|
||||
"@audioAnalysisChannelStats": {
|
||||
"description": "Per-channel audio analysis section label"
|
||||
},
|
||||
"audioAnalysisSamples": "Samples",
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
@@ -5001,5 +5074,417 @@
|
||||
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
|
||||
"@downloadFallbackExtensionsSubtitle": {
|
||||
"description": "Subtitle for fallback extensions item"
|
||||
},
|
||||
"editMetadataFieldDateHint": "YYYY-MM-DD or YYYY",
|
||||
"@editMetadataFieldDateHint": {
|
||||
"description": "Hint text for the edit metadata date field"
|
||||
},
|
||||
"editMetadataFieldTrackTotal": "Track Total",
|
||||
"@editMetadataFieldTrackTotal": {
|
||||
"description": "Label for total tracks field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldDiscTotal": "Disc Total",
|
||||
"@editMetadataFieldDiscTotal": {
|
||||
"description": "Label for total discs field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldComposer": "Composer",
|
||||
"@editMetadataFieldComposer": {
|
||||
"description": "Label for composer field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldComment": "Comment",
|
||||
"@editMetadataFieldComment": {
|
||||
"description": "Label for comment field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAdvanced": "Advanced",
|
||||
"@editMetadataAdvanced": {
|
||||
"description": "Expandable section label for advanced metadata fields"
|
||||
},
|
||||
"libraryFilterMetadataMissingTrackNumber": "Missing track number",
|
||||
"@libraryFilterMetadataMissingTrackNumber": {
|
||||
"description": "Filter option - items missing track number"
|
||||
},
|
||||
"libraryFilterMetadataMissingDiscNumber": "Missing disc number",
|
||||
"@libraryFilterMetadataMissingDiscNumber": {
|
||||
"description": "Filter option - items missing disc number"
|
||||
},
|
||||
"libraryFilterMetadataMissingArtist": "Missing artist",
|
||||
"@libraryFilterMetadataMissingArtist": {
|
||||
"description": "Filter option - items missing artist"
|
||||
},
|
||||
"libraryFilterMetadataIncorrectIsrcFormat": "Incorrect ISRC format",
|
||||
"@libraryFilterMetadataIncorrectIsrcFormat": {
|
||||
"description": "Filter option - items with an invalid ISRC format"
|
||||
},
|
||||
"libraryFilterMetadataMissingLabel": "Missing label",
|
||||
"@libraryFilterMetadataMissingLabel": {
|
||||
"description": "Filter option - items missing record label"
|
||||
},
|
||||
"collectionDeletePlaylistsMessage": "Delete {count} {count, plural, =1{playlist} other{playlists}}?",
|
||||
"@collectionDeletePlaylistsMessage": {
|
||||
"description": "Confirmation message for deleting selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistsDeleted": "{count} {count, plural, =1{playlist} other{playlists}} deleted",
|
||||
"@collectionPlaylistsDeleted": {
|
||||
"description": "Snackbar after deleting selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedTracksToPlaylist": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}",
|
||||
"@collectionAddedTracksToPlaylist": {
|
||||
"description": "Snackbar after adding multiple tracks to a playlist",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedTracksToPlaylistWithExisting": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)",
|
||||
"@collectionAddedTracksToPlaylistWithExisting": {
|
||||
"description": "Snackbar after adding multiple tracks to a playlist when some were already present",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
},
|
||||
"alreadyCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"itemCount": "{count} {count, plural, =1{item} other{items}}",
|
||||
"@itemCount": {
|
||||
"description": "Generic item count label",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackReEnrichSuccessWithFailures": "Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}",
|
||||
"@trackReEnrichSuccessWithFailures": {
|
||||
"description": "Snackbar summary after batch metadata re-enrichment finishes with failures",
|
||||
"placeholders": {
|
||||
"successCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"failedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionDeleteTracksCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionDeleteTracksCount": {
|
||||
"description": "Button label for deleting selected tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadSpeedStatus": "Downloading - {speed} MB/s",
|
||||
"@queueDownloadSpeedStatus": {
|
||||
"description": "Queue status while downloading with speed",
|
||||
"placeholders": {
|
||||
"speed": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadStarting": "Starting...",
|
||||
"@queueDownloadStarting": {
|
||||
"description": "Queue status before download progress is available"
|
||||
},
|
||||
"a11ySelectTrack": "Select track",
|
||||
"@a11ySelectTrack": {
|
||||
"description": "Accessibility label for selecting a track"
|
||||
},
|
||||
"a11yDeselectTrack": "Deselect track",
|
||||
"@a11yDeselectTrack": {
|
||||
"description": "Accessibility label for deselecting a track"
|
||||
},
|
||||
"a11yPlayTrackByArtist": "Play {trackName} by {artistName}",
|
||||
"@a11yPlayTrackByArtist": {
|
||||
"description": "Accessibility label for playing a local library track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
},
|
||||
"artistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeExtensionsCount": "{count} {count, plural, =1{extension} other{extensions}}",
|
||||
"@storeExtensionsCount": {
|
||||
"description": "Store extension result count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeRequiresVersion": "Requires v{version}+",
|
||||
"@storeRequiresVersion": {
|
||||
"description": "Store compatibility badge for minimum app version",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actionGo": "Go",
|
||||
"@actionGo": {
|
||||
"description": "Generic action button label"
|
||||
},
|
||||
"logIssueSummary": "Issue Summary",
|
||||
"@logIssueSummary": {
|
||||
"description": "Header for log issue analysis summary"
|
||||
},
|
||||
"logTotalErrors": "Total errors: {count}",
|
||||
"@logTotalErrors": {
|
||||
"description": "Total error count in log issue analysis",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logAffectedDomains": "Affected: {domains}",
|
||||
"@logAffectedDomains": {
|
||||
"description": "Affected domains in log issue analysis",
|
||||
"placeholders": {
|
||||
"domains": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryScanCancelled": "Scan cancelled",
|
||||
"@libraryScanCancelled": {
|
||||
"description": "Library scan status when a scan was cancelled"
|
||||
},
|
||||
"libraryScanCancelledSubtitle": "You can retry the scan when ready.",
|
||||
"@libraryScanCancelledSubtitle": {
|
||||
"description": "Library scan status subtitle after cancellation"
|
||||
},
|
||||
"libraryDownloadsHistoryExcluded": "{count} from Downloads history (excluded from list)",
|
||||
"@libraryDownloadsHistoryExcluded": {
|
||||
"description": "Library count note for downloaded history items excluded from the local list",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadNativeWorker": "Native download worker",
|
||||
"@downloadNativeWorker": {
|
||||
"description": "Setting title for Android native download worker"
|
||||
},
|
||||
"downloadNativeWorkerSubtitle": "Beta Android service worker for extension downloads",
|
||||
"@downloadNativeWorkerSubtitle": {
|
||||
"description": "Setting subtitle for Android native download worker"
|
||||
},
|
||||
"badgeBeta": "BETA",
|
||||
"@badgeBeta": {
|
||||
"description": "Badge label for beta features"
|
||||
},
|
||||
"extensionServiceStatus": "Service Status",
|
||||
"@extensionServiceStatus": {
|
||||
"description": "Extension detail section header for service status"
|
||||
},
|
||||
"extensionServiceHealth": "Service health",
|
||||
"@extensionServiceHealth": {
|
||||
"description": "Extension capability label for service health checks"
|
||||
},
|
||||
"extensionHealthChecksConfigured": "{count} {count, plural, =1{check} other{checks}} configured",
|
||||
"@extensionHealthChecksConfigured": {
|
||||
"description": "Extension service health check count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionOauthConnectHint": "Tap Connect to Spotify to fill this field.",
|
||||
"@extensionOauthConnectHint": {
|
||||
"description": "Hint for an OAuth login link field before connecting Spotify"
|
||||
},
|
||||
"extensionLastChecked": "Last checked {time}",
|
||||
"@extensionLastChecked": {
|
||||
"description": "Timestamp for the latest extension service health check",
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionRefreshStatus": "Refresh status",
|
||||
"@extensionRefreshStatus": {
|
||||
"description": "Tooltip for refreshing extension service health status"
|
||||
},
|
||||
"extensionCustomUrlHandling": "Custom URL Handling",
|
||||
"@extensionCustomUrlHandling": {
|
||||
"description": "Extension detail section title for custom URL handling"
|
||||
},
|
||||
"extensionCustomUrlHandlingSubtitle": "This extension can handle links from these sites",
|
||||
"@extensionCustomUrlHandlingSubtitle": {
|
||||
"description": "Extension detail subtitle for custom URL handling"
|
||||
},
|
||||
"extensionCustomUrlHandlingShareHint": "Share links from these sites to SpotiFLAC Mobile and this extension will handle them.",
|
||||
"@extensionCustomUrlHandlingShareHint": {
|
||||
"description": "Extension detail hint explaining share-to-app URL handling"
|
||||
},
|
||||
"extensionSettingsCount": "{count} {count, plural, =1{setting} other{settings}}",
|
||||
"@extensionSettingsCount": {
|
||||
"description": "Count of settings exposed by an extension quality option",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionHealthOnline": "Online",
|
||||
"@extensionHealthOnline": {
|
||||
"description": "Extension service health status - online"
|
||||
},
|
||||
"extensionHealthDegraded": "Degraded",
|
||||
"@extensionHealthDegraded": {
|
||||
"description": "Extension service health status - degraded"
|
||||
},
|
||||
"extensionHealthOffline": "Offline",
|
||||
"@extensionHealthOffline": {
|
||||
"description": "Extension service health status - offline"
|
||||
},
|
||||
"extensionHealthNotConfigured": "Not configured",
|
||||
"@extensionHealthNotConfigured": {
|
||||
"description": "Extension service health status - not configured"
|
||||
},
|
||||
"extensionHealthUnknown": "Unknown",
|
||||
"@extensionHealthUnknown": {
|
||||
"description": "Extension service health status - unknown"
|
||||
},
|
||||
"extensionHealthRequired": "required",
|
||||
"@extensionHealthRequired": {
|
||||
"description": "Label for a required extension service health check"
|
||||
},
|
||||
"extensionSettingNotSet": "Not set",
|
||||
"@extensionSettingNotSet": {
|
||||
"description": "Value shown when an extension setting has no value"
|
||||
},
|
||||
"extensionActionFailed": "Action failed",
|
||||
"@extensionActionFailed": {
|
||||
"description": "Fallback error when an extension action fails without details"
|
||||
},
|
||||
"extensionEnterValue": "Enter value",
|
||||
"@extensionEnterValue": {
|
||||
"description": "Hint for editing an extension setting value"
|
||||
},
|
||||
"extensionHealthServiceOnline": "Service online",
|
||||
"@extensionHealthServiceOnline": {
|
||||
"description": "Tooltip for online extension service"
|
||||
},
|
||||
"extensionHealthServiceDegraded": "Service degraded",
|
||||
"@extensionHealthServiceDegraded": {
|
||||
"description": "Tooltip for degraded extension service"
|
||||
},
|
||||
"extensionHealthServiceOffline": "Service offline",
|
||||
"@extensionHealthServiceOffline": {
|
||||
"description": "Tooltip for offline extension service"
|
||||
},
|
||||
"extensionHealthServiceUnknown": "Service status unknown",
|
||||
"@extensionHealthServiceUnknown": {
|
||||
"description": "Tooltip for unknown extension service health"
|
||||
},
|
||||
"audioAnalysisStereo": "Stereo",
|
||||
"@audioAnalysisStereo": {
|
||||
"description": "Audio channel layout label - stereo"
|
||||
},
|
||||
"audioAnalysisMono": "Mono",
|
||||
"@audioAnalysisMono": {
|
||||
"description": "Audio channel layout label - mono"
|
||||
},
|
||||
"trackOpenInService": "Open in {serviceName}",
|
||||
"@trackOpenInService": {
|
||||
"description": "Button label to open a track in a named music service",
|
||||
"placeholders": {
|
||||
"serviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackLyricsEmbeddedSource": "Embedded",
|
||||
"@trackLyricsEmbeddedSource": {
|
||||
"description": "Lyrics source label for embedded lyrics"
|
||||
},
|
||||
"unknownAlbum": "Unknown Album",
|
||||
"@unknownAlbum": {
|
||||
"description": "Fallback album name when metadata is missing"
|
||||
},
|
||||
"unknownArtist": "Unknown Artist",
|
||||
"@unknownArtist": {
|
||||
"description": "Fallback artist name when metadata is missing"
|
||||
},
|
||||
"permissionAudio": "Audio",
|
||||
"@permissionAudio": {
|
||||
"description": "Audio permission type label"
|
||||
},
|
||||
"permissionStorage": "Storage",
|
||||
"@permissionStorage": {
|
||||
"description": "Storage permission type label"
|
||||
},
|
||||
"permissionNotification": "Notification",
|
||||
"@permissionNotification": {
|
||||
"description": "Notification permission type label"
|
||||
},
|
||||
"errorInvalidFolderSelected": "Invalid folder selected",
|
||||
"@errorInvalidFolderSelected": {
|
||||
"description": "Error when the selected folder is invalid"
|
||||
},
|
||||
"errorCouldNotKeepFolderAccess": "Could not keep access to the selected folder",
|
||||
"@errorCouldNotKeepFolderAccess": {
|
||||
"description": "Error when persistent folder access cannot be saved"
|
||||
},
|
||||
"storeAnyVersion": "Any",
|
||||
"@storeAnyVersion": {
|
||||
"description": "Store detail value when any app version is accepted"
|
||||
},
|
||||
"storeCategoryMetadata": "Metadata",
|
||||
"@storeCategoryMetadata": {
|
||||
"description": "Store extension category - metadata"
|
||||
},
|
||||
"storeCategoryDownload": "Download",
|
||||
"@storeCategoryDownload": {
|
||||
"description": "Store extension category - download"
|
||||
},
|
||||
"storeCategoryUtility": "Utility",
|
||||
"@storeCategoryUtility": {
|
||||
"description": "Store extension category - utility"
|
||||
},
|
||||
"storeCategoryLyrics": "Lyrics",
|
||||
"@storeCategoryLyrics": {
|
||||
"description": "Store extension category - lyrics"
|
||||
},
|
||||
"storeCategoryIntegration": "Integration",
|
||||
"@storeCategoryIntegration": {
|
||||
"description": "Store extension category - integration"
|
||||
},
|
||||
"artistReleases": "Releases",
|
||||
"@artistReleases": {
|
||||
"description": "Section header for all artist releases"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4204,6 +4204,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4027,6 +4027,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"downloadSingleFilenameFormat": "Single Dosya Adı Formatı",
|
||||
"@downloadSingleFilenameFormat": {
|
||||
"description": "Setting for output filename pattern for singles/EPs"
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Пошук за допомогою{providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -4196,6 +4196,14 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
|
||||
@@ -47,7 +47,7 @@ class AppSettings {
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
@@ -81,6 +81,8 @@ class AppSettings {
|
||||
lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease)
|
||||
final bool
|
||||
lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics
|
||||
final bool
|
||||
lyricsAppleElrcWordSync; // Preserve Apple Music inline word timestamps for eLRC-capable players
|
||||
final String
|
||||
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||
|
||||
@@ -146,6 +148,7 @@ class AppSettings {
|
||||
this.lyricsIncludeTranslationNetease = false,
|
||||
this.lyricsIncludeRomanizationNetease = false,
|
||||
this.lyricsMultiPersonWordByWord = false,
|
||||
this.lyricsAppleElrcWordSync = false,
|
||||
this.musixmatchLanguage = '',
|
||||
this.lastSeenVersion = '',
|
||||
this.deduplicateDownloads = true,
|
||||
@@ -210,6 +213,7 @@ class AppSettings {
|
||||
bool? lyricsIncludeTranslationNetease,
|
||||
bool? lyricsIncludeRomanizationNetease,
|
||||
bool? lyricsMultiPersonWordByWord,
|
||||
bool? lyricsAppleElrcWordSync,
|
||||
String? musixmatchLanguage,
|
||||
String? lastSeenVersion,
|
||||
bool? deduplicateDownloads,
|
||||
@@ -291,6 +295,8 @@ class AppSettings {
|
||||
this.lyricsIncludeRomanizationNetease,
|
||||
lyricsMultiPersonWordByWord:
|
||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||
lyricsAppleElrcWordSync:
|
||||
lyricsAppleElrcWordSync ?? this.lyricsAppleElrcWordSync,
|
||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
|
||||
|
||||
@@ -78,6 +78,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
||||
lyricsMultiPersonWordByWord:
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||
lyricsAppleElrcWordSync: json['lyricsAppleElrcWordSync'] as bool? ?? false,
|
||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
||||
@@ -142,6 +143,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||
'lyricsAppleElrcWordSync': instance.lyricsAppleElrcWordSync,
|
||||
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
'deduplicateDownloads': instance.deduplicateDownloads,
|
||||
|
||||
@@ -46,7 +46,7 @@ class ThemeSettings {
|
||||
|
||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||
return ThemeSettings(
|
||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||
themeMode: themeModeFromString(json[kThemeModeKey] as String?),
|
||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||
@@ -68,7 +68,7 @@ class ThemeSettings {
|
||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
ThemeMode themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
|
||||
@@ -54,11 +54,6 @@ class LocalLibraryState {
|
||||
_isrcSet = isrcSet ?? const <String>{},
|
||||
_filePathById = filePathById ?? const <String, String>{};
|
||||
|
||||
@Deprecated(
|
||||
'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.',
|
||||
)
|
||||
List<LocalLibraryItem> get items => const <LocalLibraryItem>[];
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
|
||||
@@ -108,6 +108,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
'include_translation_netease': state.lyricsIncludeTranslationNetease,
|
||||
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
|
||||
'musixmatch_language': state.musixmatchLanguage,
|
||||
}).catchError((Object e) {
|
||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||
@@ -367,6 +368,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setLyricsAppleElrcWordSync(bool enabled) {
|
||||
state = state.copyWith(lyricsAppleElrcWordSync: enabled);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setMusixmatchLanguage(String languageCode) {
|
||||
state = state.copyWith(
|
||||
musixmatchLanguage: languageCode.trim().toLowerCase(),
|
||||
|
||||
@@ -25,7 +25,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final useAmoled = prefs.getBool(kUseAmoledKey);
|
||||
|
||||
state = ThemeSettings(
|
||||
themeMode: _themeModeFromString(modeString),
|
||||
themeMode: themeModeFromString(modeString),
|
||||
useDynamicColor: useDynamic ?? true,
|
||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||
useAmoled: useAmoled ?? false,
|
||||
@@ -71,12 +71,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
state = state.copyWith(useAmoled: value);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (releases.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAlbumSection(
|
||||
'Releases',
|
||||
context.l10n.artistReleases,
|
||||
releases,
|
||||
colorScheme,
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
@@ -944,40 +945,39 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
final sourceFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.format,
|
||||
filePath: item.filePath,
|
||||
fileName: item.safFileName,
|
||||
);
|
||||
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
final formats = audioConversionTargetFormats
|
||||
.where(
|
||||
(target) => sourceFormats.any(
|
||||
(source) => canConvertAudioFormat(
|
||||
sourceFormat: source,
|
||||
targetFormat: target,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -1039,9 +1039,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1137,23 +1137,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
final sourceFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.format,
|
||||
filePath: item.filePath,
|
||||
fileName: item.safFileName,
|
||||
);
|
||||
if (sourceFormat == null ||
|
||||
!canConvertAudioFormat(
|
||||
sourceFormat: sourceFormat,
|
||||
targetFormat: targetFormat,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
@@ -1316,6 +1311,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
@@ -1350,14 +1346,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
if (!isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
await historyDb.updateFilePath(
|
||||
item.id,
|
||||
safUri,
|
||||
newSafFileName: newFileName,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
@@ -1374,6 +1377,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
item.id,
|
||||
newPath,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
|
||||
@@ -508,8 +508,10 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
item.artistName.isNotEmpty
|
||||
? item.artistName
|
||||
: (isPlaylist
|
||||
? 'Playlist'
|
||||
: (isArtist ? 'Artist' : 'Album')),
|
||||
? context.l10n.recentTypePlaylist
|
||||
: (isArtist
|
||||
? context.l10n.recentTypeArtist
|
||||
: context.l10n.recentTypeAlbum)),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -607,7 +609,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Artist',
|
||||
context.l10n.recentTypeArtist,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -707,7 +709,7 @@ class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
ClickableArtistName(
|
||||
artistName: album.artists.isNotEmpty
|
||||
? album.artists
|
||||
: 'Album',
|
||||
: context.l10n.recentTypeAlbum,
|
||||
coverUrl: album.imageUrl,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -806,7 +808,9 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
playlist.owner.isNotEmpty ? playlist.owner : 'Playlist',
|
||||
playlist.owner.isNotEmpty
|
||||
? playlist.owner
|
||||
: context.l10n.recentTypePlaylist,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
@@ -440,8 +441,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Local',
|
||||
Text(
|
||||
context.l10n.librarySourceLocal,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -470,7 +471,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${_sortedTracksCache.length} tracks',
|
||||
context.l10n.queueTrackCount(
|
||||
_sortedTracksCache.length,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1155,7 +1158,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final failedCount = total - successCount;
|
||||
final summary = failedCount <= 0
|
||||
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
|
||||
: '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount';
|
||||
: context.l10n.trackReEnrichSuccessWithFailures(
|
||||
successCount,
|
||||
total,
|
||||
failedCount,
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
@@ -1170,52 +1177,38 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
String? ext;
|
||||
if (item.format != null && item.format!.isNotEmpty) {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
ext = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
ext = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
ext = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext == null) {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
ext = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
ext = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
ext = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
final sourceFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.format,
|
||||
filePath: item.filePath,
|
||||
);
|
||||
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
final formats = audioConversionTargetFormats
|
||||
.where(
|
||||
(target) => sourceFormats.any(
|
||||
(source) => canConvertAudioFormat(
|
||||
sourceFormat: source,
|
||||
targetFormat: target,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -1277,9 +1270,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1375,39 +1368,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
// Detect current format: prefer item.format field (works for SAF too),
|
||||
// fall back to file extension for regular paths
|
||||
String? currentFormat;
|
||||
if (item.format != null && item.format!.isNotEmpty) {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
currentFormat = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
currentFormat = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
currentFormat = 'Opus';
|
||||
}
|
||||
final currentFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.format,
|
||||
filePath: item.filePath,
|
||||
);
|
||||
if (currentFormat == null ||
|
||||
!canConvertAudioFormat(
|
||||
sourceFormat: currentFormat,
|
||||
targetFormat: targetFormat,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (currentFormat == null) {
|
||||
// Fallback: try file extension (works for regular paths)
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
currentFormat = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
currentFormat = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
currentFormat = 'Opus';
|
||||
}
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
@@ -1602,6 +1573,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
@@ -1636,9 +1608,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
if (!isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
await localDb.replaceWithConvertedItem(
|
||||
item: item,
|
||||
newFilePath: safUri,
|
||||
|
||||
@@ -18,7 +18,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -38,6 +40,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
late final PageController _pageController;
|
||||
late final AnimationController _tabJumpTransitionController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
bool _hasCheckedAppAnnouncement = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
|
||||
@@ -66,10 +69,13 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
currentTabIndex: _currentIndex,
|
||||
showRepoTab: false,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
_setupShareListener();
|
||||
_checkSafMigration();
|
||||
final updateDialogShown = await _checkForUpdates();
|
||||
if (!updateDialogShown) {
|
||||
await _checkAppAnnouncement();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,12 +133,12 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdates() async {
|
||||
if (_hasCheckedUpdate) return;
|
||||
Future<bool> _checkForUpdates() async {
|
||||
if (_hasCheckedUpdate) return false;
|
||||
_hasCheckedUpdate = true;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.checkForUpdates) return;
|
||||
if (!settings.checkForUpdates) return false;
|
||||
|
||||
final updateInfo = await UpdateChecker.checkForUpdate(
|
||||
channel: settings.updateChannel,
|
||||
@@ -145,7 +151,30 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _checkAppAnnouncement() async {
|
||||
if (_hasCheckedAppAnnouncement) return;
|
||||
_hasCheckedAppAnnouncement = true;
|
||||
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final remoteConfigService = AppRemoteConfigService();
|
||||
final announcement = await remoteConfigService.fetchActiveAnnouncement(
|
||||
locale: locale,
|
||||
);
|
||||
if (announcement == null || !mounted) return;
|
||||
|
||||
showAppAnnouncementDialog(
|
||||
context,
|
||||
announcement: announcement,
|
||||
onDismiss: () {
|
||||
remoteConfigService.markAnnouncementDismissed(announcement.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -41,6 +42,49 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
part 'queue_tab_helpers.dart';
|
||||
part 'queue_tab_widgets.dart';
|
||||
|
||||
String _formatDownloadSizeMB(num bytes) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
final speedSuffix = item.speedMBps > 0
|
||||
? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '';
|
||||
|
||||
if (item.bytesTotal > 0) {
|
||||
final received = item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: item.bytesTotal * progress;
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)} • $percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.bytesReceived > 0) {
|
||||
final canEstimateTotal = progress > 0.01 && progress < 0.995;
|
||||
if (canEstimateTotal) {
|
||||
final estimatedTotal = item.bytesReceived / progress;
|
||||
if (estimatedTotal > item.bytesReceived) {
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix';
|
||||
}
|
||||
}
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix';
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '$percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.speedMBps > 0) {
|
||||
return context.l10n.queueDownloadSpeedStatus(
|
||||
item.speedMBps.toStringAsFixed(1),
|
||||
);
|
||||
}
|
||||
|
||||
return context.l10n.queueDownloadStarting;
|
||||
}
|
||||
|
||||
class QueueTab extends ConsumerStatefulWidget {
|
||||
final PageController? parentPageController;
|
||||
final int parentPageIndex;
|
||||
@@ -1001,9 +1045,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.collectionDeletePlaylist),
|
||||
content: Text(
|
||||
'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?',
|
||||
),
|
||||
content: Text(ctx.l10n.collectionDeletePlaylistsMessage(count)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
@@ -1030,11 +1072,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (!context.mounted) return;
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$count ${count == 1 ? 'playlist' : 'playlists'} deleted',
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistsDeleted(count))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1363,6 +1401,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _itemFormatLower(UnifiedLibraryItem item) {
|
||||
final localFormat = normalizeOptionalString(item.localItem?.format);
|
||||
if (localFormat != null) {
|
||||
return localFormat.toLowerCase().replaceAll('-', '_');
|
||||
}
|
||||
final historyFormat = normalizeOptionalString(item.historyItem?.format);
|
||||
if (historyFormat != null) {
|
||||
return historyFormat.toLowerCase().replaceAll('-', '_');
|
||||
}
|
||||
return _fileExtLower(item.filePath);
|
||||
}
|
||||
|
||||
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
||||
List<UnifiedLibraryItem> items,
|
||||
) {
|
||||
@@ -1400,7 +1450,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
if (_filterFormat != null) {
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
final ext = _itemFormatLower(item);
|
||||
if (ext != _filterFormat) return false;
|
||||
}
|
||||
|
||||
@@ -1533,8 +1583,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
||||
final formats = <String>{};
|
||||
for (final item in items) {
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
||||
final ext = _itemFormatLower(item);
|
||||
if ([
|
||||
'flac',
|
||||
'alac',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'aac',
|
||||
'eac3',
|
||||
'ac3',
|
||||
'ac4',
|
||||
'opus',
|
||||
'ogg',
|
||||
'wav',
|
||||
'aiff',
|
||||
].contains(ext)) {
|
||||
formats.add(ext);
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1848,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Missing track number'),
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingTrackNumber,
|
||||
),
|
||||
selected:
|
||||
tempMetadata == 'missing-track-number',
|
||||
onSelected: (_) => setSheetState(
|
||||
@@ -1793,21 +1860,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Missing disc number'),
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingDiscNumber,
|
||||
),
|
||||
selected: tempMetadata == 'missing-disc-number',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-disc-number',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Missing artist'),
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingArtist,
|
||||
),
|
||||
selected: tempMetadata == 'missing-artist',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-artist',
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Incorrect ISRC format'),
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataIncorrectIsrcFormat,
|
||||
),
|
||||
selected:
|
||||
tempMetadata == 'incorrect-isrc-format',
|
||||
onSelected: (_) => setSheetState(
|
||||
@@ -1815,7 +1894,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Missing label'),
|
||||
label: Text(
|
||||
context
|
||||
.l10n
|
||||
.libraryFilterMetadataMissingLabel,
|
||||
),
|
||||
selected: tempMetadata == 'missing-label',
|
||||
onSelected: (_) => setSheetState(
|
||||
() => tempMetadata = 'missing-label',
|
||||
@@ -2401,8 +2484,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
final message = addedCount > 0
|
||||
? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName'
|
||||
'${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}'
|
||||
? alreadyCount > 0
|
||||
? context.l10n.collectionAddedTracksToPlaylistWithExisting(
|
||||
addedCount,
|
||||
playlistName,
|
||||
alreadyCount,
|
||||
)
|
||||
: context.l10n.collectionAddedTracksToPlaylist(
|
||||
addedCount,
|
||||
playlistName,
|
||||
)
|
||||
: context.l10n.collectionAlreadyInPlaylist(playlistName);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -3107,7 +3198,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
'$count ${count == 1 ? 'item' : 'items'}',
|
||||
context.l10n.itemCount(count),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
@@ -4601,7 +4692,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final failedCount = total - successCount;
|
||||
final summary = failedCount <= 0
|
||||
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
|
||||
: '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount';
|
||||
: context.l10n.trackReEnrichSuccessWithFailures(
|
||||
successCount,
|
||||
total,
|
||||
failedCount,
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
@@ -4657,46 +4752,39 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
String nameToCheck;
|
||||
if (item.historyItem?.safFileName != null &&
|
||||
item.historyItem!.safFileName!.isNotEmpty) {
|
||||
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||
} else if (item.localItem?.format != null &&
|
||||
item.localItem!.format!.isNotEmpty) {
|
||||
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||
} else {
|
||||
nameToCheck = item.filePath.toLowerCase();
|
||||
}
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
final sourceFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.localItem?.format ?? item.historyItem?.format,
|
||||
filePath: item.filePath,
|
||||
fileName: item.historyItem?.safFileName,
|
||||
);
|
||||
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
final formats = audioConversionTargetFormats
|
||||
.where(
|
||||
(target) => sourceFormats.any(
|
||||
(source) => canConvertAudioFormat(
|
||||
sourceFormat: source,
|
||||
targetFormat: target,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
var didStartConversion = false;
|
||||
|
||||
_hideSelectionOverlay();
|
||||
@@ -4762,9 +4850,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4875,29 +4963,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
String nameToCheck;
|
||||
if (item.historyItem?.safFileName != null &&
|
||||
item.historyItem!.safFileName!.isNotEmpty) {
|
||||
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||
} else if (item.localItem?.format != null &&
|
||||
item.localItem!.format!.isNotEmpty) {
|
||||
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||
} else {
|
||||
nameToCheck = item.filePath.toLowerCase();
|
||||
final sourceFormat = convertibleAudioSourceFormat(
|
||||
storedFormat: item.localItem?.format ?? item.historyItem?.format,
|
||||
filePath: item.filePath,
|
||||
fileName: item.historyItem?.safFileName,
|
||||
);
|
||||
if (sourceFormat == null ||
|
||||
!canConvertAudioFormat(
|
||||
sourceFormat: sourceFormat,
|
||||
targetFormat: targetFormat,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selectedItems.add(item);
|
||||
}
|
||||
|
||||
@@ -5065,6 +5142,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
@@ -5099,15 +5177,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
if (!isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await historyDb.updateFilePath(
|
||||
hi.id,
|
||||
safUri,
|
||||
newSafFileName: newFileName,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
@@ -5170,6 +5255,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
@@ -5204,9 +5290,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
if (!isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: safUri,
|
||||
@@ -5228,6 +5316,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
item.historyItem!.id,
|
||||
newPath,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
} else if (item.localItem != null) {
|
||||
@@ -5426,7 +5519,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
||||
? context.l10n.selectionDeleteTracksCount(selectedCount)
|
||||
: context.l10n.selectionSelectToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -5541,43 +5634,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (item.status == DownloadStatus.downloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0
|
||||
? item.progress
|
||||
: null,
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
_formatDownloadProgressLabel(context, item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
minHeight: 6,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
item.bytesTotal > 0
|
||||
? '${(item.progress * 100).toStringAsFixed(0)}%'
|
||||
: (item.bytesReceived > 0
|
||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
|
||||
: (item.progress > 0
|
||||
? (item.speedMBps > 0
|
||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||
: (item.speedMBps > 0
|
||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: 'Starting...'))),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.progress > 0 ? item.progress : null,
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
@@ -5998,7 +6079,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (_isSelectionMode) ...[
|
||||
Semantics(
|
||||
checked: isSelected,
|
||||
label: isSelected ? 'Deselect track' : 'Select track',
|
||||
label: isSelected
|
||||
? context.l10n.a11yDeselectTrack
|
||||
: context.l10n.a11ySelectTrack,
|
||||
child: AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
@@ -6261,8 +6344,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return fileExists
|
||||
? Semantics(
|
||||
button: true,
|
||||
label:
|
||||
'Play ${item.trackName} by ${item.artistName}',
|
||||
label: context.l10n.a11yPlayTrackByArtist(
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => _openFile(
|
||||
item.filePath,
|
||||
|
||||
@@ -33,6 +33,21 @@ class UnifiedLibraryItem {
|
||||
});
|
||||
|
||||
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
|
||||
String? quality;
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitrateKbps: item.bitrate,
|
||||
format: item.format,
|
||||
);
|
||||
} else if (item.bitDepth != null &&
|
||||
item.bitDepth! > 0 &&
|
||||
item.sampleRate != null) {
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
);
|
||||
}
|
||||
quality ??= item.quality;
|
||||
return UnifiedLibraryItem(
|
||||
id: 'dl_${item.id}',
|
||||
trackName: item.trackName,
|
||||
@@ -40,11 +55,7 @@ class UnifiedLibraryItem {
|
||||
albumName: item.albumName,
|
||||
coverUrl: item.coverUrl,
|
||||
filePath: item.filePath,
|
||||
quality: buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
storedQuality: item.quality,
|
||||
),
|
||||
quality: quality,
|
||||
addedAt: item.downloadedAt,
|
||||
source: LibraryItemSource.downloaded,
|
||||
historyItem: item,
|
||||
|
||||
@@ -281,7 +281,9 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
context.l10n.storeExtensionsCount(
|
||||
filteredExtensions.length,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -810,7 +812,9 @@ class _ExtensionItem extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Requires v${extension.minAppVersion}+',
|
||||
context.l10n.storeRequiresVersion(
|
||||
extension.minAppVersion ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
@@ -862,7 +866,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Installed',
|
||||
context.l10n.storeInstalled,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -141,7 +141,8 @@ class AboutPage extends StatelessWidget {
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: 'Paxsenix',
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
'Lyrics proxy for Musixmatch, Netease, Apple Music, '
|
||||
'QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -302,7 +303,7 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
color: colorScheme.onPrimary,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/donate_icons.dart';
|
||||
|
||||
class DonatePage extends StatelessWidget {
|
||||
class DonatePage extends StatefulWidget {
|
||||
const DonatePage({super.key});
|
||||
|
||||
@override
|
||||
State<DonatePage> createState() => _DonatePageState();
|
||||
}
|
||||
|
||||
class _DonatePageState extends State<DonatePage> {
|
||||
DonateConfig _config = DonateConfig.fallback();
|
||||
bool _hasRequestedConfig = false;
|
||||
String? _activeRemoteJson;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_hasRequestedConfig) return;
|
||||
|
||||
_hasRequestedConfig = true;
|
||||
_loadConfig(Localizations.localeOf(context).toLanguageTag());
|
||||
}
|
||||
|
||||
Future<void> _loadConfig(String locale) async {
|
||||
final service = AppRemoteConfigService();
|
||||
final cached = await service.readCachedConfig();
|
||||
if (!mounted) return;
|
||||
|
||||
if (cached != null) {
|
||||
_applyRemoteConfig(cached);
|
||||
}
|
||||
|
||||
unawaited(_refreshConfigCache(locale));
|
||||
}
|
||||
|
||||
Future<void> _refreshConfigCache(String locale) async {
|
||||
await AppRemoteConfigService().fetchConfigSnapshot(locale: locale);
|
||||
}
|
||||
|
||||
void _applyRemoteConfig(RemoteConfigSnapshot snapshot) {
|
||||
if (_activeRemoteJson == snapshot.rawJson) return;
|
||||
|
||||
setState(() {
|
||||
_activeRemoteJson = snapshot.rawJson;
|
||||
_config = snapshot.config.donate;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -57,94 +102,16 @@ class DonatePage extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_DonateLinksCard(colorScheme: colorScheme),
|
||||
|
||||
_DonateLinksCard(colorScheme: colorScheme, config: _config),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_RecentDonorsCard(colorScheme: colorScheme),
|
||||
|
||||
_RecentDonorsCard(
|
||||
colorScheme: colorScheme,
|
||||
supporters: _config.supporters,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.secondaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volunteer_activism_rounded,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Good to Know',
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_NoticeLine(
|
||||
icon: Icons.block,
|
||||
text:
|
||||
'Not selling early access, premium features, or paywalls',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.build_outlined,
|
||||
text: 'Funds go to dev tools & testing devices',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.favorite_border,
|
||||
text:
|
||||
'Your support is the only way to keep this project alive',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 24,
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
_NoticeLine(
|
||||
icon: Icons.history,
|
||||
text:
|
||||
'Your name stays permanently in every version it was included in',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.update,
|
||||
text:
|
||||
'Supporter list is updated monthly and embedded in the app',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.cloud_off,
|
||||
text:
|
||||
'No remote server -- everything is stored locally',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_DonateNoticeCard(
|
||||
colorScheme: colorScheme,
|
||||
notices: _config.notices,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -156,16 +123,112 @@ class DonatePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentDonorsCard extends StatelessWidget {
|
||||
class _DonateLinksCard extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final DonateConfig config;
|
||||
|
||||
const _RecentDonorsCard({required this.colorScheme});
|
||||
const _DonateLinksCard({required this.colorScheme, required this.config});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>[];
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
config.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
config.message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
if (!config.enabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'Donation links are currently unavailable.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (var index = 0; index < config.methods.length; index++) ...[
|
||||
_DonateMethodItem(
|
||||
method: config.methods[index],
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
if (index < config.methods.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentDonorsCard extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final List<String> supporters;
|
||||
|
||||
const _RecentDonorsCard({
|
||||
required this.colorScheme,
|
||||
required this.supporters,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
@@ -206,7 +269,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (donorNames.isEmpty)
|
||||
if (supporters.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
@@ -221,7 +284,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No supporters yet — be the first!',
|
||||
'No supporters yet - be the first!',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
@@ -236,7 +299,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: donorNames
|
||||
children: supporters
|
||||
.map(
|
||||
(name) =>
|
||||
_SupporterChip(name: name, colorScheme: colorScheme),
|
||||
@@ -250,135 +313,49 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DonateLinksCard extends StatelessWidget {
|
||||
class _DonateNoticeCard extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final List<String> notices;
|
||||
|
||||
const _DonateLinksCard({required this.colorScheme});
|
||||
const _DonateNoticeCard({required this.colorScheme, required this.notices});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_DonateCardItem(
|
||||
title: 'Ko-fi',
|
||||
subtitle: 'ko-fi.com/zarzet',
|
||||
customIcon: const KofiIcon(size: 22, color: Colors.white),
|
||||
color: const Color(0xFFFF5E5B),
|
||||
url: AppInfo.kofiUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_DonateCardItem(
|
||||
title: 'GitHub Sponsors',
|
||||
subtitle: 'github.com/sponsors/zarzet',
|
||||
customIcon: const GitHubIcon(size: 22, color: Colors.white),
|
||||
color: const Color(0xFF2D333B),
|
||||
url: AppInfo.githubSponsorsUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_CryptoWalletItem(
|
||||
title: 'USDT (TRC20)',
|
||||
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
|
||||
color: const Color(0xFF26A17B),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DonateCardItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget customIcon;
|
||||
final Color color;
|
||||
final String url;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _DonateCardItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.customIcon,
|
||||
required this.color,
|
||||
required this.url,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () =>
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(child: customIcon),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volunteer_activism_rounded,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Good to Know',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (var index = 0; index < notices.length; index++) ...[
|
||||
_NoticeLine(
|
||||
icon: _noticeIcon(index),
|
||||
text: notices[index],
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
if (index < notices.length - 1) const SizedBox(height: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -386,32 +363,18 @@ class _DonateCardItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CryptoWalletItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String walletAddress;
|
||||
final Color color;
|
||||
class _DonateMethodItem extends StatelessWidget {
|
||||
final DonateMethod method;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _CryptoWalletItem({
|
||||
required this.title,
|
||||
required this.walletAddress,
|
||||
required this.color,
|
||||
required this.colorScheme,
|
||||
});
|
||||
const _DonateMethodItem({required this.method, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Color(method.color);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: walletAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$title address copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTap: () => _handleTap(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
@@ -423,16 +386,7 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(child: _methodIcon(method)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -440,7 +394,7 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
method.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -448,10 +402,12 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
walletAddress,
|
||||
method.subtitle.isEmpty
|
||||
? method.walletAddress ?? method.url ?? ''
|
||||
: method.subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontSize: method.isWallet ? 11 : null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -459,7 +415,7 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
method.isWallet ? Icons.copy_rounded : Icons.open_in_new,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -468,6 +424,30 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleTap(BuildContext context) async {
|
||||
if (method.isWallet) {
|
||||
await Clipboard.setData(ClipboardData(text: method.walletAddress!));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${method.title} address copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final url = method.url;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
@@ -543,3 +523,44 @@ class _NoticeLine extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _methodIcon(DonateMethod method) {
|
||||
switch (method.icon.toLowerCase()) {
|
||||
case 'kofi':
|
||||
case 'ko-fi':
|
||||
return const KofiIcon(size: 22, color: Colors.white);
|
||||
case 'github':
|
||||
case 'github-sponsors':
|
||||
return const GitHubIcon(size: 22, color: Colors.white);
|
||||
case 'crypto':
|
||||
case 'wallet':
|
||||
return const Text(
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
case 'coffee':
|
||||
return const Icon(
|
||||
Icons.local_cafe_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
);
|
||||
case 'heart':
|
||||
default:
|
||||
return const Icon(Icons.favorite_rounded, color: Colors.white, size: 22);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _noticeIcon(int index) {
|
||||
const icons = [
|
||||
Icons.block,
|
||||
Icons.build_outlined,
|
||||
Icons.favorite_border,
|
||||
Icons.history,
|
||||
Icons.update,
|
||||
];
|
||||
return icons[index % icons.length];
|
||||
}
|
||||
|
||||
@@ -199,10 +199,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (Platform.isAndroid)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.downloading_outlined,
|
||||
title: 'Native download worker',
|
||||
title: context.l10n.downloadNativeWorker,
|
||||
titleTrailing: const _BetaBadge(),
|
||||
subtitle: hasDownloadExtensions
|
||||
? 'Beta Android service worker for extension downloads'
|
||||
? context.l10n.downloadNativeWorkerSubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
value:
|
||||
settings.nativeDownloadWorkerEnabled &&
|
||||
@@ -382,6 +382,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return context.l10n.downloadLossyMp3;
|
||||
case 'aac_320':
|
||||
return context.l10n.downloadLossyAac;
|
||||
case 'opus_256':
|
||||
return context.l10n.downloadLossyOpus256;
|
||||
case 'opus_128':
|
||||
@@ -441,6 +443,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: Text(context.l10n.downloadLossyAac),
|
||||
subtitle: Text(context.l10n.downloadLossyAacSubtitle),
|
||||
trailing: current == 'aac_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('aac_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus256),
|
||||
@@ -829,7 +845,7 @@ class _BetaBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'BETA',
|
||||
context.l10n.badgeBeta,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
@@ -253,8 +253,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
|
||||
if (extension.hasServiceHealth) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Service Status'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionServiceStatus,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -339,10 +341,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.monitor_heart_outlined,
|
||||
title: 'Service health',
|
||||
title: context.l10n.extensionServiceHealth,
|
||||
enabled: extension.hasServiceHealth,
|
||||
subtitle: extension.hasServiceHealth
|
||||
? '${extension.serviceHealth.length} check${extension.serviceHealth.length == 1 ? '' : 's'} configured'
|
||||
? context.l10n.extensionHealthChecksConfigured(
|
||||
extension.serviceHealth.length,
|
||||
)
|
||||
: null,
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -570,7 +574,7 @@ class _OauthLoginLinkPreview extends StatelessWidget {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) {
|
||||
return Text(
|
||||
'Tap Connect to Spotify to fill this field.',
|
||||
context.l10n.extensionOauthConnectHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
@@ -725,18 +729,18 @@ IconData _healthStatusIcon(String status) {
|
||||
}
|
||||
}
|
||||
|
||||
String _healthStatusLabel(String status) {
|
||||
String _healthStatusLabel(BuildContext context, String status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'Online';
|
||||
return context.l10n.extensionHealthOnline;
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
return context.l10n.extensionHealthDegraded;
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
return context.l10n.extensionHealthOffline;
|
||||
case 'unsupported':
|
||||
return 'Not configured';
|
||||
return context.l10n.extensionHealthNotConfigured;
|
||||
default:
|
||||
return 'Unknown';
|
||||
return context.l10n.extensionHealthUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +775,7 @@ class _HealthSummaryItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_healthStatusLabel(statusValue),
|
||||
_healthStatusLabel(context, statusValue),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -780,7 +784,11 @@ class _HealthSummaryItem extends StatelessWidget {
|
||||
if (status?.checkedAt != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Last checked ${TimeOfDay.fromDateTime(status!.checkedAt!.toLocal()).format(context)}',
|
||||
context.l10n.extensionLastChecked(
|
||||
TimeOfDay.fromDateTime(
|
||||
status!.checkedAt!.toLocal(),
|
||||
).format(context),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -790,7 +798,7 @@ class _HealthSummaryItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh status',
|
||||
tooltip: context.l10n.extensionRefreshStatus,
|
||||
onPressed: isRefreshing ? null : onRefresh,
|
||||
icon: isRefreshing
|
||||
? SizedBox(
|
||||
@@ -829,11 +837,11 @@ class _HealthCheckItem extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final color = _healthStatusColor(colorScheme, check.status);
|
||||
final detailParts = <String>[
|
||||
_healthStatusLabel(check.status),
|
||||
_healthStatusLabel(context, check.status),
|
||||
if (check.httpStatus != null) 'HTTP ${check.httpStatus}',
|
||||
if (check.serviceKey?.isNotEmpty == true) check.serviceKey!,
|
||||
if (check.latencyMs > 0) '${check.latencyMs} ms',
|
||||
if (check.required) 'required',
|
||||
if (check.required) context.l10n.extensionHealthRequired,
|
||||
];
|
||||
final message = check.error?.trim().isNotEmpty == true
|
||||
? check.error!
|
||||
@@ -1090,7 +1098,8 @@ class _SettingItemState extends State<_SettingItem> {
|
||||
)
|
||||
else
|
||||
Text(
|
||||
widget.value?.toString() ?? 'Not set',
|
||||
widget.value?.toString() ??
|
||||
context.l10n.extensionSettingNotSet,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
@@ -1144,7 +1153,7 @@ class _SettingItemState extends State<_SettingItem> {
|
||||
final error =
|
||||
payload['error'] as String? ??
|
||||
result['error'] as String? ??
|
||||
'Action failed';
|
||||
context.l10n.extensionActionFailed;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
@@ -1206,7 +1215,8 @@ class _SettingItemState extends State<_SettingItem> {
|
||||
? TextInputType.number
|
||||
: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.setting.description ?? 'Enter value',
|
||||
hintText:
|
||||
widget.setting.description ?? context.l10n.extensionEnterValue,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
@@ -1327,7 +1337,7 @@ class _PostProcessingHookItem extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Auto',
|
||||
context.l10n.extensionsHomeFeedAuto,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -1384,14 +1394,14 @@ class _URLHandlerInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Custom URL Handling',
|
||||
context.l10n.extensionCustomUrlHandling,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'This extension can handle links from these sites',
|
||||
context.l10n.extensionCustomUrlHandlingSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -1445,7 +1455,7 @@ class _URLHandlerInfo extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Share links from these sites to SpotiFLAC and this extension will handle them.',
|
||||
context.l10n.extensionCustomUrlHandlingShareHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -1533,7 +1543,9 @@ class _QualityOptionItem extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}',
|
||||
context.l10n.extensionSettingsCount(
|
||||
quality.settings.length,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -458,7 +458,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
context.l10n.extensionsErrorLoading
|
||||
: serviceHealthStatus == null
|
||||
? 'v${extension.version}'
|
||||
: 'v${extension.version} · ${_extensionHealthLabel(serviceHealthStatus)}',
|
||||
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
@@ -503,16 +503,16 @@ Color _extensionHealthColor(ColorScheme colorScheme, String status) {
|
||||
}
|
||||
}
|
||||
|
||||
String _extensionHealthLabel(String status) {
|
||||
String _extensionHealthLabel(BuildContext context, String status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'Online';
|
||||
return context.l10n.extensionHealthOnline;
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
return context.l10n.extensionHealthDegraded;
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
return context.l10n.extensionHealthOffline;
|
||||
default:
|
||||
return 'Unknown';
|
||||
return context.l10n.extensionHealthUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.downloadAlbumFolderStructure,
|
||||
subtitle: _getAlbumFolderStructureLabel(
|
||||
context,
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
@@ -234,6 +235,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: context.l10n.downloadFolderOrganization,
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
context,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
onTap: () => _showFolderOrganizationPicker(
|
||||
@@ -375,35 +377,35 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getAlbumFolderStructureLabel(String structure) {
|
||||
String _getAlbumFolderStructureLabel(BuildContext context, String structure) {
|
||||
switch (structure) {
|
||||
case 'album_only':
|
||||
return 'Albums/Album Name/';
|
||||
return context.l10n.albumFolderAlbumOnlySubtitle;
|
||||
case 'artist_year_album':
|
||||
return 'Albums/Artist/[Year] Album/';
|
||||
return context.l10n.albumFolderArtistYearAlbumSubtitle;
|
||||
case 'year_album':
|
||||
return 'Albums/[Year] Album/';
|
||||
return context.l10n.albumFolderYearAlbumSubtitle;
|
||||
case 'artist_album_singles':
|
||||
return 'Artist/Album/ + Artist/Singles/';
|
||||
return context.l10n.albumFolderArtistAlbumSinglesSubtitle;
|
||||
case 'artist_album_flat':
|
||||
return 'Artist/Album/ + Artist/song.flac';
|
||||
return context.l10n.albumFolderArtistAlbumFlatSubtitle;
|
||||
default:
|
||||
return 'Albums/Artist/Album Name/';
|
||||
return context.l10n.albumFolderArtistAlbumSubtitle;
|
||||
}
|
||||
}
|
||||
|
||||
String _getFolderOrganizationLabel(String value) {
|
||||
String _getFolderOrganizationLabel(BuildContext context, String value) {
|
||||
switch (value) {
|
||||
case 'playlist':
|
||||
return 'By Playlist';
|
||||
return context.l10n.folderOrganizationByPlaylist;
|
||||
case 'artist':
|
||||
return 'By Artist';
|
||||
return context.l10n.folderOrganizationByArtist;
|
||||
case 'album':
|
||||
return 'By Album';
|
||||
return context.l10n.folderOrganizationByAlbum;
|
||||
case 'artist_album':
|
||||
return 'Artist/Album';
|
||||
return context.l10n.folderOrganizationByArtistAlbum;
|
||||
default:
|
||||
return 'None';
|
||||
return context.l10n.folderOrganizationNone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +643,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ctx.l10n.snackbarFolderPickerFailed(
|
||||
'Could not keep access to the selected folder',
|
||||
ctx.l10n.errorCouldNotKeepFolderAccess,
|
||||
),
|
||||
),
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
|
||||
@@ -480,7 +480,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scan cancelled',
|
||||
context.l10n.libraryScanCancelled,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -489,7 +489,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'You can retry the scan when ready.',
|
||||
context.l10n.libraryScanCancelledSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer
|
||||
@@ -760,7 +760,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Scanning...',
|
||||
context.l10n.libraryScanning,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
@@ -801,8 +801,9 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
if (!isScanning && excludedDownloadedCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$excludedDownloadedCount from Downloads history '
|
||||
'(excluded from list)',
|
||||
context.l10n.libraryDownloadsHistoryExcluded(
|
||||
excludedDownloadedCount,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -495,9 +495,9 @@ class _LogEntryTile extends StatelessWidget {
|
||||
color: Colors.teal.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Go',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
context.l10n.actionGo,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.teal,
|
||||
@@ -597,7 +597,7 @@ class _LogSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Issue Summary',
|
||||
context.l10n.logIssueSummary,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -653,7 +653,7 @@ class _LogSummaryCard extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Total errors: ${analysis.errorCount}',
|
||||
context.l10n.logTotalErrors(analysis.errorCount),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -807,7 +807,7 @@ class _IssueBadge extends StatelessWidget {
|
||||
if (domains != null && domains!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Affected: ${domains!.join(", ")}',
|
||||
context.l10n.logAffectedDomains(domains!.join(', ')),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -21,6 +21,11 @@ class _LyricsProviderPriorityPageState
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
'spotify',
|
||||
'deezer',
|
||||
'youtube',
|
||||
'kugou',
|
||||
'genius',
|
||||
];
|
||||
|
||||
late List<String> _enabledProviders;
|
||||
@@ -211,6 +216,36 @@ class _LyricsProviderPriorityPageState
|
||||
description: context.l10n.lyricsProviderQqMusicDesc,
|
||||
icon: Icons.queue_music,
|
||||
);
|
||||
case 'spotify':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Spotify',
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.graphic_eq,
|
||||
);
|
||||
case 'deezer':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Deezer',
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.album_outlined,
|
||||
);
|
||||
case 'youtube':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'YouTube',
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.smart_display_outlined,
|
||||
);
|
||||
case 'kugou':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Kugou',
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.library_music_outlined,
|
||||
);
|
||||
case 'genius':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Genius',
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
);
|
||||
default:
|
||||
return _LyricsProviderInfo(
|
||||
name: id,
|
||||
|
||||
@@ -77,8 +77,7 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider:
|
||||
settings.embedMetadata && settings.embedLyrics,
|
||||
showDivider: settings.embedMetadata && settings.embedLyrics,
|
||||
),
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
@@ -88,8 +87,11 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
context,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
onTap: () =>
|
||||
_showLyricsModePicker(context, ref, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
@@ -124,8 +126,12 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
icon: Icons.translate_outlined,
|
||||
title: context.l10n.downloadNeteaseIncludeTranslation,
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? context.l10n.downloadNeteaseIncludeTranslationEnabled
|
||||
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
|
||||
? context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeTranslationEnabled
|
||||
: context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeTranslationDisabled,
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -157,6 +163,17 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.graphic_eq_outlined,
|
||||
title: context.l10n.downloadAppleElrcWordSync,
|
||||
subtitle: settings.lyricsAppleElrcWordSync
|
||||
? context.l10n.downloadAppleElrcWordSyncEnabled
|
||||
: context.l10n.downloadAppleElrcWordSyncDisabled,
|
||||
value: settings.lyricsAppleElrcWordSync,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsAppleElrcWordSync(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: context.l10n.downloadMusixmatchLanguage,
|
||||
@@ -199,6 +216,11 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
'musixmatch': 'Musixmatch',
|
||||
'apple_music': 'Apple Music',
|
||||
'qqmusic': 'QQ Music',
|
||||
'spotify': 'Spotify',
|
||||
'deezer': 'Deezer',
|
||||
'youtube': 'YouTube',
|
||||
'kugou': 'Kugou',
|
||||
'genius': 'Genius',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(
|
||||
@@ -206,9 +228,7 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
List<String> providers,
|
||||
) {
|
||||
if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled;
|
||||
return providers
|
||||
.map((p) => _providerDisplayNames[p] ?? p)
|
||||
.join(' > ');
|
||||
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
|
||||
}
|
||||
|
||||
void _showLyricsModePicker(
|
||||
@@ -233,16 +253,18 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.lyricsMode,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.lyricsModeDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
@@ -311,14 +333,16 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadMusixmatchLanguage,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.downloadMusixmatchLanguageDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
|
||||
@@ -106,6 +106,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
Future<void> _requestStoragePermission() async {
|
||||
final permissionAudio = context.l10n.permissionAudio;
|
||||
final permissionStorage = context.l10n.permissionStorage;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
@@ -121,7 +123,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
allGranted = audioStatus.isGranted;
|
||||
|
||||
if (audioStatus.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Audio');
|
||||
await _showPermissionDeniedDialog(permissionAudio);
|
||||
return;
|
||||
}
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
@@ -139,7 +141,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final status = await Permission.storage.request();
|
||||
allGranted = status.isGranted;
|
||||
if (status.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Storage');
|
||||
await _showPermissionDeniedDialog(permissionStorage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -184,6 +186,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
final permissionNotification = context.l10n.permissionNotification;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
@@ -191,14 +194,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (status.isGranted || status.isProvisional) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Notification');
|
||||
await _showPermissionDeniedDialog(permissionNotification);
|
||||
}
|
||||
} else if (_androidSdkVersion >= 33) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Notification');
|
||||
await _showPermissionDeniedDialog(permissionNotification);
|
||||
}
|
||||
} else {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
@@ -392,7 +395,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
validation.errorReason ?? 'Invalid folder selected',
|
||||
validation.errorReason ??
|
||||
context.l10n.errorInvalidFolderSelected,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
@@ -410,7 +414,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarFolderPickerFailed(
|
||||
'Could not keep access to the selected folder',
|
||||
context.l10n.errorCouldNotKeepFolderAccess,
|
||||
),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
@@ -681,7 +685,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
'assets/images/logo-transparent.png',
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
color: colorScheme.primary,
|
||||
|
||||
@@ -194,7 +194,7 @@ class _ExtensionDetailsScreenState
|
||||
textColor: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
_Badge(
|
||||
label: _getCategoryName(ext.category),
|
||||
label: _getCategoryName(context, ext.category),
|
||||
color: colorScheme.tertiaryContainer,
|
||||
textColor: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
@@ -390,7 +390,7 @@ class _ExtensionDetailsScreenState
|
||||
),
|
||||
_MetadataRow(
|
||||
label: context.l10n.extensionMinAppVersion,
|
||||
value: ext.minAppVersion ?? 'Any',
|
||||
value: ext.minAppVersion ?? context.l10n.storeAnyVersion,
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
@@ -496,18 +496,18 @@ class _ExtensionDetailsScreenState
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryName(String category) {
|
||||
String _getCategoryName(BuildContext context, String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
return context.l10n.storeCategoryMetadata;
|
||||
case 'download':
|
||||
return 'Download';
|
||||
return context.l10n.storeCategoryDownload;
|
||||
case 'utility':
|
||||
return 'Utility';
|
||||
return context.l10n.storeCategoryUtility;
|
||||
case 'lyrics':
|
||||
return 'Lyrics';
|
||||
return context.l10n.storeCategoryLyrics;
|
||||
case 'integration':
|
||||
return 'Integration';
|
||||
return context.l10n.storeCategoryIntegration;
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
|
||||
@@ -265,11 +265,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
case 'track_number':
|
||||
return l10n.editMetadataFieldTrackNum;
|
||||
case 'total_tracks':
|
||||
return 'Track Total';
|
||||
return l10n.editMetadataFieldTrackTotal;
|
||||
case 'disc_number':
|
||||
return l10n.editMetadataFieldDiscNum;
|
||||
case 'total_discs':
|
||||
return 'Disc Total';
|
||||
return l10n.editMetadataFieldDiscTotal;
|
||||
case 'genre':
|
||||
return l10n.editMetadataFieldGenre;
|
||||
case 'isrc':
|
||||
@@ -281,7 +281,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
case 'copyright':
|
||||
return l10n.editMetadataFieldCopyright;
|
||||
case 'composer':
|
||||
return 'Composer';
|
||||
return l10n.editMetadataFieldComposer;
|
||||
case 'cover':
|
||||
return l10n.editMetadataFieldCover;
|
||||
default:
|
||||
@@ -1224,16 +1224,23 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
const SizedBox(height: 6),
|
||||
_buildCoverEditor(cs),
|
||||
_buildAutoFillSection(cs),
|
||||
_field('Title', _titleCtrl),
|
||||
_field('Artist', _artistCtrl),
|
||||
_field('Album', _albumCtrl),
|
||||
_field('Album Artist', _albumArtistCtrl),
|
||||
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
|
||||
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
|
||||
_field(context.l10n.editMetadataFieldArtist, _artistCtrl),
|
||||
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldAlbumArtist,
|
||||
_albumArtistCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldDate,
|
||||
_dateCtrl,
|
||||
hint: context.l10n.editMetadataFieldDateHint,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Track #',
|
||||
context.l10n.editMetadataFieldTrackNum,
|
||||
_trackNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
@@ -1241,7 +1248,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Track Total',
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
@@ -1253,7 +1260,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Disc #',
|
||||
context.l10n.editMetadataFieldDiscNum,
|
||||
_discNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
@@ -1261,15 +1268,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Disc Total',
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field('Genre', _genreCtrl),
|
||||
_field('ISRC', _isrcCtrl),
|
||||
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
|
||||
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
@@ -1295,7 +1302,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Advanced',
|
||||
context.l10n.editMetadataAdvanced,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
@@ -1305,10 +1312,20 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
if (_showAdvanced) ...[
|
||||
_field('Label', _labelCtrl),
|
||||
_field('Copyright', _copyrightCtrl),
|
||||
_field('Composer', _composerCtrl),
|
||||
_field('Comment', _commentCtrl, maxLines: 3),
|
||||
_field(context.l10n.editMetadataFieldLabel, _labelCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldCopyright,
|
||||
_copyrightCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@@ -1501,7 +1518,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Cover Art',
|
||||
context.l10n.editMetadataFieldCover,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
|
||||
|
||||
@@ -17,11 +17,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
@@ -365,13 +367,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedDuration = _readPositiveInt(metadata['duration']);
|
||||
final resolvedBitDepth = readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedFormat = _normalizeAudioFormatValue(
|
||||
metadata['audio_codec']?.toString() ?? metadata['format']?.toString(),
|
||||
);
|
||||
final resolvedBitrate = _isBitrateFormatValue(resolvedFormat)
|
||||
? _readPlausibleBitrateKbps(
|
||||
metadata['bitrate'] ?? metadata['bit_rate'],
|
||||
)
|
||||
: null;
|
||||
final resolvedDuration = readPositiveInt(metadata['duration']);
|
||||
final resolvedAlbum = metadata['album']?.toString();
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
final resolvedQuality = _displayQualityForValues(
|
||||
format: resolvedFormat ?? _storedAudioFormat,
|
||||
bitDepth: resolvedBitDepth ?? bitDepth,
|
||||
sampleRate: resolvedSampleRate ?? sampleRate,
|
||||
bitrateKbps: resolvedBitrate ?? _audioBitrate,
|
||||
storedQuality: _quality,
|
||||
);
|
||||
|
||||
@@ -386,10 +398,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// Resolve label/copyright from file when the model doesn't carry them
|
||||
// (e.g. local library items, or download history items without these fields).
|
||||
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
|
||||
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
|
||||
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
|
||||
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
|
||||
final resolvedTrackNumber = readPositiveInt(metadata['track_number']);
|
||||
final resolvedTotalTracks = readPositiveInt(metadata['total_tracks']);
|
||||
final resolvedDiscNumber = readPositiveInt(metadata['disc_number']);
|
||||
final resolvedTotalDiscs = readPositiveInt(metadata['total_discs']);
|
||||
final resolvedComposer = metadata['composer']?.toString();
|
||||
final resolvedLabel = metadata['label']?.toString();
|
||||
final resolvedCopyright = metadata['copyright']?.toString();
|
||||
@@ -426,6 +438,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
!_isLocalItem &&
|
||||
(resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
resolvedBitrate != null ||
|
||||
resolvedFormat != null ||
|
||||
needsTrackNumber ||
|
||||
needsTotalTracks ||
|
||||
needsDiscNumber ||
|
||||
@@ -475,6 +489,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
bitrate: resolvedBitrate,
|
||||
format: resolvedFormat,
|
||||
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
||||
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
||||
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
||||
@@ -482,6 +498,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
duration: needsDuration ? resolvedDuration : null,
|
||||
composer: needsComposer ? resolvedComposer : null,
|
||||
);
|
||||
if (mounted && _downloadItem != null) {
|
||||
setState(() {
|
||||
_currentDownloadItem = _downloadItem!.copyWith(
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
bitrate: resolvedBitrate,
|
||||
format: resolvedFormat,
|
||||
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
||||
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
||||
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
||||
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
||||
duration: needsDuration ? resolvedDuration : null,
|
||||
composer: needsComposer ? resolvedComposer : null,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (_isLocalItem && needsDuration) {
|
||||
await LibraryDatabase.instance.updateAudioMetadata(
|
||||
_localLibraryItem!.id,
|
||||
@@ -614,7 +647,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
int? get totalTracks =>
|
||||
_readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalTracks
|
||||
: _downloadItem!.totalTracks);
|
||||
@@ -631,7 +664,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
int? get totalDiscs =>
|
||||
_readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.totalDiscs
|
||||
: _downloadItem!.totalDiscs);
|
||||
@@ -670,17 +703,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_editedMetadata?['composer']?.toString() ??
|
||||
(_isLocalItem ? _localLibraryItem!.composer : null);
|
||||
int? get duration =>
|
||||
_readPositiveInt(_editedMetadata?['duration']) ??
|
||||
readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
int? get bitDepth =>
|
||||
_readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
|
||||
int? get sampleRate =>
|
||||
_readPositiveInt(_editedMetadata?['sample_rate']) ??
|
||||
readPositiveInt(_editedMetadata?['sample_rate']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.sampleRate
|
||||
: _downloadItem!.sampleRate);
|
||||
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
|
||||
int? get _audioBitrate =>
|
||||
_isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate;
|
||||
String? get _storedAudioFormat =>
|
||||
_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format;
|
||||
|
||||
String get _filePath =>
|
||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
@@ -706,15 +742,83 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
|
||||
|
||||
int? _readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
String? _normalizeAudioFormatValue(String? value) {
|
||||
final normalized = normalizeOptionalString(
|
||||
value,
|
||||
)?.toLowerCase().replaceAll('-', '_');
|
||||
return switch (normalized) {
|
||||
'flac' => 'flac',
|
||||
'alac' => 'alac',
|
||||
'aac' || 'mp4a' => 'aac',
|
||||
'eac3' || 'ec_3' => 'eac3',
|
||||
'ac3' || 'ac_3' => 'ac3',
|
||||
'ac4' || 'ac_4' => 'ac4',
|
||||
'mp3' => 'mp3',
|
||||
'opus' || 'ogg' => 'opus',
|
||||
'm4a' || 'mp4' => 'm4a',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
int? _readPlausibleBitrateKbps(dynamic value) {
|
||||
final parsed = readPositiveInt(value);
|
||||
if (parsed == null) return null;
|
||||
final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
|
||||
return kbps >= 16 ? kbps : null;
|
||||
}
|
||||
|
||||
bool _isBitrateFormatValue(String? value) {
|
||||
return const {
|
||||
'aac',
|
||||
'eac3',
|
||||
'ac3',
|
||||
'ac4',
|
||||
'mp3',
|
||||
'opus',
|
||||
'm4a',
|
||||
}.contains(_normalizeAudioFormatValue(value));
|
||||
}
|
||||
|
||||
String? _usableStoredQuality(String? quality) {
|
||||
final normalized = normalizeOptionalString(quality);
|
||||
if (normalized == null || isPlaceholderQualityLabel(normalized)) {
|
||||
return null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
final bitrateMatch = RegExp(
|
||||
r'\b(\d+)\s*kbps\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(normalized);
|
||||
if (bitrateMatch != null) {
|
||||
final bitrate = int.tryParse(bitrateMatch.group(1) ?? '');
|
||||
if (bitrate != null && bitrate < 16) return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
String? _displayQualityForValues({
|
||||
required String? format,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrateKbps,
|
||||
String? storedQuality,
|
||||
}) {
|
||||
final normalizedFormat = _normalizeAudioFormatValue(format);
|
||||
final formatLabel = normalizedFormat == null
|
||||
? normalizeOptionalString(format)?.toUpperCase()
|
||||
: _formatLabelForRaw(normalizedFormat);
|
||||
if (_isBitrateFormatValue(normalizedFormat)) {
|
||||
return buildDisplayAudioQuality(
|
||||
bitrateKbps: bitrateKbps,
|
||||
format: formatLabel,
|
||||
) ??
|
||||
_usableStoredQuality(storedQuality) ??
|
||||
formatLabel;
|
||||
}
|
||||
return buildDisplayAudioQuality(
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
storedQuality: _usableStoredQuality(storedQuality),
|
||||
);
|
||||
}
|
||||
|
||||
String _displayServiceTrackId(String value) {
|
||||
@@ -776,11 +880,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: null;
|
||||
|
||||
return buildDisplayAudioQuality(
|
||||
return _displayQualityForValues(
|
||||
format: _storedAudioFormat ?? fileExt,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: _isLocalItem ? _localBitrate : null,
|
||||
format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt,
|
||||
bitrateKbps: _audioBitrate,
|
||||
storedQuality: _quality,
|
||||
);
|
||||
}
|
||||
@@ -1311,8 +1415,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Local',
|
||||
Text(
|
||||
context.l10n.librarySourceLocal,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1405,11 +1509,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (openService == 'deezer') {
|
||||
buttonLabel = context.l10n.trackOpenInDeezer;
|
||||
} else if (openService == 'amazon') {
|
||||
buttonLabel = 'Open in Amazon Music';
|
||||
buttonLabel = context.l10n.trackOpenInService(
|
||||
'Amazon Music',
|
||||
);
|
||||
} else if (openService == 'tidal') {
|
||||
buttonLabel = 'Open in Tidal';
|
||||
buttonLabel = context.l10n.trackOpenInService('Tidal');
|
||||
} else if (openService == 'qobuz') {
|
||||
buttonLabel = 'Open in Qobuz';
|
||||
buttonLabel = context.l10n.trackOpenInService('Qobuz');
|
||||
} else {
|
||||
buttonLabel = context.l10n.trackOpenInSpotify;
|
||||
}
|
||||
@@ -1514,11 +1620,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||
if (totalTracks != null && totalTracks! > 0)
|
||||
_MetadataItem('Track Total', totalTracks.toString()),
|
||||
_MetadataItem(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
totalTracks.toString(),
|
||||
),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||
if (totalDiscs != null && totalDiscs! > 0)
|
||||
_MetadataItem('Disc Total', totalDiscs.toString()),
|
||||
_MetadataItem(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
totalDiscs.toString(),
|
||||
),
|
||||
if (duration != null)
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
||||
if (audioQualityStr != null)
|
||||
@@ -1532,7 +1644,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (copyright != null && copyright!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
||||
if (composer != null && composer!.isNotEmpty)
|
||||
_MetadataItem('Composer', composer!),
|
||||
_MetadataItem(context.l10n.editMetadataFieldComposer, composer!),
|
||||
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
@@ -1621,6 +1733,44 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatLabelForRaw(String raw) {
|
||||
final normalized = raw.toLowerCase().replaceAll('-', '_');
|
||||
return switch (normalized) {
|
||||
'flac' => 'FLAC',
|
||||
'alac' => 'ALAC',
|
||||
'eac3' || 'ec_3' => 'EAC3',
|
||||
'ac3' || 'ac_3' => 'AC3',
|
||||
'ac4' || 'ac_4' => 'AC4',
|
||||
'aac' || 'mp4a' => 'AAC',
|
||||
'm4a' || 'mp4' => 'M4A',
|
||||
'mp3' => 'MP3',
|
||||
'opus' => 'Opus',
|
||||
'ogg' => 'OGG',
|
||||
_ => raw.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
String _displayFormatLabelForFile(String fileName) {
|
||||
final storedFormat = normalizeOptionalString(_storedAudioFormat);
|
||||
final raw =
|
||||
storedFormat ??
|
||||
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
|
||||
return _formatLabelForRaw(raw);
|
||||
}
|
||||
|
||||
bool _isBitrateFormatLabel(String label) {
|
||||
return const {
|
||||
'MP3',
|
||||
'OPUS',
|
||||
'OGG',
|
||||
'M4A',
|
||||
'AAC',
|
||||
'EAC3',
|
||||
'AC3',
|
||||
'AC4',
|
||||
}.contains(label.toUpperCase());
|
||||
}
|
||||
|
||||
Widget _buildFileInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1629,9 +1779,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
final displayFilePath = _formatPathForDisplay(rawFilePath);
|
||||
final fileName = _extractFileNameFromPathOrUri(rawFilePath);
|
||||
final fileExtension = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: 'Unknown';
|
||||
final fileExtension = _displayFormatLabelForFile(fileName);
|
||||
final resolvedQuality = _displayAudioQuality;
|
||||
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
|
||||
|
||||
@@ -1704,9 +1852,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if ((fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG') &&
|
||||
if (_isBitrateFormatLabel(fileExtension) &&
|
||||
lossyBitrateLabel != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -1726,12 +1872,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_isLocalItem &&
|
||||
_localBitrate != null &&
|
||||
_localBitrate! > 0 &&
|
||||
(fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG'))
|
||||
else if (_audioBitrate != null &&
|
||||
_audioBitrate! > 0 &&
|
||||
_isBitrateFormatLabel(fileExtension))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
@@ -1742,7 +1885,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_localBitrate}kbps',
|
||||
'${_audioBitrate}kbps',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1885,7 +2028,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Source: ${_lyricsSource!}',
|
||||
context.l10n.trackLyricsSource(_lyricsSource!),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -2085,7 +2228,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_rawLyrics = embeddedLyrics;
|
||||
_lyricsSource = embeddedSource.isNotEmpty
|
||||
? embeddedSource
|
||||
: 'Embedded';
|
||||
: context.l10n.trackLyricsEmbeddedSource;
|
||||
_lyricsEmbedded = true;
|
||||
_lyricsLoading = false;
|
||||
_embeddedLyricsChecked = true;
|
||||
@@ -2216,7 +2359,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_rawLyrics = embeddedLyrics;
|
||||
_lyricsSource = embeddedSource.isNotEmpty
|
||||
? embeddedSource
|
||||
: 'Embedded';
|
||||
: context.l10n.trackLyricsEmbeddedSource;
|
||||
_lyricsEmbedded = true;
|
||||
_lyricsLoading = false;
|
||||
_embeddedLyricsChecked = true;
|
||||
@@ -3233,6 +3376,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
return lower.endsWith('.flac') ||
|
||||
lower.endsWith('.m4a') ||
|
||||
lower.endsWith('.aac') ||
|
||||
lower.endsWith('.mp3') ||
|
||||
lower.endsWith('.opus') ||
|
||||
lower.endsWith('.ogg');
|
||||
@@ -3259,9 +3403,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return 'CUE+$audioFmt';
|
||||
}
|
||||
}
|
||||
if (_isLocalItem && _localLibraryItem != null) {
|
||||
final format = normalizeOptionalString(
|
||||
_localLibraryItem!.format,
|
||||
)?.toLowerCase().replaceAll('-', '_');
|
||||
switch (format) {
|
||||
case 'flac':
|
||||
return 'FLAC';
|
||||
case 'alac':
|
||||
return 'ALAC';
|
||||
case 'm4a':
|
||||
return 'M4A';
|
||||
case 'aac':
|
||||
case 'mp4a':
|
||||
return 'AAC';
|
||||
case 'mp3':
|
||||
return 'MP3';
|
||||
case 'opus':
|
||||
case 'ogg':
|
||||
return 'Opus';
|
||||
}
|
||||
}
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) return 'FLAC';
|
||||
if (lower.endsWith('.m4a')) return 'M4A';
|
||||
if (lower.endsWith('.aac')) return 'AAC';
|
||||
if (lower.endsWith('.mp3')) return 'MP3';
|
||||
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
|
||||
if (lower.endsWith('.cue')) return 'CUE';
|
||||
@@ -3394,19 +3560,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
final formats = <String>[];
|
||||
if (currentFormat == 'FLAC') {
|
||||
formats.addAll(['ALAC', 'MP3', 'Opus']);
|
||||
formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'ALAC') {
|
||||
formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'M4A') {
|
||||
formats.addAll(['FLAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'MP3') {
|
||||
formats.add('Opus');
|
||||
} else if (currentFormat == 'Opus') {
|
||||
formats.add('MP3');
|
||||
} else {
|
||||
formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'AAC') {
|
||||
formats.addAll(['MP3', 'Opus']);
|
||||
} else if (currentFormat == 'MP3') {
|
||||
formats.addAll(['AAC', 'Opus']);
|
||||
} else if (currentFormat == 'Opus') {
|
||||
formats.addAll(['AAC', 'MP3']);
|
||||
} else {
|
||||
formats.addAll(['AAC', 'MP3', 'Opus']);
|
||||
}
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
|
||||
@@ -3471,9 +3647,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3566,6 +3742,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
void _showCueSplitSheet(BuildContext context) async {
|
||||
var cuePath = cleanFilePath;
|
||||
final unknownAlbum = context.l10n.unknownAlbum;
|
||||
final unknownArtist = context.l10n.unknownArtist;
|
||||
final trackSuffix = RegExp(r'#track\d+$');
|
||||
if (trackSuffix.hasMatch(cuePath)) {
|
||||
cuePath = cuePath.replaceFirst(trackSuffix, '');
|
||||
@@ -3586,8 +3764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final album = cueInfo['album'] as String? ?? 'Unknown Album';
|
||||
final artist = cueInfo['artist'] as String? ?? 'Unknown Artist';
|
||||
final album = cueInfo['album'] as String? ?? unknownAlbum;
|
||||
final artist = cueInfo['artist'] as String? ?? unknownArtist;
|
||||
final audioPath = cueInfo['audio_path'] as String? ?? '';
|
||||
final genre = cueInfo['genre'] as String? ?? '';
|
||||
final date = cueInfo['date'] as String? ?? '';
|
||||
@@ -4236,6 +4414,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
@@ -4277,11 +4459,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final deletedOriginal = await PlatformBridge.safDelete(
|
||||
cleanFilePath,
|
||||
).catchError((_) => false);
|
||||
if (deletedOriginal != true) {
|
||||
_log.w('Converted SAF file created but failed deleting original URI');
|
||||
if (!isSameContentUri(cleanFilePath, safUri)) {
|
||||
final deletedOriginal = await PlatformBridge.safDelete(
|
||||
cleanFilePath,
|
||||
).catchError((_) => false);
|
||||
if (deletedOriginal != true) {
|
||||
_log.w(
|
||||
'Converted SAF file created but failed deleting original URI',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isLocalItem) {
|
||||
@@ -4290,6 +4476,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
safUri,
|
||||
newSafFileName: newFileName,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
@@ -4317,6 +4508,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_downloadItem!.id,
|
||||
newPath,
|
||||
newQuality: newQuality,
|
||||
newFormat: normalizedConvertedAudioFormat(targetFormat),
|
||||
newBitrate: convertedAudioBitrateKbps(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
),
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
@@ -4394,7 +4590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
};
|
||||
|
||||
final initialDurationSeconds =
|
||||
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -4433,7 +4629,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (refreshedLyrics.isNotEmpty) {
|
||||
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
|
||||
_rawLyrics = refreshedLyrics;
|
||||
_lyricsSource = 'Embedded';
|
||||
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
|
||||
_lyricsEmbedded = true;
|
||||
} else {
|
||||
_lyrics = null;
|
||||
|
||||