mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe72286273 | |||
| 2b8ec744dd | |||
| 5f11f5b114 | |||
| 61f62363b3 | |||
| 3278e32711 | |||
| 0be6455d46 | |||
| 0bf5a39a92 | |||
| 5424648158 | |||
| dcfd95f276 | |||
| 4d6f7d8b08 | |||
| 2c2cf8cdf8 | |||
| 08c738dc69 | |||
| eb36b0bb7b | |||
| 3fd14e21eb | |||
| 408895b607 | |||
| 1a01147a95 | |||
| 8950907428 | |||
| eb40a88437 | |||
| 7f82049beb | |||
| c0c1d745f3 | |||
| c2b38a7c5a | |||
| ae8638a4b2 | |||
| b864fafa82 | |||
| ee5ab1a751 | |||
| 64b884e27a | |||
| dc8bb2cbc2 | |||
| d882fc292c | |||
| 5dc0980ced | |||
| 1cd668c869 | |||
| a827ebf6f4 | |||
| 3917ae02e2 | |||
| bd14c7dc63 | |||
| e0e28aee38 | |||
| 1550eedc12 | |||
| b2074dfd02 | |||
| e9171d6f21 | |||
| ef60bba2e1 | |||
| 12fb942f16 | |||
| 3a2481e8b2 | |||
| bede5ae8d7 | |||
| 445b186e3b | |||
| 354fe61b85 | |||
| 95f5ae610e | |||
| 2e806a28b9 | |||
| 2ab0350733 | |||
| ce813bc216 | |||
| 21fe047e00 | |||
| 8558450378 | |||
| f9e68b628d | |||
| 50509d0a16 | |||
| c1c0494912 | |||
| 58e615462c | |||
| f0bf769f0d | |||
| 423d50cfb5 | |||
| 2f4a62e03c | |||
| e64bea41e6 | |||
| f0acda0f01 | |||
| af4e4561ec | |||
| 1787059f42 | |||
| b2705cb2ae | |||
| f236d72a19 | |||
| cf270a36ff | |||
| 6d932386b0 | |||
| 9c054b9e3a | |||
| d9f0007a2d | |||
| ee35f52baf | |||
| 21347420f3 | |||
| 26987459f3 | |||
| 897388853b | |||
| ef52332b8b | |||
| 1489378ffd | |||
| ccc93f881a | |||
| ded8b68098 | |||
| 983be8b37a |
@@ -66,7 +66,7 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "25"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -388,8 +388,6 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
  
|
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -399,7 +397,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -565,7 +563,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -574,7 +572,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -584,7 +582,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
+3
-1
@@ -60,7 +60,9 @@ ios/Flutter/Flutter.framework/
|
|||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
|
||||||
# Extension folder
|
# Extension folder
|
||||||
extension/
|
extension/*
|
||||||
|
extension/v2/
|
||||||
|
extension/v2/**
|
||||||
|
|
||||||
# Agent instructions
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 37
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -26,13 +26,13 @@ android {
|
|||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -62,6 +62,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
ndk {
|
ndk {
|
||||||
debugSymbolLevel = "FULL"
|
debugSymbolLevel = "FULL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -108,6 +114,23 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:exported="true"
|
||||||
|
android:enabled="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- flutter_local_notifications receivers -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
@@ -124,6 +147,10 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
|||||||
import io.flutter.embedding.engine.FlutterShellArgs
|
import io.flutter.embedding.engine.FlutterShellArgs
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.ryanheise.audioservice.AudioServicePlugin
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -36,6 +38,10 @@ import java.security.MessageDigest
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
class MainActivity: FlutterFragmentActivity() {
|
||||||
|
override fun provideFlutterEngine(context: Context): FlutterEngine {
|
||||||
|
return AudioServicePlugin.getFlutterEngine(context)
|
||||||
|
}
|
||||||
|
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
||||||
"com.zarz.spotiflac/download_progress_stream"
|
"com.zarz.spotiflac/download_progress_stream"
|
||||||
@@ -47,6 +53,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var backendChannel: MethodChannel? = null
|
||||||
|
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
private val safDirLock = Any()
|
private val safDirLock = Any()
|
||||||
@@ -148,8 +156,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"mali-t7",
|
"mali-t7",
|
||||||
"powervr sgx",
|
"powervr sgx",
|
||||||
"powervr ge8320",
|
"powervr ge8320",
|
||||||
|
"vivante",
|
||||||
"gc1000",
|
"gc1000",
|
||||||
"gc2000",
|
"gc2000",
|
||||||
|
"gc4000",
|
||||||
|
"gc5000",
|
||||||
|
"gc7000",
|
||||||
|
"gc8000",
|
||||||
|
"gc820",
|
||||||
|
"gc880",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||||
@@ -163,6 +178,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"apq8084",
|
"apq8084",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sony Walkman / audio players report MANUFACTURER "SonyAudio" (distinct
|
||||||
|
// from Xperia phones, which use "Sony"). They ship legacy Vivante GPUs
|
||||||
|
// whose drivers crash in glLinkProgram with Impeller shaders, and the GL
|
||||||
|
// renderer string is unavailable when shell args are built, so match on
|
||||||
|
// the manufacturer instead.
|
||||||
|
private val PROBLEMATIC_MANUFACTURERS = listOf(
|
||||||
|
"sonyaudio",
|
||||||
|
)
|
||||||
|
|
||||||
private val PROBLEMATIC_MODELS = listOf(
|
private val PROBLEMATIC_MODELS = listOf(
|
||||||
"sm-t220",
|
"sm-t220",
|
||||||
"sm-t225",
|
"sm-t225",
|
||||||
@@ -173,6 +197,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val board = Build.BOARD.lowercase(Locale.ROOT)
|
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||||
|
val manufacturer = Build.MANUFACTURER.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
for (problematicManufacturer in PROBLEMATIC_MANUFACTURERS) {
|
||||||
|
if (manufacturer.contains(problematicManufacturer)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic manufacturer: $problematicManufacturer")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||||
@@ -2049,14 +2081,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||||
|
val isSessionGrant = host == "session-grant"
|
||||||
val isCallback =
|
val isCallback =
|
||||||
host == "callback" ||
|
isSessionGrant ||
|
||||||
|
host == "callback" ||
|
||||||
host == "spotify-callback" ||
|
host == "spotify-callback" ||
|
||||||
path.contains("callback")
|
path.contains("callback")
|
||||||
if (!isCallback) {
|
if (!isCallback) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
val code = (
|
||||||
|
if (isSessionGrant) {
|
||||||
|
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
|
||||||
|
} else {
|
||||||
|
uri.getQueryParameter("code")
|
||||||
|
}
|
||||||
|
)?.trim().orEmpty()
|
||||||
if (code.isEmpty()) {
|
if (code.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2068,15 +2108,43 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
intent.data = null
|
intent.data = null
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
val json = if (isSessionGrant) {
|
||||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
Gobackend.setExtensionSessionGrantByID(extId, code)
|
||||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
Gobackend.invokeExtensionActionJSON(extId, "completeGrant")
|
||||||
|
} else {
|
||||||
|
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||||
|
Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||||
|
}
|
||||||
|
android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json")
|
||||||
|
if (isSessionGrant) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifySessionGrantCompleted(extId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
|
||||||
|
if (isSessionGrant) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifySessionGrantCompleted(extId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) {
|
||||||
|
val payload = mapOf(
|
||||||
|
"extension_id" to extensionId,
|
||||||
|
"success" to success,
|
||||||
|
)
|
||||||
|
val channel = backendChannel
|
||||||
|
if (channel == null) {
|
||||||
|
pendingSessionGrantEvents.add(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", payload)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
try {
|
try {
|
||||||
Gobackend.cleanupExtensions()
|
Gobackend.cleanupExtensions()
|
||||||
@@ -2140,7 +2208,17 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
val channel = MethodChannel(messenger, CHANNEL)
|
||||||
|
backendChannel = channel
|
||||||
|
if (pendingSessionGrantEvents.isNotEmpty()) {
|
||||||
|
val events = pendingSessionGrantEvents.toList()
|
||||||
|
pendingSessionGrantEvents.clear()
|
||||||
|
for (event in events) {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.setMethodCallHandler { call, result ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
@@ -2225,6 +2303,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"setAllowPrivateNetwork" -> {
|
||||||
|
val allowed = call.argument<Boolean>("allowed") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setAllowPrivateNetwork(allowed)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"checkDuplicate" -> {
|
"checkDuplicate" -> {
|
||||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -2643,6 +2728,46 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"writeM4AFreeformTags" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.writeM4AFreeformTags(filePath, metadataJson)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"ensureAC4Config" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val sourcePath = call.argument<String>("source_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.ensureAC4Config(filePath, sourcePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"writeAC4Metadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
|
||||||
|
val coverPath = call.argument<String>("cover_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.writeAC4Metadata(filePath, metadataJson, coverPath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"writeTempToSaf" -> {
|
"writeTempToSaf" -> {
|
||||||
val tempPath = call.argument<String>("temp_path") ?: ""
|
val tempPath = call.argument<String>("temp_path") ?: ""
|
||||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ object NativeDownloadFinalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
||||||
// Kept as a narrow hook for future richer progress snapshots.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanupFailedFinalizationOutput(
|
private fun cleanupFailedFinalizationOutput(
|
||||||
@@ -422,16 +421,19 @@ object NativeDownloadFinalizer {
|
|||||||
try {
|
try {
|
||||||
for (candidate in decryptionKeyCandidates(key)) {
|
for (candidate in decryptionKeyCandidates(key)) {
|
||||||
checkCancelled(shouldCancel)
|
checkCancelled(shouldCancel)
|
||||||
val attempts = mutableListOf<Pair<String, Boolean>>()
|
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
|
||||||
attempts.add(outputPath to (preferredExt == ".flac"))
|
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
|
||||||
if (preferredExt == ".flac") {
|
if (preferredExt == ".flac") {
|
||||||
attempts.add(buildOutputPath(localInput, ".m4a") to false)
|
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
|
||||||
}
|
}
|
||||||
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
||||||
attempts.add(buildOutputPath(localInput, ".mp4") to false)
|
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
|
||||||
}
|
}
|
||||||
|
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
|
||||||
|
// keeps the .mp4 filename but stores the codec params.
|
||||||
|
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
|
||||||
|
|
||||||
for ((candidateOutput, mapAudioOnly) in attempts) {
|
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
|
||||||
try {
|
try {
|
||||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||||
// Force the flac muxer when the target extension is
|
// Force the flac muxer when the target extension is
|
||||||
@@ -439,7 +441,11 @@ object NativeDownloadFinalizer {
|
|||||||
// stream layout, producing FLAC-in-MP4 under a .flac
|
// stream layout, producing FLAC-in-MP4 under a .flac
|
||||||
// filename which downstream native FLAC tag writers
|
// filename which downstream native FLAC tag writers
|
||||||
// cannot read.
|
// cannot read.
|
||||||
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
|
val muxerOverride = when {
|
||||||
|
forceMov -> "-f mov "
|
||||||
|
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 command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
||||||
val result = runFFmpeg(command, shouldCancel)
|
val result = runFFmpeg(command, shouldCancel)
|
||||||
lastOutput = result.second
|
lastOutput = result.second
|
||||||
@@ -1159,18 +1165,28 @@ object NativeDownloadFinalizer {
|
|||||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
||||||
var adoptedTemp = false
|
var adoptedTemp = false
|
||||||
var originalDeleted = false
|
var originalDeleted = false
|
||||||
try {
|
|
||||||
val command = if (isM4a && coverFile != null) {
|
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||||
|
return if (isM4a && coverFile != null) {
|
||||||
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
||||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
||||||
"-disposition:v:0 attached_pic " +
|
"-disposition:v:0 attached_pic " +
|
||||||
"-metadata:s:v ${q("title=Album cover")} " +
|
"-metadata:s:v ${q("title=Album cover")} " +
|
||||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
||||||
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
|
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||||
} else {
|
} else {
|
||||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
|
val movFlag = if (forceMov) "-f mov " else ""
|
||||||
|
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = runFFmpeg(buildEmbedCommand(false))
|
||||||
|
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||||
|
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||||
|
temp.delete()
|
||||||
|
result = runFFmpeg(buildEmbedCommand(true))
|
||||||
}
|
}
|
||||||
val result = runFFmpeg(command)
|
|
||||||
if (result.first && temp.exists()) {
|
if (result.first && temp.exists()) {
|
||||||
if (inputFile.delete()) {
|
if (inputFile.delete()) {
|
||||||
originalDeleted = true
|
originalDeleted = true
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media" />
|
||||||
|
</automotiveApp>
|
||||||
@@ -11,8 +11,8 @@ subprojects {
|
|||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"name": "SpotiFLAC Mobile",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.6.0",
|
"version": "4.7.1",
|
||||||
"versionDate": "2026-06-13",
|
"versionDate": "2026-07-01",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"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",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 34347687
|
"size": 37455821
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||||
|
type mp4Box struct {
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
hdr int64
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||||
|
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||||
|
|
||||||
|
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||||
|
n := int64(len(data))
|
||||||
|
if pos < 0 || pos+8 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||||
|
typ := string(data[pos+4 : pos+8])
|
||||||
|
hdr := int64(8)
|
||||||
|
if size == 1 {
|
||||||
|
if pos+16 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||||
|
hdr = 16
|
||||||
|
} else if size == 0 {
|
||||||
|
size = n - pos
|
||||||
|
}
|
||||||
|
if size < hdr || pos+size > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
if b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.typ == typ && !fn(b) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||||
|
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||||
|
// which may be nested inside an encrypted (enca) sample entry.
|
||||||
|
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
if len(typ) != 4 {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
for i := start; i+8 <= end; i++ {
|
||||||
|
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||||
|
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||||
|
// entry header (from the box body start) before child boxes begin. ok is false
|
||||||
|
// for malformed/truncated entries whose declared header is not fully present.
|
||||||
|
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||||
|
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||||
|
base := entry.body()
|
||||||
|
if base+10 > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||||
|
hdrLen = 8 + 20
|
||||||
|
switch version {
|
||||||
|
case 1:
|
||||||
|
hdrLen += 16
|
||||||
|
case 2:
|
||||||
|
hdrLen += 36
|
||||||
|
}
|
||||||
|
if base+hdrLen > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hdrLen, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ac4Location struct {
|
||||||
|
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||||
|
entry mp4Box // the ac-4 sample entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return ac4Location{}, false
|
||||||
|
}
|
||||||
|
var found ac4Location
|
||||||
|
var ok2 bool
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||||
|
ok2 = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return found, ok2
|
||||||
|
}
|
||||||
|
|
||||||
|
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||||
|
if b.hdr == 16 {
|
||||||
|
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||||
|
} else {
|
||||||
|
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||||
|
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||||
|
// inserted into moov.
|
||||||
|
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||||
|
base := stco.body() + 4
|
||||||
|
if base+4 <= stco.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||||
|
}
|
||||||
|
p += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||||
|
base := co64.body() + 4
|
||||||
|
if base+4 <= co64.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||||
|
}
|
||||||
|
p += 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||||
|
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||||
|
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||||
|
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||||
|
// flavor for AC-4 even when dac4 is present.
|
||||||
|
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||||
|
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
|
||||||
|
if ftyp.body()+4 <= int64(len(data)) {
|
||||||
|
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||||
|
}
|
||||||
|
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||||
|
if string(data[p:p+4]) == "qt " {
|
||||||
|
copy(data[p:p+4], []byte("isom"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(data)
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
entry := loc.entry
|
||||||
|
verPos := entry.body() + 8
|
||||||
|
if verPos+2 > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||||
|
return data // already v0 (or v2, left untouched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||||
|
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||||
|
extStart := entry.body() + 8 + 20
|
||||||
|
extEnd := extStart + 16
|
||||||
|
if extEnd > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
delta := int64(-16)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||||
|
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(data, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(data, entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(data)-16)
|
||||||
|
out = append(out, data[:extStart]...)
|
||||||
|
out = append(out, data[extEnd:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||||
|
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||||
|
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||||
|
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||||
|
// moov still carries it). No-op when the file has no AC-4 track.
|
||||||
|
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||||
|
dst, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := locateAC4Entry(dst); !ok {
|
||||||
|
return nil // not an AC-4 file; nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
dst = normalizeQuickTimeAudioToMP4(dst)
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(dst)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("malformed ac-4 sample entry")
|
||||||
|
}
|
||||||
|
childStart := loc.entry.body() + hdrLen
|
||||||
|
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||||
|
// Already has dac4; still persist any normalization changes.
|
||||||
|
return os.WriteFile(decryptedPath, dst, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("source has no moov")
|
||||||
|
}
|
||||||
|
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("dac4 not found in source")
|
||||||
|
}
|
||||||
|
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
|
||||||
|
|
||||||
|
insertPos := childStart
|
||||||
|
delta := int64(len(dac4))
|
||||||
|
|
||||||
|
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(dst, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(dst, loc.entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(dst)+len(dac4))
|
||||||
|
out = append(out, dst[:insertPos]...)
|
||||||
|
out = append(out, dac4...)
|
||||||
|
out = append(out, dst[insertPos:]...)
|
||||||
|
|
||||||
|
return os.WriteFile(decryptedPath, out, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mp4TestBox(typ string, body []byte) []byte {
|
||||||
|
out := make([]byte, 8+len(body))
|
||||||
|
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||||
|
copy(out[4:8], typ)
|
||||||
|
copy(out[8:], body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||||
|
entry := mp4TestBox("ac-4", entryBody)
|
||||||
|
stsdBody := append([]byte{
|
||||||
|
0, 0, 0, 0, // version/flags
|
||||||
|
0, 0, 0, 1, // entry_count
|
||||||
|
}, entry...)
|
||||||
|
stsd := mp4TestBox("stsd", stsdBody)
|
||||||
|
stbl := mp4TestBox("stbl", stsd)
|
||||||
|
minf := mp4TestBox("minf", stbl)
|
||||||
|
mdia := mp4TestBox("mdia", minf)
|
||||||
|
trak := mp4TestBox("trak", mdia)
|
||||||
|
moov := mp4TestBox("moov", trak)
|
||||||
|
return moov
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||||
|
body := make([]byte, 10)
|
||||||
|
binary.BigEndian.PutUint16(body[8:10], version)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||||
|
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||||
|
if !bytes.Equal(got, input) {
|
||||||
|
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||||
|
sourcePath := filepath.Join(dir, "source.mp4")
|
||||||
|
|
||||||
|
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||||
|
t.Fatal("expected malformed AC-4 sample entry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||||
|
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||||
|
type ac4Metadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Composer string `json:"composer"`
|
||||||
|
TrackNumber string `json:"trackNumber"`
|
||||||
|
TotalTracks string `json:"totalTracks"`
|
||||||
|
DiscNumber string `json:"discNumber"`
|
||||||
|
TotalDiscs string `json:"totalDiscs"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Lyrics string `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiSafe(s string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesTextTag(atomType, value string) []byte {
|
||||||
|
data := make([]byte, 8+len(value))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||||
|
copy(data[8:], []byte(value))
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||||
|
payload := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||||
|
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||||
|
data := make([]byte, 8+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||||
|
copy(data[8:], payload)
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesCoverTag(image []byte) []byte {
|
||||||
|
typeCode := uint32(13) // JPEG
|
||||||
|
if len(image) >= 8 &&
|
||||||
|
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||||
|
typeCode = 14 // PNG
|
||||||
|
}
|
||||||
|
data := make([]byte, 8+len(image))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||||
|
copy(data[8:], image)
|
||||||
|
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesMetadataHandler() []byte {
|
||||||
|
payload := make([]byte, 0, 25)
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||||
|
payload = append(payload, []byte("mdir")...) // handler type
|
||||||
|
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||||
|
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||||
|
payload = append(payload, 0) // empty name
|
||||||
|
return buildM4AAtom("hdlr", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||||
|
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||||
|
ilst := make([]byte, 0, 256)
|
||||||
|
add := func(atomType, value string) {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("\xa9nam", md.Title)
|
||||||
|
add("\xa9ART", md.Artist)
|
||||||
|
add("\xa9alb", md.Album)
|
||||||
|
add("aART", md.AlbumArtist)
|
||||||
|
add("\xa9day", md.Date)
|
||||||
|
add("\xa9gen", md.Genre)
|
||||||
|
add("\xa9wrt", md.Composer)
|
||||||
|
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||||
|
}
|
||||||
|
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.ISRC) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Label) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Copyright) != "" {
|
||||||
|
add("cprt", md.Copyright)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Lyrics) != "" {
|
||||||
|
add("\xa9lyr", md.Lyrics)
|
||||||
|
}
|
||||||
|
if len(cover) > 0 {
|
||||||
|
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
ilstBox := buildM4AAtom("ilst", ilst)
|
||||||
|
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||||
|
metaPayload = append(metaPayload, ilstBox...)
|
||||||
|
meta := buildM4AAtom("meta", metaPayload)
|
||||||
|
return buildM4AAtom("udta", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||||
|
// the moov of an MP4 buffer and returns the rewritten bytes.
|
||||||
|
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
newUdta := buildITunesUdta(md, cover)
|
||||||
|
|
||||||
|
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||||
|
delta := int64(len(newUdta)) - udta.size
|
||||||
|
shiftChunkOffsets(data, moov, udta.offset, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:udta.offset]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[udta.end():]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := int64(len(newUdta))
|
||||||
|
insertPos := moov.end()
|
||||||
|
shiftChunkOffsets(data, moov, insertPos, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:insertPos]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[insertPos:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||||
|
// true when the file was an AC-4 track and metadata was written; false when the
|
||||||
|
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||||
|
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, ok := locateAC4Entry(data); !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var md ac4Metadata
|
||||||
|
if strings.TrimSpace(metadataJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||||
|
}
|
||||||
|
var cover []byte
|
||||||
|
if strings.TrimSpace(coverPath) != "" {
|
||||||
|
if b, err := os.ReadFile(coverPath); err == nil {
|
||||||
|
cover = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := writeMP4iTunesMetadata(data, md, cover)
|
||||||
|
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -314,7 +314,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
|||||||
footerFlags := uint32(1 << 31)
|
footerFlags := uint32(1 << 31)
|
||||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
// Final layout: header + items + footer
|
|
||||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
result = append(result, header...)
|
result = append(result, header...)
|
||||||
result = append(result, itemsData...)
|
result = append(result, itemsData...)
|
||||||
|
|||||||
+23
-12
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -783,7 +784,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
// not include this field. Albums whose track count is already known (non-zero)
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
// are skipped.
|
// are skipped.
|
||||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
// Find albums that need track counts
|
|
||||||
type indexedID struct {
|
type indexedID struct {
|
||||||
idx int
|
idx int
|
||||||
albumID string
|
albumID string
|
||||||
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
if !isDeezerRetryableError(err) {
|
||||||
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "EOF") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1286,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerAPIError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *deezerAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDeezerRetryableError(err error) bool {
|
||||||
|
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var apiErr *deezerAPIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
|
|||||||
+177
-16
@@ -283,6 +283,7 @@ type DownloadRequest struct {
|
|||||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
TotalDiscs int `json:"total_discs,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
@@ -310,6 +311,7 @@ type DownloadResponse struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
@@ -1378,7 +1380,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
} else if isApe || isWv || isMpc {
|
} else if isApe || isWv || isMpc {
|
||||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||||
result["audio_codec"] = result["format"]
|
result["audio_codec"] = result["format"]
|
||||||
// APE, WavPack, Musepack: read APEv2 tags
|
|
||||||
apeTag, apeErr := ReadAPETags(filePath)
|
apeTag, apeErr := ReadAPETags(filePath)
|
||||||
if apeErr == nil && apeTag != nil {
|
if apeErr == nil && apeTag != nil {
|
||||||
meta := APETagToAudioMetadata(apeTag)
|
meta := APETagToAudioMetadata(apeTag)
|
||||||
@@ -1510,6 +1511,48 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
|
||||||
|
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
|
||||||
|
// written natively after the FFmpeg metadata pass for the values to persist.
|
||||||
|
// Only keys present in the JSON are touched; an empty value clears the tag.
|
||||||
|
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
|
||||||
|
var fields map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(filePath, fields); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
|
||||||
|
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
|
||||||
|
// the file is not AC-4.
|
||||||
|
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
|
||||||
|
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
|
||||||
|
}
|
||||||
|
return `{"success":true}`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
|
||||||
|
// "handled" field reports whether the file was AC-4 (true) so the caller can
|
||||||
|
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
|
||||||
|
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
|
||||||
|
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
|
||||||
|
}
|
||||||
|
resp := map[string]any{"success": true, "handled": handled}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
||||||
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||||
var fields map[string]string
|
var fields map[string]string
|
||||||
@@ -1569,7 +1612,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// APE/WV/MPC: write APEv2 tags natively
|
|
||||||
if isApeFile {
|
if isApeFile {
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
totalTracks := 0
|
totalTracks := 0
|
||||||
@@ -2035,6 +2077,7 @@ func normalizeExtensionTrackMetadataMap(
|
|||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": coverURL,
|
"images": coverURL,
|
||||||
"cover_url": coverURL,
|
"cover_url": coverURL,
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": trackNum,
|
"track_number": trackNum,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -2063,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
|
|||||||
"artist_id": album.ArtistID,
|
"artist_id": album.ArtistID,
|
||||||
"images": album.CoverURL,
|
"images": album.CoverURL,
|
||||||
"cover_url": album.CoverURL,
|
"cover_url": album.CoverURL,
|
||||||
|
"header_image": album.HeaderImage,
|
||||||
|
"header_video": album.HeaderVideo,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
"album_type": album.AlbumType,
|
"album_type": album.AlbumType,
|
||||||
|
"audio_traits": album.AudioTraits,
|
||||||
"provider_id": album.ProviderID,
|
"provider_id": album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2150,11 +2196,13 @@ func getExtensionProviderMetadataResponse(
|
|||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"playlist_info": map[string]interface{}{
|
"playlist_info": map[string]interface{}{
|
||||||
"id": playlist.ID,
|
"id": playlist.ID,
|
||||||
"name": playlist.Name,
|
"name": playlist.Name,
|
||||||
"images": playlist.CoverURL,
|
"images": playlist.CoverURL,
|
||||||
"cover_url": playlist.CoverURL,
|
"cover_url": playlist.CoverURL,
|
||||||
"provider_id": playlist.ProviderID,
|
"header_image": playlist.HeaderImage,
|
||||||
|
"header_video": playlist.HeaderVideo,
|
||||||
|
"provider_id": playlist.ProviderID,
|
||||||
"owner": map[string]interface{}{
|
"owner": map[string]interface{}{
|
||||||
"name": playlist.Artists,
|
"name": playlist.Artists,
|
||||||
"images": playlist.CoverURL,
|
"images": playlist.CoverURL,
|
||||||
@@ -2183,6 +2231,7 @@ func getExtensionProviderMetadataResponse(
|
|||||||
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
||||||
"cover_url": artist.ImageURL,
|
"cover_url": artist.ImageURL,
|
||||||
"header_image": artist.HeaderImage,
|
"header_image": artist.HeaderImage,
|
||||||
|
"header_video": artist.HeaderVideo,
|
||||||
"provider_id": artist.ProviderID,
|
"provider_id": artist.ProviderID,
|
||||||
},
|
},
|
||||||
"albums": albums,
|
"albums": albums,
|
||||||
@@ -2232,6 +2281,16 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
|||||||
|
|
||||||
switch strings.ToLower(trimmedProviderID) {
|
switch strings.ToLower(trimmedProviderID) {
|
||||||
case "deezer":
|
case "deezer":
|
||||||
|
if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil {
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
return GetDeezerMetadata(resourceType, resourceID)
|
return GetDeezerMetadata(resourceType, resourceID)
|
||||||
default:
|
default:
|
||||||
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
||||||
@@ -2247,6 +2306,19 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) {
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(providerID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return response, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||||
if trackID == "" {
|
if trackID == "" {
|
||||||
return "", fmt.Errorf("empty track ID")
|
return "", fmt.Errorf("empty track ID")
|
||||||
@@ -2470,8 +2542,19 @@ func classifyDownloadErrorType(msg string) string {
|
|||||||
return "isp_blocked"
|
return "isp_blocked"
|
||||||
} else if strings.Contains(lowerMsg, "cancel") {
|
} else if strings.Contains(lowerMsg, "cancel") {
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
|
} else if strings.Contains(lowerMsg, "verify_required") ||
|
||||||
|
strings.Contains(lowerMsg, "verification_required") ||
|
||||||
|
strings.Contains(lowerMsg, "verification required") ||
|
||||||
|
strings.Contains(lowerMsg, "needs verification") ||
|
||||||
|
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||||
|
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||||
|
strings.Contains(lowerMsg, "unauthorized") ||
|
||||||
|
strings.Contains(lowerMsg, "precondition required") ||
|
||||||
|
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
||||||
|
messageHasHTTPStatusCode(lowerMsg, "428") {
|
||||||
|
return "verification_required"
|
||||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
strings.Contains(lowerMsg, "429") ||
|
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||||
strings.Contains(lowerMsg, "too many requests") {
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
return "rate_limit"
|
return "rate_limit"
|
||||||
} else if strings.Contains(lowerMsg, "permission") ||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
@@ -2496,6 +2579,15 @@ func classifyDownloadErrorType(msg string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||||
|
return strings.Contains(lowerMsg, "http "+code) ||
|
||||||
|
strings.Contains(lowerMsg, "http status "+code) ||
|
||||||
|
strings.Contains(lowerMsg, "status "+code) ||
|
||||||
|
strings.Contains(lowerMsg, code+" for ") ||
|
||||||
|
strings.Contains(lowerMsg, code+":") ||
|
||||||
|
strings.Contains(lowerMsg, code+";")
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("no cover URL provided")
|
return fmt.Errorf("no cover URL provided")
|
||||||
@@ -2648,8 +2740,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||||
|
|
||||||
// When search_online is true, search for metadata from internet using the
|
|
||||||
// configured metadata-provider priority.
|
|
||||||
if req.SearchOnline {
|
if req.SearchOnline {
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
@@ -2818,7 +2908,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isFlac {
|
if isFlac {
|
||||||
// Native Go FLAC metadata embedding.
|
|
||||||
// Only populate Metadata fields for selected update groups; empty/zero
|
// Only populate Metadata fields for selected update groups; empty/zero
|
||||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||||
// preserving whatever is already in the file.
|
// preserving whatever is already in the file.
|
||||||
@@ -3198,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
req := GetPendingAuthRequest(extensionID)
|
req := ensureExtensionPendingAuthRequest(extensionID)
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -3217,10 +3306,48 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
|
if extensionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req := GetPendingAuthRequest(extensionID); req != nil {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(extensionID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if record, err := ext.runtime.loadSignedSession(config); err == nil {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = ext.runtime.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
|
||||||
|
return GetPendingAuthRequest(extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||||
SetExtensionAuthCode(extensionID, authCode)
|
SetExtensionAuthCode(extensionID, authCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
||||||
|
setPendingSignedSessionGrant(extensionID, grant)
|
||||||
|
}
|
||||||
|
|
||||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
@@ -3387,6 +3514,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3452,6 +3580,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"extension_id": extensionID,
|
"extension_id": extensionID,
|
||||||
"name": result.Name,
|
"name": result.Name,
|
||||||
"cover_url": result.CoverURL,
|
"cover_url": result.CoverURL,
|
||||||
|
"header_image": result.HeaderImage,
|
||||||
|
"header_video": result.HeaderVideo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
@@ -3463,6 +3593,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": result.Track.AlbumArtist,
|
"album_artist": result.Track.AlbumArtist,
|
||||||
"duration_ms": result.Track.DurationMS,
|
"duration_ms": result.Track.DurationMS,
|
||||||
"images": result.Track.ResolvedCoverURL(),
|
"images": result.Track.ResolvedCoverURL(),
|
||||||
|
"preview_url": result.Track.PreviewURL,
|
||||||
"release_date": result.Track.ReleaseDate,
|
"release_date": result.Track.ReleaseDate,
|
||||||
"track_number": result.Track.TrackNumber,
|
"track_number": result.Track.TrackNumber,
|
||||||
"total_tracks": result.Track.TotalTracks,
|
"total_tracks": result.Track.TotalTracks,
|
||||||
@@ -3485,6 +3616,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3506,6 +3638,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": result.Album.Name,
|
"name": result.Album.Name,
|
||||||
"artists": result.Album.Artists,
|
"artists": result.Album.Artists,
|
||||||
"cover_url": result.Album.CoverURL,
|
"cover_url": result.Album.CoverURL,
|
||||||
|
"header_image": result.Album.HeaderImage,
|
||||||
|
"header_video": result.Album.HeaderVideo,
|
||||||
|
"audio_traits": result.Album.AudioTraits,
|
||||||
"release_date": result.Album.ReleaseDate,
|
"release_date": result.Album.ReleaseDate,
|
||||||
"total_tracks": result.Album.TotalTracks,
|
"total_tracks": result.Album.TotalTracks,
|
||||||
"album_type": result.Album.AlbumType,
|
"album_type": result.Album.AlbumType,
|
||||||
@@ -3519,6 +3654,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": result.Artist.Name,
|
"name": result.Artist.Name,
|
||||||
"image_url": result.Artist.ImageURL,
|
"image_url": result.Artist.ImageURL,
|
||||||
"header_image": result.Artist.HeaderImage,
|
"header_image": result.Artist.HeaderImage,
|
||||||
|
"header_video": result.Artist.HeaderVideo,
|
||||||
"listeners": result.Artist.Listeners,
|
"listeners": result.Artist.Listeners,
|
||||||
"provider_id": result.Artist.ProviderID,
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
@@ -3578,6 +3714,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3813,13 +3950,29 @@ func GetStoreCategoriesJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
func storeExtensionPackageSuffix(downloadURL string) string {
|
||||||
|
rawPath := downloadURL
|
||||||
|
if parsed, err := url.Parse(downloadURL); err == nil {
|
||||||
|
rawPath = parsed.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerPath := strings.ToLower(rawPath)
|
||||||
|
if strings.HasSuffix(lowerPath, ".sflx") {
|
||||||
|
return ".sflx"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
|
||||||
|
return ".spotiflac-ext"
|
||||||
|
}
|
||||||
|
return ".spotiflac-ext"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
|
||||||
if strings.TrimSpace(extensionID) == "" {
|
if strings.TrimSpace(extensionID) == "" {
|
||||||
return "", fmt.Errorf("invalid extension id")
|
return "", fmt.Errorf("invalid extension id")
|
||||||
}
|
}
|
||||||
|
|
||||||
safeExtensionID := sanitizeFilename(extensionID)
|
safeExtensionID := sanitizeFilename(extensionID)
|
||||||
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
@@ -3828,7 +3981,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
ext, err := store.findExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3893,9 +4051,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
|
|||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
return extension.%s();
|
return extension.%s();
|
||||||
}
|
}
|
||||||
|
if (typeof %s === 'function') {
|
||||||
|
return %s();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, functionName, functionName)
|
`, functionName, functionName, functionName, functionName)
|
||||||
|
|
||||||
jsStartedAt := time.Now()
|
jsStartedAt := time.Now()
|
||||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||||
|
|||||||
@@ -31,6 +31,44 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"HTTP 401 for /tickets",
|
||||||
|
"HTTP status 428: precondition required",
|
||||||
|
"Verification required",
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||||
|
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
CleanupExtensions()
|
||||||
|
defer CleanupExtensions()
|
||||||
|
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||||
|
ext.ID = "deezer"
|
||||||
|
ext.Manifest.Name = "deezer"
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonText, "album-track") {
|
||||||
|
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dataDir := filepath.Join(dir, "data")
|
dataDir := filepath.Join(dir, "data")
|
||||||
@@ -390,10 +428,25 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||||
}
|
}
|
||||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.spotiflac-ext",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||||
}
|
}
|
||||||
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||||
|
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
|
||||||
|
}
|
||||||
|
if _, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
" ",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err == nil {
|
||||||
t.Fatal("expected invalid extension id")
|
t.Fatal("expected invalid extension id")
|
||||||
}
|
}
|
||||||
if err := ClearStoreCacheJSON(); err != nil {
|
if err := ClearStoreCacheJSON(); err != nil {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
extensionHealthDefaultTimeout = 4 * time.Second
|
extensionHealthDefaultTimeout = 4 * time.Second
|
||||||
extensionHealthMaxBodyBytes = 64 * 1024
|
extensionHealthMaxBodyBytes = 64 * 1024
|
||||||
extensionHealthDefaultCache = 60 * time.Second
|
extensionHealthDefaultCache = 10 * time.Minute
|
||||||
|
extensionHealthMinCache = 60 * time.Second
|
||||||
|
extensionHealthUnknownCache = 2 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExtensionHealthResult struct {
|
type ExtensionHealthResult struct {
|
||||||
@@ -58,6 +60,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := CheckExtensionHealth(ext)
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
bytes, err := json.Marshal(result)
|
bytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -85,16 +88,31 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
|||||||
extensionHealthCacheMu.Unlock()
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
result := CheckExtensionHealth(ext)
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(ext.ID)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||||
|
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||||
|
ttl = extensionHealthUnknownCache
|
||||||
|
}
|
||||||
|
|
||||||
extensionHealthCacheMu.Lock()
|
extensionHealthCacheMu.Lock()
|
||||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||||
result: result,
|
result: result,
|
||||||
expiresAt: now.Add(ttl),
|
expiresAt: time.Now().Add(ttl),
|
||||||
}
|
}
|
||||||
extensionHealthCacheMu.Unlock()
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||||
@@ -149,6 +167,9 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||||
|
if checkTTL < extensionHealthMinCache {
|
||||||
|
checkTTL = extensionHealthMinCache
|
||||||
|
}
|
||||||
if checkTTL < ttl {
|
if checkTTL < ttl {
|
||||||
ttl = checkTTL
|
ttl = checkTTL
|
||||||
}
|
}
|
||||||
@@ -226,7 +247,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
|||||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||||
result.LatencyMs = time.Since(start).Milliseconds()
|
result.LatencyMs = time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Status = "offline"
|
if isTransientExtensionHealthError(err) {
|
||||||
|
result.Status = "unknown"
|
||||||
|
} else {
|
||||||
|
result.Status = "offline"
|
||||||
|
}
|
||||||
result.Error = err.Error()
|
result.Error = err.Error()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -262,6 +287,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTransientExtensionHealthError(err error) bool {
|
||||||
|
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||||
|
}
|
||||||
|
|
||||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||||
if len(strings.TrimSpace(string(body))) == 0 {
|
if len(strings.TrimSpace(string(body))) == 0 {
|
||||||
return "online", ""
|
return "online", ""
|
||||||
@@ -287,6 +316,9 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
|
|||||||
case "degraded", "partial", "warning", "warn":
|
case "degraded", "partial", "warning", "warn":
|
||||||
return "degraded", rawStatus
|
return "degraded", rawStatus
|
||||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
|
if isTransientHealthStatusMessage(string(body)) {
|
||||||
|
return "unknown", rawStatus
|
||||||
|
}
|
||||||
return "offline", rawStatus
|
return "offline", rawStatus
|
||||||
default:
|
default:
|
||||||
return "online", rawStatus
|
return "online", rawStatus
|
||||||
@@ -327,42 +359,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
|
|||||||
|
|
||||||
rawStatus, hasStatus := service["status"]
|
rawStatus, hasStatus := service["status"]
|
||||||
okValue, hasOK := service["ok"].(bool)
|
okValue, hasOK := service["ok"].(bool)
|
||||||
|
joinedMessage := strings.Join(messageParts, ": ")
|
||||||
|
transient := isTransientHealthStatusMessage(detail) ||
|
||||||
|
isTransientHealthStatusMessage(errText) ||
|
||||||
|
isTransientHealthStatusMessage(label)
|
||||||
|
|
||||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||||
if statusCode >= 200 && statusCode < 300 {
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
}
|
}
|
||||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
if transient || isTransientHealthStatusCode(statusCode) {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
return "offline", joinedMessage, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if isExtensionHealthAuthRequired(detail) {
|
if isExtensionHealthAuthRequired(detail) {
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
|
}
|
||||||
|
if transient {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
if hasOK {
|
if hasOK {
|
||||||
if okValue {
|
if okValue {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
return "offline", joinedMessage, true
|
||||||
}
|
}
|
||||||
if !hasStatus {
|
if !hasStatus {
|
||||||
return "unknown", strings.Join(messageParts, ": "), true
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
|
|
||||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||||
switch statusString {
|
switch statusString {
|
||||||
case "ok", "up", "online", "healthy", "operational":
|
case "ok", "up", "online", "healthy", "operational":
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
case "degraded", "partial", "warning", "warn":
|
case "degraded", "partial", "warning", "warn":
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
return "offline", joinedMessage, true
|
||||||
default:
|
default:
|
||||||
return "unknown", strings.Join(messageParts, ": "), true
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +418,32 @@ func isExtensionHealthAuthRequired(detail string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusMessage(text string) bool {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(t, "context deadline exceeded") ||
|
||||||
|
strings.Contains(t, "deadline exceeded") ||
|
||||||
|
strings.Contains(t, "timeout") ||
|
||||||
|
strings.Contains(t, "timed out") ||
|
||||||
|
strings.Contains(t, "temporarily unavailable") ||
|
||||||
|
strings.Contains(t, "try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusCode(code int) bool {
|
||||||
|
switch code {
|
||||||
|
case http.StatusRequestTimeout,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func healthNumber(value interface{}) (int, bool) {
|
func healthNumber(value interface{}) (int, bool) {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
|||||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||||
t.Fatal("expected auth required")
|
t.Fatal("expected auth required")
|
||||||
}
|
}
|
||||||
|
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||||
|
t.Fatal("expected timeout health errors to be transient")
|
||||||
|
}
|
||||||
|
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||||
|
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||||
|
}
|
||||||
|
|
||||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||||
t.Fatalf("nil health = %#v", result)
|
t.Fatalf("nil health = %#v", result)
|
||||||
|
|||||||
@@ -44,18 +44,24 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isExtensionPackagePath(filePath string) bool {
|
||||||
|
lowerPath := strings.ToLower(filePath)
|
||||||
|
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||||
|
}
|
||||||
|
|
||||||
type loadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *extensionRuntime
|
runtime *extensionRuntime
|
||||||
initialized bool
|
indexProgram *goja.Program
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
@@ -166,8 +172,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !isExtensionPackagePath(filePath) {
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -306,6 +312,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
func initializeVMLocked(ext *loadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
ext.VM = nil
|
ext.VM = nil
|
||||||
ext.runtime = nil
|
ext.runtime = nil
|
||||||
|
ext.indexProgram = nil
|
||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
@@ -315,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
ext.indexProgram = indexProgram
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
@@ -341,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err = vm.RunString(string(jsCode))
|
_, err = vm.RunProgram(indexProgram)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -356,10 +368,17 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
indexProgram := ext.indexProgram
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
if indexProgram == nil {
|
||||||
if err != nil {
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := &extensionRuntime{
|
runtime := &extensionRuntime{
|
||||||
@@ -402,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||||
runtime.closeStorageFlusher()
|
runtime.closeStorageFlusher()
|
||||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -673,7 +692,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
loaded = append(loaded, ext.ID)
|
loaded = append(loaded, ext.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if isExtensionPackagePath(entry.Name()) {
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -775,8 +794,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !isExtensionPackagePath(filePath) {
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -924,8 +943,8 @@ type ExtensionUpgradeInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !isExtensionPackagePath(filePath) {
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -1170,14 +1189,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
|
|
||||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||||
|
actionNameLiteral := strconv.Quote(actionName)
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
var actionName = %s;
|
||||||
try {
|
function runAction(fn) {
|
||||||
var result = extension.%s();
|
try {
|
||||||
if (result && typeof result.then === 'function') {
|
var result = fn();
|
||||||
return { success: true, pending: true, message: 'Action started' };
|
if (result && typeof result.then === 'function') {
|
||||||
}
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||||
var isArr = false;
|
var isArr = false;
|
||||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||||
@@ -1192,13 +1213,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true, result: result };
|
return { success: true, result: result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: e.toString() };
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
||||||
return { success: false, error: 'Action function not found: %s' };
|
return runAction(function() { return extension[actionName](); });
|
||||||
})()
|
}
|
||||||
`, actionName, actionName, actionName)
|
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
||||||
|
return runAction(function() { return session.completeGrant(); });
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: ' + actionName };
|
||||||
|
})()
|
||||||
|
`, actionNameLiteral)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignedSessionEndpoints struct {
|
||||||
|
Bootstrap string `json:"bootstrap,omitempty"`
|
||||||
|
Challenge string `json:"challenge,omitempty"`
|
||||||
|
Exchange string `json:"exchange,omitempty"`
|
||||||
|
Refresh string `json:"refresh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignedSessionConfig struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
AppVersion string `json:"appVersion,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||||
|
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||||
|
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||||
|
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||||
|
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||||
|
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
@@ -200,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select type requires options
|
|
||||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: fmt.Sprintf("settings[%d].options", i),
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
@@ -238,6 +259,26 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.SignedSession != nil {
|
||||||
|
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||||
|
if baseURL == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil || parsed.Hostname() == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||||
|
}
|
||||||
|
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+413
-108
@@ -29,6 +29,7 @@ type ExtTrackMetadata struct {
|
|||||||
ExternalURL string `json:"external_urls,omitempty"`
|
ExternalURL string `json:"external_urls,omitempty"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
Images string `json:"images,omitempty"`
|
Images string `json:"images,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
@@ -68,9 +69,12 @@ type ExtAlbumMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
ArtistID string `json:"artist_id,omitempty"`
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
AudioTraits []string `json:"audio_traits,omitempty"`
|
||||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,7 @@ type ExtArtistMetadata struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
HeaderImage string `json:"header_image,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"`
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||||
@@ -473,6 +478,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
|
|||||||
return itemID != "" && isDownloadCancelled(itemID)
|
return itemID != "" && isDownloadCancelled(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExtensionDownloadErrorType(errorType, message string) string {
|
||||||
|
normalized := strings.TrimSpace(errorType)
|
||||||
|
classified := classifyDownloadErrorType(message)
|
||||||
|
if classified != "" && classified != "unknown" {
|
||||||
|
switch strings.ToLower(normalized) {
|
||||||
|
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
|
||||||
|
return classified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
type DownloadDecryptionInfo struct {
|
type DownloadDecryptionInfo struct {
|
||||||
Strategy string `json:"strategy,omitempty"`
|
Strategy string `json:"strategy,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
@@ -483,14 +500,15 @@ type DownloadDecryptionInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtDownloadResult struct {
|
type ExtDownloadResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
BitDepth int `json:"bit_depth,omitempty"`
|
BitDepth int `json:"bit_depth,omitempty"`
|
||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
AudioCodec string `json:"audio_codec,omitempty"`
|
AudioCodec string `json:"audio_codec,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
@@ -724,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
|
||||||
|
value := gojaObjectValue(obj, keys...)
|
||||||
|
if gojaValueIsEmpty(value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exported, ok := value.Export().([]interface{})
|
||||||
|
if !ok || len(exported) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(exported))
|
||||||
|
for _, item := range exported {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
str = strings.TrimSpace(str)
|
||||||
|
if str != "" {
|
||||||
|
result = append(result, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
|
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
|
||||||
if gojaValueIsEmpty(value) {
|
if gojaValueIsEmpty(value) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -754,6 +798,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
|
|||||||
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
||||||
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
|
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
|
||||||
Images: gojaObjectString(obj, "images"),
|
Images: gojaObjectString(obj, "images"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||||
@@ -820,12 +865,147 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
|
|||||||
Artists: gojaObjectString(obj, "artists"),
|
Artists: gojaObjectString(obj, "artists"),
|
||||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
||||||
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||||
|
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||||
}, nil
|
}.withTrackFallbacks(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTrackFallbacks fills the album-level artist and release date from the
|
||||||
|
// album's tracks when the extension did not provide them at the album level.
|
||||||
|
// This is a generic mechanism so any extension benefits, without per-extension
|
||||||
|
// special-casing in the app.
|
||||||
|
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
|
||||||
|
if strings.TrimSpace(a.Artists) == "" {
|
||||||
|
a.Artists = albumArtistFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(a.ReleaseDate) == "" {
|
||||||
|
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
if len(a.AudioTraits) == 0 {
|
||||||
|
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
|
||||||
|
// back to the most common track artist across the album.
|
||||||
|
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
|
||||||
|
for _, t := range tracks {
|
||||||
|
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
order := []string{}
|
||||||
|
for _, t := range tracks {
|
||||||
|
artist := strings.TrimSpace(t.Artists)
|
||||||
|
if artist == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := counts[artist]; !ok {
|
||||||
|
order = append(order, artist)
|
||||||
|
}
|
||||||
|
counts[artist]++
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
bestCount := 0
|
||||||
|
for _, artist := range order {
|
||||||
|
if counts[artist] > bestCount {
|
||||||
|
best = artist
|
||||||
|
bestCount = counts[artist]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumReleaseDateFromTracks returns the first non-empty track release date.
|
||||||
|
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
|
||||||
|
for _, t := range tracks {
|
||||||
|
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
|
||||||
|
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
|
||||||
|
// extensions like Tidal and Qobuz already provide. Tokens match what the album
|
||||||
|
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
|
||||||
|
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
|
||||||
|
atmos := false
|
||||||
|
hiRes := false
|
||||||
|
lossless := false
|
||||||
|
|
||||||
|
for _, t := range tracks {
|
||||||
|
modes := strings.ToUpper(t.AudioModes)
|
||||||
|
quality := strings.ToUpper(t.AudioQuality)
|
||||||
|
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
|
||||||
|
atmos = true
|
||||||
|
}
|
||||||
|
if strings.Contains(quality, "HI_RES") ||
|
||||||
|
strings.Contains(quality, "HIRES") ||
|
||||||
|
strings.Contains(quality, "MASTER") ||
|
||||||
|
strings.Contains(quality, "MQA") {
|
||||||
|
hiRes = true
|
||||||
|
}
|
||||||
|
if strings.Contains(quality, "LOSSLESS") ||
|
||||||
|
strings.Contains(quality, "FLAC") {
|
||||||
|
lossless = true
|
||||||
|
}
|
||||||
|
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
|
||||||
|
if bd > 16 || sr > 48 {
|
||||||
|
hiRes = true
|
||||||
|
} else {
|
||||||
|
lossless = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traits := []string{}
|
||||||
|
if atmos {
|
||||||
|
traits = append(traits, "dolby_atmos")
|
||||||
|
}
|
||||||
|
if hiRes {
|
||||||
|
traits = append(traits, "hi_res_lossless")
|
||||||
|
} else if lossless {
|
||||||
|
traits = append(traits, "lossless")
|
||||||
|
}
|
||||||
|
return traits
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
|
||||||
|
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
|
||||||
|
func parseBitDepthSampleRate(quality string) (int, float64) {
|
||||||
|
lower := strings.ToLower(quality)
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0.0
|
||||||
|
|
||||||
|
if idx := strings.Index(lower, "bit"); idx > 0 {
|
||||||
|
j := idx
|
||||||
|
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
|
||||||
|
bitDepth = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(lower, "khz"); idx > 0 {
|
||||||
|
j := idx
|
||||||
|
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
|
||||||
|
sampleRate = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bitDepth, sampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
|
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
|
||||||
@@ -891,6 +1071,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
|
|||||||
Name: gojaObjectString(obj, "name"),
|
Name: gojaObjectString(obj, "name"),
|
||||||
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
||||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
Listeners: gojaObjectInt(obj, "listeners"),
|
Listeners: gojaObjectInt(obj, "listeners"),
|
||||||
Albums: albums,
|
Albums: albums,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
@@ -942,35 +1123,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
|
|||||||
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
||||||
obj := value.ToObject(vm)
|
obj := value.ToObject(vm)
|
||||||
return ExtDownloadResult{
|
return ExtDownloadResult{
|
||||||
Success: gojaObjectBool(obj, "success"),
|
Success: gojaObjectBool(obj, "success"),
|
||||||
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
||||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||||
Title: gojaObjectString(obj, "title"),
|
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
||||||
Artist: gojaObjectString(obj, "artist"),
|
Title: gojaObjectString(obj, "title"),
|
||||||
Album: gojaObjectString(obj, "album"),
|
Artist: gojaObjectString(obj, "artist"),
|
||||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
Album: gojaObjectString(obj, "album"),
|
||||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
ISRC: gojaObjectString(obj, "isrc"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
Genre: gojaObjectString(obj, "genre"),
|
ISRC: gojaObjectString(obj, "isrc"),
|
||||||
Label: gojaObjectString(obj, "label"),
|
Genre: gojaObjectString(obj, "genre"),
|
||||||
Copyright: gojaObjectString(obj, "copyright"),
|
Label: gojaObjectString(obj, "label"),
|
||||||
Composer: gojaObjectString(obj, "composer"),
|
Copyright: gojaObjectString(obj, "copyright"),
|
||||||
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
Composer: gojaObjectString(obj, "composer"),
|
||||||
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
||||||
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
||||||
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
||||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
||||||
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||||
|
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
||||||
RequiresContainerConversion: gojaObjectBool(
|
RequiresContainerConversion: gojaObjectBool(
|
||||||
obj,
|
obj,
|
||||||
"requires_container_conversion",
|
"requires_container_conversion",
|
||||||
@@ -982,9 +1164,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
|||||||
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
||||||
obj := value.ToObject(vm)
|
obj := value.ToObject(vm)
|
||||||
handleResult := ExtURLHandleResult{
|
handleResult := ExtURLHandleResult{
|
||||||
Type: gojaObjectString(obj, "type"),
|
Type: gojaObjectString(obj, "type"),
|
||||||
Name: gojaObjectString(obj, "name"),
|
Name: gojaObjectString(obj, "name"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
||||||
@@ -2135,6 +2319,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastErrType string
|
||||||
|
var lastRetryAfterSeconds int
|
||||||
var stopProviderFallback bool
|
var stopProviderFallback bool
|
||||||
var sourceExtensionLocked bool
|
var sourceExtensionLocked bool
|
||||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||||
@@ -2416,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
resp.Composer = req.Composer
|
resp.Composer = req.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
|
||||||
}
|
|
||||||
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -2449,11 +2627,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
lastErrType = ""
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
|
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
||||||
|
lastRetryAfterSeconds = result.RetryAfterSeconds
|
||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||||
|
|
||||||
|
if strings.EqualFold(lastErrType, "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
Service: req.Source,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if stopProviderFallback || sourceExtensionLocked {
|
if stopProviderFallback || sourceExtensionLocked {
|
||||||
if sourceExtensionLocked {
|
if sourceExtensionLocked {
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||||
@@ -2461,10 +2652,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "Download failed: " + lastErr.Error(),
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
ErrorType: "extension_error",
|
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
||||||
Service: req.Source,
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
|
Service: req.Source,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2518,6 +2710,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + err.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if terminalAvailability {
|
if terminalAvailability {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
||||||
@@ -2532,12 +2733,26 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback provider: request its own highest quality, not the
|
// Honor the requested quality when this provider recognizes it
|
||||||
// source provider's quality token.
|
// (e.g. an explicit user selection). Only when the token is not
|
||||||
|
// one of this provider's own options do we fall back to its
|
||||||
|
// highest quality, since a source provider's token may not map.
|
||||||
fallbackQuality := req.Quality
|
fallbackQuality := req.Quality
|
||||||
if len(ext.Manifest.QualityOptions) > 0 {
|
if len(ext.Manifest.QualityOptions) > 0 {
|
||||||
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
requested := strings.TrimSpace(req.Quality)
|
||||||
fallbackQuality = best
|
recognized := false
|
||||||
|
if requested != "" {
|
||||||
|
for _, opt := range ext.Manifest.QualityOptions {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
|
||||||
|
recognized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !recognized {
|
||||||
|
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
||||||
|
fallbackQuality = best
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2585,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
applyExtensionRequestFallbacks(&resp, req)
|
applyExtensionRequestFallbacks(&resp, req)
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
|
||||||
}
|
|
||||||
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -2618,10 +2825,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
lastErrType = ""
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
|
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
||||||
|
lastRetryAfterSeconds = result.RetryAfterSeconds
|
||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
effType := lastErrType
|
||||||
|
if effType == "" {
|
||||||
|
effType = classifyDownloadErrorType(lastErr.Error())
|
||||||
|
}
|
||||||
|
if strings.EqualFold(effType, "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if terminalAvailability {
|
if terminalAvailability {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
||||||
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
||||||
@@ -2630,14 +2858,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||||
if errorType == "unknown" {
|
if errorType == "unknown" {
|
||||||
errorType = "not_found"
|
errorType = "not_found"
|
||||||
}
|
}
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||||
ErrorType: errorType,
|
ErrorType: errorType,
|
||||||
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2654,21 +2883,22 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
"total_tracks": req.TotalTracks,
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"playlist_position": req.PlaylistPosition,
|
||||||
"disc_number": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"total_discs": req.TotalDiscs,
|
"disc_number": req.DiscNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"total_discs": req.TotalDiscs,
|
||||||
"date": req.ReleaseDate,
|
"year": extractYear(req.ReleaseDate),
|
||||||
"release_date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"release_date": req.ReleaseDate,
|
||||||
"composer": req.Composer,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
@@ -2713,21 +2943,22 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
|||||||
AddAllowedDownloadDir(tempDir)
|
AddAllowedDownloadDir(tempDir)
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
"total_tracks": req.TotalTracks,
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"playlist_position": req.PlaylistPosition,
|
||||||
"disc_number": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"total_discs": req.TotalDiscs,
|
"disc_number": req.DiscNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"total_discs": req.TotalDiscs,
|
||||||
"date": req.ReleaseDate,
|
"year": extractYear(req.ReleaseDate),
|
||||||
"release_date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"release_date": req.ReleaseDate,
|
||||||
"composer": req.Composer,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
@@ -2761,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
|
|||||||
return err == nil && !info.IsDir() && info.Size() > 0
|
return err == nil && !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
|
||||||
|
if alreadyExists || !req.EmbedMetadata {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := strings.TrimSpace(resp.FilePath)
|
||||||
|
if !canEmbedGenreLabel(filePath) {
|
||||||
|
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
|
||||||
|
var coverData []byte
|
||||||
|
if coverURL != "" {
|
||||||
|
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
|
||||||
|
} else if len(data) > 0 {
|
||||||
|
coverData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
|
||||||
|
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
|
||||||
|
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
|
||||||
|
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
|
||||||
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
|
||||||
|
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
|
||||||
|
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
|
||||||
|
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
|
||||||
|
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
|
||||||
|
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
|
||||||
|
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
|
||||||
|
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
|
||||||
|
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
|
||||||
|
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
|
||||||
|
}
|
||||||
|
if req.EmbedLyrics {
|
||||||
|
metadata.Lyrics = resp.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
|
||||||
|
} else {
|
||||||
|
err = EmbedMetadata(filePath, metadata, "")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPositiveInt(values ...int) int {
|
||||||
|
for _, value := range values {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
return p.customSearch(query, options, "", "")
|
return p.customSearch(query, options, "", "")
|
||||||
}
|
}
|
||||||
@@ -2884,13 +3187,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtURLHandleResult struct {
|
type ExtURLHandleResult struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||||
|
|||||||
@@ -8,11 +8,35 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||||
|
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||||
|
// intended for users who route the app's traffic through a local proxy or
|
||||||
|
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||||
|
var allowPrivateNetworkAccess atomic.Bool
|
||||||
|
|
||||||
|
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||||
|
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||||
|
// layer via the platform bridge.
|
||||||
|
func SetAllowPrivateNetwork(allowed bool) {
|
||||||
|
allowPrivateNetworkAccess.Store(allowed)
|
||||||
|
if allowed {
|
||||||
|
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||||
|
} else {
|
||||||
|
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||||
|
func IsPrivateNetworkAllowed() bool {
|
||||||
|
return allowPrivateNetworkAccess.Load()
|
||||||
|
}
|
||||||
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -303,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIP(host string) bool {
|
func isPrivateIP(host string) bool {
|
||||||
|
// Opt-in escape hatch: when the user has enabled private/local network
|
||||||
|
// access, treat every host as public so local proxies / custom DNS work.
|
||||||
|
if allowPrivateNetworkAccess.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
if hostLower == "" {
|
if hostLower == "" {
|
||||||
return false
|
return false
|
||||||
@@ -465,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
|
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||||
|
sessionObj := vm.NewObject()
|
||||||
|
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||||
|
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||||
|
sessionObj.Set("status", r.signedSessionStatus)
|
||||||
|
sessionObj.Set("clear", r.signedSessionClear)
|
||||||
|
vm.Set("session", sessionObj)
|
||||||
|
}
|
||||||
|
|
||||||
fileObj := vm.NewObject()
|
fileObj := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
|||||||
}
|
}
|
||||||
switch parsedOptions.Mode {
|
switch parsedOptions.Mode {
|
||||||
case "cbc", "ctr":
|
case "cbc", "ctr":
|
||||||
// supported
|
|
||||||
default:
|
default:
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
var totalSize int64
|
var totalSize int64
|
||||||
contentRange := probeResp.Header.Get("Content-Range")
|
contentRange := probeResp.Header.Get("Content-Range")
|
||||||
if contentRange != "" {
|
if contentRange != "" {
|
||||||
// Format: "bytes 0-1/12345"
|
|
||||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||||
sizeStr := contentRange[idx+1:]
|
sizeStr := contentRange[idx+1:]
|
||||||
if sizeStr != "*" {
|
if sizeStr != "*" {
|
||||||
@@ -457,7 +456,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
break // Success
|
break // Success
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-success status
|
|
||||||
io.Copy(io.Discard, chunkResp.Body)
|
io.Copy(io.Discard, chunkResp.Body)
|
||||||
chunkResp.Body.Close()
|
chunkResp.Body.Close()
|
||||||
|
|
||||||
@@ -474,7 +472,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read chunk body and write to file
|
|
||||||
chunkWritten := int64(0)
|
chunkWritten := int64(0)
|
||||||
for {
|
for {
|
||||||
nr, er := chunkResp.Body.Read(buf)
|
nr, er := chunkResp.Body.Read(buf)
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
const signedSessionRefreshSkew = time.Hour
|
||||||
|
|
||||||
|
var (
|
||||||
|
pendingSignedSessionGrants = make(map[string]string)
|
||||||
|
pendingSignedSessionGrantsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type signedSessionRecord struct {
|
||||||
|
InstallID string `json:"install_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
AppVersion string `json:"app_version,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signedSessionExchangeResponse struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
ChallengeID string `json:"challenge_id,omitempty"`
|
||||||
|
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||||
|
AuthURL string `json:"auth_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||||
|
if config == nil {
|
||||||
|
return SignedSessionConfig{}
|
||||||
|
}
|
||||||
|
resolved := *config
|
||||||
|
if resolved.AppVersion == "" {
|
||||||
|
resolved.AppVersion = "ext-1.0"
|
||||||
|
}
|
||||||
|
if resolved.Platform == "" {
|
||||||
|
resolved.Platform = "extension"
|
||||||
|
}
|
||||||
|
if resolved.CallbackURL == "" {
|
||||||
|
resolved.CallbackURL = "spotiflac://session-grant"
|
||||||
|
}
|
||||||
|
if resolved.SchemeLabel == "" {
|
||||||
|
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||||
|
}
|
||||||
|
if resolved.HeaderPrefix == "" {
|
||||||
|
resolved.HeaderPrefix = "X-Sig-"
|
||||||
|
}
|
||||||
|
if resolved.TimeWindowSeconds <= 0 {
|
||||||
|
resolved.TimeWindowSeconds = 300
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Bootstrap == "" {
|
||||||
|
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Challenge == "" {
|
||||||
|
resolved.Endpoints.Challenge = "/challenge"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Exchange == "" {
|
||||||
|
resolved.Endpoints.Exchange = "/session/exchange"
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
return "", fmt.Errorf("signed session namespace is empty")
|
||||||
|
}
|
||||||
|
baseDir := filepath.Dir(r.dataDir)
|
||||||
|
if baseDir == "." || baseDir == "" {
|
||||||
|
baseDir = r.dataDir
|
||||||
|
}
|
||||||
|
dir := filepath.Join(baseDir, "signed_sessions")
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scope := strings.Join([]string{
|
||||||
|
namespace,
|
||||||
|
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.AppVersion)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.Platform)),
|
||||||
|
}, "\n")
|
||||||
|
sum := sha256.Sum256([]byte(scope))
|
||||||
|
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||||
|
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range namespace {
|
||||||
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||||
|
b.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), ".-_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := &signedSessionRecord{}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(data, record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.InstallID) == "" {
|
||||||
|
record.InstallID = randomHex(16)
|
||||||
|
}
|
||||||
|
normalizeSignedSessionRecordScope(config, record)
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
baseURL := strings.TrimSpace(config.BaseURL)
|
||||||
|
appVersion := strings.TrimSpace(config.AppVersion)
|
||||||
|
platform := strings.TrimSpace(config.Platform)
|
||||||
|
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.Namespace != namespace ||
|
||||||
|
record.BaseURL != baseURL ||
|
||||||
|
record.AppVersion != appVersion ||
|
||||||
|
record.Platform != platform {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
}
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(record, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(bytesLen int) string {
|
||||||
|
buf := make([]byte, bytesLen)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.000Z",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
||||||
|
authenticated = false
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"authenticated": authenticated,
|
||||||
|
"expires_at": record.ExpiresAt,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"session_id": record.SessionID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||||
|
grant := ""
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
grant = pendingSignedSessionGrants[r.extensionID]
|
||||||
|
delete(pendingSignedSessionGrants, r.extensionID)
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
||||||
|
}
|
||||||
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"grant": grant,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var exchanged signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||||
|
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||||
|
return fmt.Errorf("session exchange response missing session fields")
|
||||||
|
}
|
||||||
|
record.SessionID = exchanged.SessionID
|
||||||
|
record.SessionSecret = exchanged.SessionSecret
|
||||||
|
record.ExpiresAt = exchanged.ExpiresAt
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
||||||
|
}
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
requestPath := call.Arguments[1].String()
|
||||||
|
body := []byte{}
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
switch v := call.Arguments[2].Export().(type) {
|
||||||
|
case string:
|
||||||
|
body = []byte(v)
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
encoded, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
body = encoded
|
||||||
|
default:
|
||||||
|
body = []byte(call.Arguments[2].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraHeaders := map[string]string{}
|
||||||
|
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||||
|
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
extraHeaders[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := r.ensureSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
|
"body": string(respBody),
|
||||||
|
"headers": respHeaders,
|
||||||
|
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"needsVerification": true,
|
||||||
|
"error": "VERIFY_REQUIRED",
|
||||||
|
"open_auth_url": authURL,
|
||||||
|
"auth_url": authURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if record.SessionID == "" || record.SessionSecret == "" {
|
||||||
|
return nil, fmt.Errorf("signed session is not authenticated")
|
||||||
|
}
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return nil, fmt.Errorf("signed session expired")
|
||||||
|
}
|
||||||
|
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||||
|
_ = r.refreshSignedSession(config, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||||
|
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var refreshed signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
if refreshed.SessionID != "" {
|
||||||
|
record.SessionID = refreshed.SessionID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.SessionSecret != "" {
|
||||||
|
record.SessionSecret = refreshed.SessionSecret
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||||
|
record.ExpiresAt = refreshed.ExpiresAt
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(bootstrapURL)
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("app_version", config.AppVersion)
|
||||||
|
query.Set("install_id", record.InstallID)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||||
|
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var boot signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(body, &boot); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||||
|
record.SessionID = boot.SessionID
|
||||||
|
record.SessionSecret = boot.SessionSecret
|
||||||
|
record.ExpiresAt = boot.ExpiresAt
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
authURL := boot.AuthURL
|
||||||
|
if authURL == "" && boot.ChallengeURL != "" {
|
||||||
|
authURL = boot.ChallengeURL
|
||||||
|
}
|
||||||
|
if authURL == "" && boot.ChallengeID != "" {
|
||||||
|
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||||
|
}
|
||||||
|
if authURL != "" {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
CallbackURL: config.CallbackURL,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
}
|
||||||
|
return authURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||||
|
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(challengeURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
callback, err := url.Parse(config.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q := callback.Query()
|
||||||
|
q.Set("cb_version", "v2grant")
|
||||||
|
q.Set("state", r.extensionID)
|
||||||
|
callback.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("id", challengeID)
|
||||||
|
query.Set("cb", callback.String())
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||||
|
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||||
|
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||||
|
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimSpace(endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", fmt.Errorf("signed session endpoint is empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(endpoint, "https://") {
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimLeft(endpoint, "/")
|
||||||
|
ref, _ := url.Parse(endpoint)
|
||||||
|
return base.ResolveReference(ref).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) doSignedSessionRequest(
|
||||||
|
config SignedSessionConfig,
|
||||||
|
record *signedSessionRecord,
|
||||||
|
method string,
|
||||||
|
requestPath string,
|
||||||
|
body []byte,
|
||||||
|
extraHeaders map[string]string,
|
||||||
|
) (*http.Response, []byte, map[string]interface{}, error) {
|
||||||
|
fullURL, err := signedSessionURL(config, requestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
nonce := randomHex(12)
|
||||||
|
bodyHashBytes := sha256.Sum256(body)
|
||||||
|
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||||
|
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||||
|
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||||
|
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||||
|
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||||
|
signingInput := strings.Join([]string{
|
||||||
|
config.SchemeLabel,
|
||||||
|
method,
|
||||||
|
parsed.EscapedPath(),
|
||||||
|
"",
|
||||||
|
bodyHash,
|
||||||
|
ts,
|
||||||
|
nonce,
|
||||||
|
record.SessionID,
|
||||||
|
config.AppVersion,
|
||||||
|
config.Platform,
|
||||||
|
}, "\n")
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if len(body) > 0 {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
prefix := config.HeaderPrefix
|
||||||
|
req.Header.Set(prefix+"Session", record.SessionID)
|
||||||
|
req.Header.Set(prefix+"Timestamp", ts)
|
||||||
|
req.Header.Set(prefix+"Nonce", nonce)
|
||||||
|
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||||
|
req.Header.Set(prefix+"Signature", sig)
|
||||||
|
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||||
|
req.Header.Set(prefix+"Platform", config.Platform)
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
headers := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
headers[k] = v[0]
|
||||||
|
} else {
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, respBody, headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionRetryAfterSeconds(resp *http.Response) int {
|
||||||
|
if resp == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seconds, err := strconv.Atoi(value); err == nil {
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
if retryAt, err := http.ParseTime(value); err == nil {
|
||||||
|
seconds := int(time.Until(retryAt).Seconds())
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write(message)
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||||
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
|
grant = strings.TrimSpace(grant)
|
||||||
|
if extensionID == "" || grant == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
pendingSignedSessionGrants[extensionID] = grant
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
@@ -330,22 +330,26 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
|
||||||
registry, err := s.fetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *storeExtension
|
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext := e
|
||||||
break
|
return &ext, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext == nil {
|
return nil, fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
}
|
||||||
|
|
||||||
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
|
ext, err := s.findExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
|
|||||||
+15
-1
@@ -13,7 +13,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
multiUnderscore = regexp.MustCompile(`_+`)
|
multiUnderscore = regexp.MustCompile(`_+`)
|
||||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
|
||||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||||
)
|
)
|
||||||
@@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||||
|
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
|
||||||
"{year}": yearValue,
|
"{year}": yearValue,
|
||||||
"{date}": dateValue,
|
"{date}": dateValue,
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
@@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
number := getInt(metadata, parts[1])
|
number := getInt(metadata, parts[1])
|
||||||
|
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
|
||||||
|
number = getPlaylistPosition(metadata)
|
||||||
|
}
|
||||||
width, err := strconv.Atoi(parts[2])
|
width, err := strconv.Atoi(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
candidateKeys = append(candidateKeys, "track_number")
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case "disc":
|
case "disc":
|
||||||
candidateKeys = append(candidateKeys, "disc_number")
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
|
case "playlist_position", "playlistPosition", "playlist position", "position":
|
||||||
|
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidateKeys {
|
for _, candidate := range candidateKeys {
|
||||||
@@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPlaylistPosition(metadata map[string]interface{}) int {
|
||||||
|
return getInt(metadata, "playlist_position")
|
||||||
|
}
|
||||||
|
|
||||||
func formatTrackNumber(n int) string {
|
func formatTrackNumber(n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"playlist_position": 4,
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{playlist_position:02} - {artist} - {title}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
expected := "04 - Artist Name - Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"artist": "Artist Name",
|
"artist": "Artist Name",
|
||||||
|
|||||||
+4
-4
@@ -5,25 +5,25 @@ go 1.25.0
|
|||||||
toolchain go1.25.9
|
toolchain go1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/text v0.38.0
|
golang.org/x/text v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||||
github.com/klauspost/compress v1.18.6 // indirect
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
golang.org/x/mod v0.37.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-8
@@ -4,10 +4,10 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
|
|||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
|
||||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
@@ -34,8 +34,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
|||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
|
||||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
@@ -46,7 +46,7 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+117
-73
@@ -1,7 +1,9 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -437,101 +439,143 @@ func (e *ISPBlockingError) Error() string {
|
|||||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
// isTransientNetworkError reports retryable transport failures such as
|
||||||
|
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
|
||||||
|
func isTransientNetworkError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
|
||||||
|
// errors. Application-level API messages are excluded.
|
||||||
|
func isConnectivityFailure(err error) bool {
|
||||||
|
return connectivityFailureReason(err) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectivityFailureReason(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "Request timed out - ISP may be throttling"
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return "Connection closed unexpectedly - ISP may be blocking"
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := extractDomain(requestURL)
|
var urlErr *url.Error
|
||||||
errStr := strings.ToLower(err.Error())
|
if errors.As(err, &urlErr) {
|
||||||
|
if urlErr.Timeout() {
|
||||||
|
return "Connection timed out - ISP may be blocking access"
|
||||||
|
}
|
||||||
|
if urlErr.Err != nil {
|
||||||
|
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if errors.As(err, &dnsErr) {
|
if errors.As(err, &dnsErr) {
|
||||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
|
||||||
return &ISPBlockingError{
|
return "DNS resolution failed - domain may be blocked by ISP"
|
||||||
Domain: domain,
|
|
||||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var opErr *net.OpError
|
var opErr *net.OpError
|
||||||
if errors.As(err, &opErr) {
|
if errors.As(err, &opErr) {
|
||||||
if opErr.Op == "dial" {
|
if opErr.Timeout() {
|
||||||
var syscallErr syscall.Errno
|
return "Connection timed out - ISP may be blocking access"
|
||||||
if errors.As(opErr.Err, &syscallErr) {
|
}
|
||||||
switch syscallErr {
|
var errno syscall.Errno
|
||||||
case syscall.ECONNREFUSED:
|
if errors.As(opErr.Err, &errno) {
|
||||||
return &ISPBlockingError{
|
switch errno {
|
||||||
Domain: domain,
|
case syscall.ECONNREFUSED:
|
||||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
return "Connection refused - port may be blocked by ISP/firewall"
|
||||||
OriginalErr: err,
|
case syscall.ECONNRESET:
|
||||||
}
|
return "Connection reset - ISP may be intercepting traffic"
|
||||||
case syscall.ECONNRESET:
|
case syscall.ETIMEDOUT:
|
||||||
return &ISPBlockingError{
|
return "Connection timed out - ISP may be blocking access"
|
||||||
Domain: domain,
|
case syscall.ENETUNREACH:
|
||||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
return "Network unreachable - ISP may be blocking route"
|
||||||
OriginalErr: err,
|
case syscall.EHOSTUNREACH:
|
||||||
}
|
return "Host unreachable - ISP may be blocking destination"
|
||||||
case syscall.ETIMEDOUT:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Connection timed out - ISP may be blocking access",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
case syscall.ENETUNREACH:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Network unreachable - ISP may be blocking route",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
case syscall.EHOSTUNREACH:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Host unreachable - ISP may be blocking destination",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsErr *tls.RecordHeaderError
|
var tlsErr *tls.RecordHeaderError
|
||||||
if errors.As(err, &tlsErr) {
|
if errors.As(err, &tlsErr) {
|
||||||
return &ISPBlockingError{
|
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
|
||||||
Domain: domain,
|
}
|
||||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
|
||||||
OriginalErr: err,
|
var certErr x509.CertificateInvalidError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
var hostnameErr x509.HostnameError
|
||||||
|
if errors.As(err, &hostnameErr) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
var unknownAuth x509.UnknownAuthorityError
|
||||||
|
if errors.As(err, &unknownAuth) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
|
||||||
|
// that should trigger a Chrome fingerprint retry.
|
||||||
|
func isTLSHandshakeOrResetError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var recordErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &recordErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var certErr x509.CertificateInvalidError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var hostnameErr x509.HostnameError
|
||||||
|
if errors.As(err, &hostnameErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var unknownAuth x509.UnknownAuthorityError
|
||||||
|
if errors.As(err, &unknownAuth) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
var errno syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
blockingPatterns := []struct {
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
pattern string
|
if err == nil {
|
||||||
reason string
|
return nil
|
||||||
}{
|
|
||||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
|
||||||
{"connection refused", "Connection refused - port may be blocked"},
|
|
||||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
|
||||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
|
||||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
|
||||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
|
||||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
|
||||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
|
||||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
|
||||||
}
|
}
|
||||||
|
reason := connectivityFailureReason(err)
|
||||||
for _, bp := range blockingPatterns {
|
if reason == "" {
|
||||||
if strings.Contains(errStr, bp.pattern) {
|
return nil
|
||||||
return &ISPBlockingError{
|
}
|
||||||
Domain: domain,
|
return &ISPBlockingError{
|
||||||
Reason: bp.reason,
|
Domain: extractDomain(requestURL),
|
||||||
OriginalErr: err,
|
Reason: reason,
|
||||||
}
|
OriginalErr: err,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -131,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
|||||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||||
t.Fatal("invalid retry-after should be zero")
|
t.Fatal("invalid retry-after should be zero")
|
||||||
}
|
}
|
||||||
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
|
||||||
|
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||||
t.Fatalf("IsISPBlocking = %#v", isp)
|
t.Fatalf("IsISPBlocking = %#v", isp)
|
||||||
}
|
}
|
||||||
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
|
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
|
||||||
|
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
|
||||||
t.Fatal("expected logged ISP blocking")
|
t.Fatal("expected logged ISP blocking")
|
||||||
}
|
}
|
||||||
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
|
||||||
|
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||||
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
||||||
}
|
}
|
||||||
|
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
|
||||||
|
t.Fatal("isTransientNetworkError mismatch")
|
||||||
|
}
|
||||||
|
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
|
||||||
|
t.Fatal("isConnectivityFailure mismatch")
|
||||||
|
}
|
||||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||||
t.Fatal("nil wrap should stay nil")
|
t.Fatal("nil wrap should stay nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
if isTLSHandshakeOrResetError(err) {
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
|
||||||
strings.Contains(errStr, "handshake") ||
|
|
||||||
strings.Contains(errStr, "certificate") ||
|
|
||||||
strings.Contains(errStr, "connection reset")
|
|
||||||
|
|
||||||
if tlsRelated {
|
|
||||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
|
|||||||
+196
-28
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -92,6 +93,18 @@ type scannedCueFileInfo struct {
|
|||||||
audioPath string
|
audioPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type libraryScanTask struct {
|
||||||
|
index int
|
||||||
|
info libraryAudioFileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type libraryScanTaskResult struct {
|
||||||
|
index int
|
||||||
|
path string
|
||||||
|
results []LibraryScanResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func isLibraryStagingFile(path string) bool {
|
func isLibraryStagingFile(path string) bool {
|
||||||
name := strings.ToLower(filepath.Base(path))
|
name := strings.ToLower(filepath.Base(path))
|
||||||
if strings.HasSuffix(name, ".partial") {
|
if strings.HasSuffix(name, ".partial") {
|
||||||
@@ -150,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func libraryScanWorkerCount(taskCount int) int {
|
||||||
|
if taskCount < 16 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
workers := runtime.NumCPU()
|
||||||
|
if workers > 4 {
|
||||||
|
workers = 4
|
||||||
|
}
|
||||||
|
if workers < 2 {
|
||||||
|
workers = 2
|
||||||
|
}
|
||||||
|
if workers > taskCount {
|
||||||
|
workers = taskCount
|
||||||
|
}
|
||||||
|
return workers
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = scannedFiles
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
|
||||||
|
if totalFiles > 0 {
|
||||||
|
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
|
||||||
|
}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return resultsByIndex, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workers := libraryScanWorkerCount(len(tasks))
|
||||||
|
if workers <= 1 {
|
||||||
|
errorCount := 0
|
||||||
|
for _, task := range tasks {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
|
||||||
|
*completed++
|
||||||
|
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultsByIndex[task.index] = []LibraryScanResult{*result}
|
||||||
|
}
|
||||||
|
return resultsByIndex, errorCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCh := make(chan libraryScanTask)
|
||||||
|
resultCh := make(chan libraryScanTaskResult, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for task := range taskCh {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
|
||||||
|
taskResult := libraryScanTaskResult{
|
||||||
|
index: task.index,
|
||||||
|
path: task.info.path,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
if err == nil && result != nil {
|
||||||
|
taskResult.results = []LibraryScanResult{*result}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
case resultCh <- taskResult:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(taskCh)
|
||||||
|
for _, task := range tasks {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
case taskCh <- task:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
errorCount := 0
|
||||||
|
for taskResult := range resultCh {
|
||||||
|
*completed++
|
||||||
|
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
|
||||||
|
if taskResult.err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultsByIndex[taskResult.index] = taskResult.results
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return resultsByIndex, errorCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
libraryCoverCacheMu.Lock()
|
libraryCoverCacheMu.Lock()
|
||||||
libraryCoverCacheDir = cacheDir
|
libraryCoverCacheDir = cacheDir
|
||||||
@@ -225,6 +361,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
|
||||||
|
audioTasks := make([]libraryScanTask, 0, totalFiles)
|
||||||
|
completedFiles := 0
|
||||||
|
|
||||||
for i, fileInfo := range audioFileInfos {
|
for i, fileInfo := range audioFileInfos {
|
||||||
filePath := fileInfo.path
|
filePath := fileInfo.path
|
||||||
select {
|
select {
|
||||||
@@ -233,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ScannedFiles = i + 1
|
|
||||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
|
||||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -260,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results = append(results, cueResults...)
|
resultsByIndex[i] = cueResults
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
|
||||||
if err != nil {
|
}
|
||||||
errorCount++
|
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, *result)
|
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||||
|
audioTasks,
|
||||||
|
scanTime,
|
||||||
|
cancelCh,
|
||||||
|
totalFiles,
|
||||||
|
&completedFiles,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
errorCount += audioErrors
|
||||||
|
for index, scanResults := range audioResults {
|
||||||
|
resultsByIndex[index] = scanResults
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range audioFileInfos {
|
||||||
|
results = append(results, resultsByIndex[i]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
@@ -874,6 +1026,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
|
||||||
|
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
|
||||||
|
completedFiles := skippedCount
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
for i, f := range filesToScan {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -881,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
|
||||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
|
||||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -908,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results = append(results, cueResults...)
|
resultsByIndex[i] = cueResults
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
||||||
if err != nil {
|
}
|
||||||
errorCount++
|
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, *result)
|
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||||
|
audioTasks,
|
||||||
|
scanTime,
|
||||||
|
cancelCh,
|
||||||
|
totalFiles,
|
||||||
|
&completedFiles,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
errorCount += audioErrors
|
||||||
|
for index, scanResults := range audioResults {
|
||||||
|
resultsByIndex[index] = scanResults
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range filesToScan {
|
||||||
|
results = append(results, resultsByIndex[i]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
|
|||||||
+502
-149
@@ -20,6 +20,12 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
durationToleranceSec = 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsProviderUnavailableCooldown = 10 * time.Minute
|
||||||
|
lyricsProviderParallelism = 3
|
||||||
|
lyricsProviderPriorityGrace = 5000 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LyricsProviderLRCLIB = "lrclib"
|
LyricsProviderLRCLIB = "lrclib"
|
||||||
LyricsProviderNetease = "netease"
|
LyricsProviderNetease = "netease"
|
||||||
@@ -46,6 +52,33 @@ var (
|
|||||||
appVersion string
|
appVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type lyricsProviderHealthEntry struct {
|
||||||
|
unavailableUntil time.Time
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsProviderSearchRequest struct {
|
||||||
|
spotifyID string
|
||||||
|
trackName string
|
||||||
|
artistName string
|
||||||
|
primaryArtist string
|
||||||
|
simplifiedTrack string
|
||||||
|
durationSec float64
|
||||||
|
fetchOptions LyricsFetchOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsProviderSearchResult struct {
|
||||||
|
index int
|
||||||
|
providerName string
|
||||||
|
lyrics *LyricsResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lyricsProviderHealthMu sync.RWMutex
|
||||||
|
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
|
||||||
|
)
|
||||||
|
|
||||||
func SetAppVersion(version string) {
|
func SetAppVersion(version string) {
|
||||||
normalized := strings.TrimSpace(version)
|
normalized := strings.TrimSpace(version)
|
||||||
|
|
||||||
@@ -99,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
|
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
lyricsProviders = nil
|
lyricsProviders = nil
|
||||||
|
clearLyricsProviderHealth()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lyricsProviders = valid
|
lyricsProviders = valid
|
||||||
|
clearLyricsProviderHealth()
|
||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearLyricsProviderHealth() {
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
defer lyricsProviderHealthMu.Unlock()
|
||||||
|
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lyricsProviderHealthKey(providerName string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(providerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) {
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
lyricsProviderHealthMu.RLock()
|
||||||
|
entry, ok := lyricsProviderHealth[key]
|
||||||
|
lyricsProviderHealthMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
if !now.Before(entry.unavailableUntil) {
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) {
|
||||||
|
delete(lyricsProviderHealth, key)
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
return true, time.Until(entry.unavailableUntil), entry.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLyricsProviderAvailable(providerName string) {
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
delete(lyricsProviderHealth, key)
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLyricsProviderUnavailable(providerName string, err error) {
|
||||||
|
if err == nil || !isLyricsProviderUnavailableError(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(err.Error())
|
||||||
|
if len(reason) > 160 {
|
||||||
|
reason = reason[:160]
|
||||||
|
}
|
||||||
|
unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown)
|
||||||
|
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
lyricsProviderHealth[key] = lyricsProviderHealthEntry{
|
||||||
|
unavailableUntil: unavailableUntil,
|
||||||
|
reason: reason,
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsNotFoundSignals = []string{
|
||||||
|
"lyrics not found",
|
||||||
|
"no lyrics found",
|
||||||
|
"no songs found",
|
||||||
|
"not found on",
|
||||||
|
"empty track",
|
||||||
|
"empty search query",
|
||||||
|
"needs a deezer id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider/API-level failures that should temporarily disable a lyrics source.
|
||||||
|
// Transport failures are handled by isConnectivityFailure via typed errors.
|
||||||
|
var lyricsServiceUnavailableSignals = []string{
|
||||||
|
"fetch failed",
|
||||||
|
"missing required parameters",
|
||||||
|
"request failed",
|
||||||
|
"request unsuccessful",
|
||||||
|
"search failed",
|
||||||
|
"search unavailable",
|
||||||
|
"rate limit",
|
||||||
|
"too many requests",
|
||||||
|
"operation too frequent",
|
||||||
|
"操作频繁",
|
||||||
|
"proxy returned http 429",
|
||||||
|
"proxy returned http 5",
|
||||||
|
"unexpected status code: 429",
|
||||||
|
"unexpected status code: 5",
|
||||||
|
"unexpected response code",
|
||||||
|
"returned http 429",
|
||||||
|
"returned http 5",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsProviderUnavailableError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
for _, signal := range lyricsNotFoundSignals {
|
||||||
|
if strings.Contains(msg, signal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isConnectivityFailure(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, signal := range lyricsServiceUnavailableSignals {
|
||||||
|
if strings.Contains(msg, signal) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -474,15 +630,22 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
if len(extensionProviders) > 0 {
|
if len(extensionProviders) > 0 {
|
||||||
for _, provider := range extensionProviders {
|
for _, provider := range extensionProviders {
|
||||||
|
providerName := "extension:" + provider.extension.ID
|
||||||
|
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
|
||||||
|
GoLog("[Lyrics] Skipping unavailable extension lyrics provider %s for %s: %s\n", provider.extension.ID, remaining.Round(time.Second), reason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||||
|
markLyricsProviderAvailable(providerName)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||||
|
markLyricsProviderUnavailable(providerName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,175 +659,338 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
providerOrder := GetLyricsProviderOrder()
|
providerOrder := GetLyricsProviderOrder()
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
|
request := lyricsProviderSearchRequest{
|
||||||
|
spotifyID: spotifyID,
|
||||||
|
trackName: trackName,
|
||||||
|
artistName: artistName,
|
||||||
|
primaryArtist: primaryArtist,
|
||||||
|
simplifiedTrack: simplifiedTrack,
|
||||||
|
durationSec: durationSec,
|
||||||
|
fetchOptions: fetchOptions,
|
||||||
|
}
|
||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
for _, providerName := range providerOrder {
|
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
var lyrics *LyricsResponse
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
var err error
|
}
|
||||||
|
|
||||||
switch providerName {
|
func fetchBuiltInLyricsProviders(
|
||||||
case LyricsProviderLRCLIB:
|
providerOrder []string,
|
||||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
request lyricsProviderSearchRequest,
|
||||||
|
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
type providerCandidate struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
case LyricsProviderNetease:
|
candidates := make([]providerCandidate, 0, len(providerOrder))
|
||||||
neteaseClient := NewNeteaseClient()
|
results := make(chan lyricsProviderSearchResult, len(providerOrder))
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
sem := make(chan struct{}, lyricsProviderParallelism)
|
||||||
trackName,
|
var wg sync.WaitGroup
|
||||||
primaryArtist,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil && simplifiedTrack != trackName {
|
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
|
||||||
simplifiedTrack,
|
|
||||||
primaryArtist,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderMusixmatch:
|
for index, providerName := range providerOrder {
|
||||||
musixmatchClient := NewMusixmatchClient()
|
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
|
||||||
lyrics, err = musixmatchClient.FetchLyrics(
|
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
|
||||||
trackName,
|
continue
|
||||||
primaryArtist,
|
}
|
||||||
durationSec,
|
|
||||||
fetchOptions.MusixmatchLanguage,
|
|
||||||
)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
lyrics, err = musixmatchClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.MusixmatchLanguage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderAppleMusic:
|
knownProvider := isKnownBuiltInLyricsProvider(providerName)
|
||||||
appleClient := NewAppleMusicClient()
|
if !knownProvider {
|
||||||
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, fetchOptions.AppleElrcWordSync)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderQQMusic:
|
|
||||||
qqClient := NewQQMusicClient()
|
|
||||||
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderLyricsPlus:
|
|
||||||
lyricsPlusClient := NewLyricsPlusClient()
|
|
||||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
primaryArtist,
|
|
||||||
"",
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.MultiPersonWordByWord,
|
|
||||||
fetchOptions.AppleElrcWordSync,
|
|
||||||
)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
"",
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.MultiPersonWordByWord,
|
|
||||||
fetchOptions.AppleElrcWordSync,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil && simplifiedTrack != trackName {
|
|
||||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
|
||||||
simplifiedTrack,
|
|
||||||
primaryArtist,
|
|
||||||
"",
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.MultiPersonWordByWord,
|
|
||||||
fetchOptions.AppleElrcWordSync,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
candidate := providerCandidate{index: index, name: providerName}
|
||||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
candidates = append(candidates, candidate)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
wg.Add(1)
|
||||||
return lyrics, nil
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
GoLog("[Lyrics] Trying provider: %s\n", candidate.name)
|
||||||
|
lyrics, err, ok := fetchProvider(candidate.name, request)
|
||||||
|
if !ok {
|
||||||
|
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, err: fmt.Errorf("unknown provider")}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil && lyricsHasUsableText(lyrics) {
|
||||||
|
GoLog("[Lyrics] Got lyrics from: %s\n", candidate.name)
|
||||||
|
markLyricsProviderAvailable(candidate.name)
|
||||||
|
} else if err != nil {
|
||||||
|
GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err)
|
||||||
|
markLyricsProviderUnavailable(candidate.name, err)
|
||||||
|
}
|
||||||
|
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
completed := make(map[int]bool, len(candidates))
|
||||||
|
var best *lyricsProviderSearchResult
|
||||||
|
var lastErr error
|
||||||
|
var graceTimer *time.Timer
|
||||||
|
var grace <-chan time.Time
|
||||||
|
|
||||||
|
stopGrace := func() {
|
||||||
|
if graceTimer != nil {
|
||||||
|
if !graceTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-graceTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graceTimer = nil
|
||||||
|
grace = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer stopGrace()
|
||||||
|
|
||||||
|
hasPendingEarlier := func(index int) bool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.index >= index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !completed[candidate.index] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for remaining := len(candidates); remaining > 0; {
|
||||||
|
if best != nil && !hasPendingEarlier(best.index) {
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
|
if best != nil && graceTimer == nil {
|
||||||
|
graceTimer = time.NewTimer(lyricsProviderPriorityGrace)
|
||||||
|
grace = graceTimer.C
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
case result, ok := <-results:
|
||||||
|
if !ok {
|
||||||
|
remaining = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
remaining--
|
||||||
|
completed[result.index] = true
|
||||||
|
if result.err != nil {
|
||||||
|
lastErr = result.err
|
||||||
|
}
|
||||||
|
if lyricsHasUsableText(result.lyrics) && (best == nil || result.index < best.index) {
|
||||||
|
copied := result
|
||||||
|
best = &copied
|
||||||
|
stopGrace()
|
||||||
|
}
|
||||||
|
case <-grace:
|
||||||
|
if best != nil {
|
||||||
|
GoLog("[Lyrics] Returning provider %s after %s priority grace\n", best.providerName, lyricsProviderPriorityGrace)
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if best != nil {
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownBuiltInLyricsProvider(providerName string) bool {
|
||||||
|
switch providerName {
|
||||||
|
case LyricsProviderLRCLIB,
|
||||||
|
LyricsProviderNetease,
|
||||||
|
LyricsProviderMusixmatch,
|
||||||
|
LyricsProviderAppleMusic,
|
||||||
|
LyricsProviderQQMusic,
|
||||||
|
LyricsProviderSpotify,
|
||||||
|
LyricsProviderDeezer,
|
||||||
|
LyricsProviderYouTube,
|
||||||
|
LyricsProviderKugou,
|
||||||
|
LyricsProviderGenius,
|
||||||
|
LyricsProviderLyricsPlus:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
switch providerName {
|
||||||
|
case LyricsProviderLRCLIB:
|
||||||
|
lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec)
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderNetease:
|
||||||
|
neteaseClient := NewNeteaseClient()
|
||||||
|
lyrics, err := neteaseClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
request.simplifiedTrack,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderMusixmatch:
|
||||||
|
musixmatchClient := NewMusixmatchClient()
|
||||||
|
lyrics, err := musixmatchClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = musixmatchClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderAppleMusic:
|
||||||
|
appleClient := NewAppleMusicClient()
|
||||||
|
lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderQQMusic:
|
||||||
|
qqClient := NewQQMusicClient()
|
||||||
|
lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderSpotify:
|
||||||
|
spotifyClient := NewSpotifyLyricsClient()
|
||||||
|
lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = spotifyClient.FetchLyrics("", request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderDeezer:
|
||||||
|
deezerClient := NewDeezerLyricsClient()
|
||||||
|
lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderYouTube:
|
||||||
|
youtubeClient := NewYouTubeLyricsClient()
|
||||||
|
lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = youtubeClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderKugou:
|
||||||
|
kugouClient := NewKugouLyricsClient()
|
||||||
|
lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = kugouClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderGenius:
|
||||||
|
geniusClient := NewGeniusLyricsClient()
|
||||||
|
lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = geniusClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderLyricsPlus:
|
||||||
|
lyricsPlusClient := NewLyricsPlusClient()
|
||||||
|
lyrics, err := lyricsPlusClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||||
|
request.simplifiedTrack,
|
||||||
|
request.primaryArtist,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown provider: %s", providerName), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
@@ -674,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if primaryArtist != artistName {
|
if primaryArtist != artistName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
@@ -681,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
@@ -689,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query := primaryArtist + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
@@ -697,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
@@ -705,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||||
@@ -848,6 +1189,18 @@ func detectLyricsErrorPayload(raw string) (string, bool) {
|
|||||||
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||||
return "request unsuccessful", true
|
return "request unsuccessful", true
|
||||||
}
|
}
|
||||||
|
if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey {
|
||||||
|
return "request unsuccessful", true
|
||||||
|
}
|
||||||
|
if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey {
|
||||||
|
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||||
|
return strings.TrimSpace(msg), true
|
||||||
|
}
|
||||||
|
if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||||
|
return strings.TrimSpace(msg), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("unexpected response code %.0f", code), true
|
||||||
|
}
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
|
||||||
|
|
||||||
type AppleMusicClient struct {
|
type AppleMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -188,7 +191,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
|||||||
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
|
token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", fmt.Errorf("apple music token not found")
|
return "", fmt.Errorf("apple music token not found")
|
||||||
}
|
}
|
||||||
@@ -235,7 +238,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return nil, fmt.Errorf("apple music catalog search unauthorized")
|
return nil, errAppleMusicUnauthorized
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||||
@@ -281,7 +284,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
if errors.Is(err, errAppleMusicUnauthorized) {
|
||||||
clearAppleMusicToken()
|
clearAppleMusicToken()
|
||||||
token, tokenErr := c.getAppleMusicToken()
|
token, tokenErr := c.getAppleMusicToken()
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
|
|||||||
@@ -24,12 +24,8 @@ import (
|
|||||||
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
|
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
|
||||||
// Sourced from the upstream YouLy+ client server list.
|
// Sourced from the upstream YouLy+ client server list.
|
||||||
var lyricsPlusServers = []string{
|
var lyricsPlusServers = []string{
|
||||||
"https://lyricsplus.prjktla.my.id",
|
|
||||||
"https://lyricsplus.atomix.one",
|
|
||||||
"https://lyricsplus.binimum.org",
|
|
||||||
"https://lyricsplus.prjktla.workers.dev",
|
"https://lyricsplus.prjktla.workers.dev",
|
||||||
"https://lyricsplus-seven.vercel.app",
|
"https://lyricsplus.binimum.org",
|
||||||
"https://lyrics-plus-backend.vercel.app",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LyricsPlusClient struct {
|
type LyricsPlusClient struct {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ type neteaseSearchResponse struct {
|
|||||||
} `json:"songs"`
|
} `json:"songs"`
|
||||||
SongCount int `json:"songCount"`
|
SongCount int `json:"songCount"`
|
||||||
} `json:"result"`
|
} `json:"result"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type neteaseLyricsResponse struct {
|
type neteaseLyricsResponse struct {
|
||||||
@@ -87,6 +89,17 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
|||||||
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if searchResp.Code != 0 && searchResp.Code != 200 {
|
||||||
|
message := strings.TrimSpace(searchResp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(searchResp.Msg)
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = "unexpected response code"
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message)
|
||||||
|
}
|
||||||
|
|
||||||
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||||
return 0, fmt.Errorf("no songs found on netease")
|
return 0, fmt.Errorf("no songs found on netease")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
|
|||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
params.Set("per_page", "10")
|
params.Set("per_page", "5")
|
||||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("genius search failed: %w", err)
|
return "", fmt.Errorf("genius search failed: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
|||||||
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
||||||
t.Fatalf("error payload = %q/%v", msg, ok)
|
t.Fatalf("error payload = %q/%v", msg, ok)
|
||||||
}
|
}
|
||||||
|
if msg, ok := detectLyricsErrorPayload(`{"isError":true,"error":"Missing required parameters"}`); !ok || msg != "Missing required parameters" {
|
||||||
|
t.Fatalf("isError payload = %q/%v", msg, ok)
|
||||||
|
}
|
||||||
|
if msg, ok := detectLyricsErrorPayload(`{"code":405,"message":"rate limited"}`); !ok || msg != "rate limited" {
|
||||||
|
t.Fatalf("coded error payload = %q/%v", msg, ok)
|
||||||
|
}
|
||||||
|
if !isLyricsProviderUnavailableError(errors.New("rate limit")) {
|
||||||
|
t.Fatal("expected rate-limit errors to mark provider unavailable")
|
||||||
|
}
|
||||||
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
|
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
|
||||||
t.Fatal("unexpected LRC timestamp conversion")
|
t.Fatal("unexpected LRC timestamp conversion")
|
||||||
}
|
}
|
||||||
@@ -130,9 +140,120 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLyricsProviderHealthSkipsUnavailableProvider(t *testing.T) {
|
||||||
|
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
|
||||||
|
defer SetLyricsProviderOrder(nil)
|
||||||
|
globalLyricsCache.ClearAll()
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
downClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`service unavailable`)), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if lyrics, err := downClient.FetchLyricsAllSources("", "Down Song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected unavailable provider error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("expected one HTTP call before cooldown, got %d", calls)
|
||||||
|
}
|
||||||
|
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); !skip {
|
||||||
|
t.Fatal("expected LRCLIB to be marked unavailable")
|
||||||
|
}
|
||||||
|
if lyrics, err := downClient.FetchLyricsAllSources("", "Another Song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected skipped provider error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("provider was called while in cooldown, calls=%d", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
globalLyricsCache.ClearAll()
|
||||||
|
notFoundCalls := 0
|
||||||
|
notFoundClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
notFoundCalls++
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/get":
|
||||||
|
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||||
|
case "/api/search":
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil
|
||||||
|
default:
|
||||||
|
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||||
|
}
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected not found error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); skip {
|
||||||
|
t.Fatal("not-found result must not mark provider unavailable")
|
||||||
|
}
|
||||||
|
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song 2", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected second not found error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if notFoundCalls != 4 {
|
||||||
|
t.Fatalf("expected not-found provider to be retried, calls=%d", notFoundCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentLyricsProvidersReturnFastFallback(t *testing.T) {
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
lyrics, err := fetchBuiltInLyricsProviders(
|
||||||
|
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
|
||||||
|
lyricsProviderSearchRequest{},
|
||||||
|
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
if providerName == LyricsProviderLRCLIB {
|
||||||
|
time.Sleep(lyricsProviderPriorityGrace + 800*time.Millisecond)
|
||||||
|
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "slow"}, nil, true
|
||||||
|
}
|
||||||
|
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent providers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics == nil || lyrics.Provider != "Apple Music" {
|
||||||
|
t.Fatalf("expected fast fallback lyrics, got %#v", lyrics)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed >= lyricsProviderPriorityGrace+700*time.Millisecond {
|
||||||
|
t.Fatalf("fallback waited too long: %s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentLyricsProvidersPreferEarlierProviderWithinGrace(t *testing.T) {
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
lyrics, err := fetchBuiltInLyricsProviders(
|
||||||
|
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
|
||||||
|
lyricsProviderSearchRequest{},
|
||||||
|
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
if providerName == LyricsProviderLRCLIB {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "preferred"}, nil, true
|
||||||
|
}
|
||||||
|
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent providers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics == nil || lyrics.Provider != "LRCLIB" {
|
||||||
|
t.Fatalf("expected preferred provider lyrics, got %#v", lyrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||||
clearAppleMusicToken()
|
clearAppleMusicToken()
|
||||||
defer clearAppleMusicToken()
|
defer clearAppleMusicToken()
|
||||||
|
if len(lyricsPlusServers) == 0 || lyricsPlusServers[0] != "https://lyricsplus.prjktla.workers.dev" {
|
||||||
|
t.Fatalf("unexpected LyricsPlus server order = %#v", lyricsPlusServers)
|
||||||
|
}
|
||||||
|
|
||||||
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
||||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
@@ -140,7 +261,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
|
||||||
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
|
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil
|
||||||
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
|
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
|
||||||
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
||||||
@@ -236,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
if _, err := netease.SearchSong("", ""); err == nil {
|
if _, err := netease.SearchSong("", ""); err == nil {
|
||||||
t.Fatal("expected empty netease search error")
|
t.Fatal("expected empty netease search error")
|
||||||
}
|
}
|
||||||
|
rateLimitedNetease := &NeteaseClient{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(`{"msg":"操作频繁,请稍候再试","code":405,"message":"操作频繁,请稍候再试"}`)), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
if _, err := rateLimitedNetease.SearchSong("Song", "Artist"); err == nil || !isLyricsProviderUnavailableError(err) {
|
||||||
|
t.Fatalf("expected unavailable netease rate-limit error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
@@ -311,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
||||||
|
if got := req.URL.Query().Get("per_page"); got != "5" {
|
||||||
|
t.Fatalf("genius per_page = %q", got)
|
||||||
|
}
|
||||||
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
|
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"):
|
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
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditM4AFreeformTextWritesISRCAndLabel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "track.m4a")
|
||||||
|
|
||||||
|
ilst := buildM4ATextTag("\xa9nam", "Title")
|
||||||
|
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(path, map[string]string{
|
||||||
|
"isrc": "USRC17607839",
|
||||||
|
"label": "Some Label",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EditM4AFreeformText: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ISRC != "USRC17607839" {
|
||||||
|
t.Fatalf("ISRC = %q, want USRC17607839", meta.ISRC)
|
||||||
|
}
|
||||||
|
if meta.Label != "Some Label" {
|
||||||
|
t.Fatalf("Label = %q, want Some Label", meta.Label)
|
||||||
|
}
|
||||||
|
if meta.Title != "Title" {
|
||||||
|
t.Fatalf("Title = %q, want Title (existing tag must survive)", meta.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditM4AFreeformTextReplacesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "track.m4a")
|
||||||
|
|
||||||
|
ilst := buildM4ATextTag("\xa9nam", "Title")
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("ISRC", "OLDISRC00001")...)
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("LABEL", "Old Label")...)
|
||||||
|
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(path, map[string]string{
|
||||||
|
"isrc": "NEWISRC00002",
|
||||||
|
"label": "",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EditM4AFreeformText: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ISRC != "NEWISRC00002" {
|
||||||
|
t.Fatalf("ISRC = %q, want NEWISRC00002", meta.ISRC)
|
||||||
|
}
|
||||||
|
if meta.Label != "" {
|
||||||
|
t.Fatalf("Label = %q, want empty (cleared)", meta.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
-42
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
stdimage "image"
|
stdimage "image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
|
|||||||
return "image/jpeg"
|
return "image/jpeg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxFlacPictureBytes keeps cover art below the 24-bit length field of a FLAC
|
||||||
|
// metadata block; go-flac silently truncates oversized blocks into a corrupt file.
|
||||||
|
const maxFlacPictureBytes = 16 * 1000 * 1000
|
||||||
|
|
||||||
|
// fitCoverForFlac returns cover bytes that fit inside a FLAC PICTURE block,
|
||||||
|
// re-encoding and downscaling when needed. Returns false if the data cannot be
|
||||||
|
// decoded as an image.
|
||||||
|
func fitCoverForFlac(coverData []byte) ([]byte, bool) {
|
||||||
|
if len(coverData) <= maxFlacPictureBytes {
|
||||||
|
return coverData, true
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := stdimage.Decode(bytes.NewReader(coverData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, quality := range []int{90, 80, 70, 60} {
|
||||||
|
if encoded, ok := encodeJPEGUnder(img, quality, maxFlacPictureBytes); ok {
|
||||||
|
return encoded, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, maxDim := range []int{1500, 1200, 1000, 800} {
|
||||||
|
scaled := downscaleImage(img, maxDim)
|
||||||
|
if encoded, ok := encodeJPEGUnder(scaled, 85, maxFlacPictureBytes); ok {
|
||||||
|
return encoded, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJPEGUnder(img stdimage.Image, quality, limit int) ([]byte, bool) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if buf.Len() > limit {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return buf.Bytes(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func downscaleImage(img stdimage.Image, maxDim int) stdimage.Image {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width, height := bounds.Dx(), bounds.Dy()
|
||||||
|
if width <= maxDim && height <= maxDim {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := float64(maxDim) / float64(max(width, height))
|
||||||
|
newWidth := max(1, int(float64(width)*scale))
|
||||||
|
newHeight := max(1, int(float64(height)*scale))
|
||||||
|
|
||||||
|
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newWidth, newHeight))
|
||||||
|
for y := 0; y < newHeight; y++ {
|
||||||
|
srcY := bounds.Min.Y + int(float64(y)/scale)
|
||||||
|
for x := 0; x < newWidth; x++ {
|
||||||
|
srcX := bounds.Min.X + int(float64(x)/scale)
|
||||||
|
dst.Set(x, y, img.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||||
if len(coverData) == 0 {
|
if len(coverData) == 0 {
|
||||||
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fitted, ok := fitCoverForFlac(coverData)
|
||||||
|
if !ok {
|
||||||
|
return flac.MetaDataBlock{}, fmt.Errorf("cover too large for FLAC picture block and could not be resized")
|
||||||
|
}
|
||||||
|
coverData = fitted
|
||||||
|
|
||||||
mime := detectCoverMIME(coverPath, coverData)
|
mime := detectCoverMIME(coverPath, coverData)
|
||||||
picture := &flacpicture.MetadataBlockPicture{
|
picture := &flacpicture.MetadataBlockPicture{
|
||||||
PictureType: flacpicture.PictureTypeFrontCover,
|
PictureType: flacpicture.PictureTypeFrontCover,
|
||||||
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
|
|
||||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create picture block: %w", err)
|
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||||
@@ -230,10 +303,11 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
|
|
||||||
picBlock, err := buildPictureBlock("", coverData)
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create picture block: %w", err)
|
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
@@ -1123,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
udtaBodyStart := udta.offset + udta.headerSize
|
udtaBodyStart := udta.offset + udta.headerSize
|
||||||
udtaBodySize := udta.size - udta.headerSize
|
udtaBodySize := udta.size - udta.headerSize
|
||||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
|
||||||
return ilst, nil
|
return ilst, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1133,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
|
|
||||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
|
||||||
return ilst, nil
|
return ilst, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1143,6 +1213,26 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
|
||||||
|
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
|
||||||
|
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
|
||||||
|
// used for AC-4 passthrough).
|
||||||
|
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
|
||||||
|
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
|
||||||
|
isoStart := meta.offset + meta.headerSize + 4
|
||||||
|
isoSize := meta.size - meta.headerSize - 4
|
||||||
|
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
|
||||||
|
return ilst, true
|
||||||
|
}
|
||||||
|
// QuickTime: child atoms begin immediately after the meta header.
|
||||||
|
qtStart := meta.offset + meta.headerSize
|
||||||
|
qtSize := meta.size - meta.headerSize
|
||||||
|
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
|
||||||
|
return ilst, true
|
||||||
|
}
|
||||||
|
return atomHeader{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||||
@@ -1280,9 +1370,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
|||||||
udtaBodyStart := udta.offset + udta.headerSize
|
udtaBodyStart := udta.offset + udta.headerSize
|
||||||
udtaBodySize := udta.size - udta.headerSize
|
udtaBodySize := udta.size - udta.headerSize
|
||||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
|
||||||
udtaCopy := udta
|
udtaCopy := udta
|
||||||
return m4aMetadataPath{
|
return m4aMetadataPath{
|
||||||
moov: moov,
|
moov: moov,
|
||||||
@@ -1295,9 +1383,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
|
||||||
return m4aMetadataPath{
|
return m4aMetadataPath{
|
||||||
moov: moov,
|
moov: moov,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
@@ -1432,6 +1518,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove := map[string]struct{}{
|
||||||
|
"REPLAYGAIN_TRACK_GAIN": {},
|
||||||
|
"REPLAYGAIN_TRACK_PEAK": {},
|
||||||
|
"REPLAYGAIN_ALBUM_GAIN": {},
|
||||||
|
"REPLAYGAIN_ALBUM_PEAK": {},
|
||||||
|
"ITUNNORM": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
order := []string{
|
||||||
|
"replaygain_track_gain",
|
||||||
|
"replaygain_track_peak",
|
||||||
|
"replaygain_album_gain",
|
||||||
|
"replaygain_album_peak",
|
||||||
|
"iTunNORM",
|
||||||
|
}
|
||||||
|
tags := make([]m4aFreeformTag, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
value := strings.TrimSpace(replayGainFields[key])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := key
|
||||||
|
if key != "iTunNORM" {
|
||||||
|
name = strings.ToLower(key)
|
||||||
|
}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: name, value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeM4AFreeformTags(filePath, remove, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
type m4aFreeformTag struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
|
||||||
|
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
|
||||||
|
// supplied tags (empty values are skipped, which effectively clears the field).
|
||||||
|
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
|
||||||
|
//
|
||||||
|
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
|
||||||
|
// fields like ISRC and LABEL are silently dropped when written via -metadata.
|
||||||
|
// Writing them as iTunes freeform atoms natively is the only way they persist.
|
||||||
|
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1445,6 +1576,13 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
|
|
||||||
path, err := findM4AMetadataPath(f, info.Size())
|
path, err := findM4AMetadataPath(f, info.Size())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
|
||||||
|
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
|
||||||
|
// to write freeform tags, so skip gracefully instead of failing.
|
||||||
|
if strings.Contains(err.Error(), "ilst not found") {
|
||||||
|
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1456,13 +1594,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
bodyStart := path.ilst.offset + path.ilst.headerSize
|
bodyStart := path.ilst.offset + path.ilst.headerSize
|
||||||
bodyEnd := path.ilst.offset + path.ilst.size
|
bodyEnd := path.ilst.offset + path.ilst.size
|
||||||
newBody := make([]byte, 0, int(path.ilst.size))
|
newBody := make([]byte, 0, int(path.ilst.size))
|
||||||
targets := map[string]struct{}{
|
|
||||||
"REPLAYGAIN_TRACK_GAIN": {},
|
|
||||||
"REPLAYGAIN_TRACK_PEAK": {},
|
|
||||||
"REPLAYGAIN_ALBUM_GAIN": {},
|
|
||||||
"REPLAYGAIN_ALBUM_PEAK": {},
|
|
||||||
"ITUNNORM": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for pos := bodyStart; pos+8 <= bodyEnd; {
|
for pos := bodyStart; pos+8 <= bodyEnd; {
|
||||||
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
||||||
@@ -1480,7 +1611,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
if header.typ == "----" {
|
if header.typ == "----" {
|
||||||
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
||||||
if freeformErr == nil {
|
if freeformErr == nil {
|
||||||
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||||
keep = false
|
keep = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1492,23 +1623,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
pos += header.size
|
pos += header.size
|
||||||
}
|
}
|
||||||
|
|
||||||
order := []string{
|
for _, tag := range tags {
|
||||||
"replaygain_track_gain",
|
if strings.TrimSpace(tag.value) == "" {
|
||||||
"replaygain_track_peak",
|
|
||||||
"replaygain_album_gain",
|
|
||||||
"replaygain_album_peak",
|
|
||||||
"iTunNORM",
|
|
||||||
}
|
|
||||||
for _, key := range order {
|
|
||||||
value := strings.TrimSpace(replayGainFields[key])
|
|
||||||
if value == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := key
|
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
|
||||||
if key != "iTunNORM" {
|
|
||||||
name = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newIlst := buildM4AAtom("ilst", newBody)
|
newIlst := buildM4AAtom("ilst", newBody)
|
||||||
@@ -1535,6 +1654,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
return os.WriteFile(filePath, updated, 0o644)
|
return os.WriteFile(filePath, updated, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
|
||||||
|
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
|
||||||
|
// they must be written natively for the values to actually persist. An empty
|
||||||
|
// value clears the corresponding tag. Other (recognized) tags are left intact.
|
||||||
|
func EditM4AFreeformText(filePath string, fields map[string]string) error {
|
||||||
|
_, hasISRC := fields["isrc"]
|
||||||
|
_, hasLabel := fields["label"]
|
||||||
|
if !hasISRC && !hasLabel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remove := map[string]struct{}{}
|
||||||
|
tags := make([]m4aFreeformTag, 0, 2)
|
||||||
|
if hasISRC {
|
||||||
|
remove["ISRC"] = struct{}{}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
|
||||||
|
}
|
||||||
|
if hasLabel {
|
||||||
|
remove["LABEL"] = struct{}{}
|
||||||
|
remove["ORGANIZATION"] = struct{}{}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeM4AFreeformTags(filePath, remove, tags)
|
||||||
|
}
|
||||||
|
|
||||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
base := strings.TrimSuffix(filePath, ext)
|
base := strings.TrimSuffix(filePath, ext)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
|
if os.IsPermission(err) {
|
||||||
return os.OpenFile(path, os.O_WRONLY, 0)
|
return os.OpenFile(path, os.O_WRONLY, 0)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ const (
|
|||||||
wavFormatExtensn = 0xFFFE
|
wavFormatExtensn = 0xFFFE
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- low-level chunk size helpers ----------
|
|
||||||
|
|
||||||
func putUint32(dst []byte, le bool, v uint32) {
|
func putUint32(dst []byte, le bool, v uint32) {
|
||||||
if le {
|
if le {
|
||||||
binary.LittleEndian.PutUint32(dst, v)
|
binary.LittleEndian.PutUint32(dst, v)
|
||||||
@@ -95,8 +93,6 @@ func parseExtendedFloat80(b []byte) float64 {
|
|||||||
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
|
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- WAV (RIFF) ----------
|
|
||||||
|
|
||||||
type wavProbe struct {
|
type wavProbe struct {
|
||||||
sampleRate int
|
sampleRate int
|
||||||
bitDepth int
|
bitDepth int
|
||||||
@@ -289,8 +285,6 @@ func ReadWAVTags(filePath string) (*AudioMetadata, error) {
|
|||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- AIFF / AIFC ----------
|
|
||||||
|
|
||||||
type aiffProbe struct {
|
type aiffProbe struct {
|
||||||
sampleRate int
|
sampleRate int
|
||||||
bitDepth int
|
bitDepth int
|
||||||
@@ -443,8 +437,6 @@ func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
|
|||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- ID3v2 reading from a buffered chunk ----------
|
|
||||||
|
|
||||||
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
|
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
|
||||||
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
|
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
|
||||||
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
|
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
|
||||||
@@ -535,8 +527,6 @@ func extractAPICFromID3(tag []byte) ([]byte, string) {
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- ID3v2.4 building ----------
|
|
||||||
|
|
||||||
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
|
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
|
||||||
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
|
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
|
||||||
var frames bytes.Buffer
|
var frames bytes.Buffer
|
||||||
@@ -642,8 +632,6 @@ func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []b
|
|||||||
return out.Bytes()
|
return out.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- tag writing (streaming chunk rewrite) ----------
|
|
||||||
|
|
||||||
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
|
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
|
||||||
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
|
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
|
||||||
// The audio data and all other chunks are preserved; container size is patched.
|
// The audio data and all other chunks are preserved; container size is patched.
|
||||||
@@ -692,7 +680,6 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
|
|||||||
pad := int64(size) & 1
|
pad := int64(size) & 1
|
||||||
|
|
||||||
if strings.EqualFold(id, chunkID) {
|
if strings.EqualFold(id, chunkID) {
|
||||||
// Drop the existing tag chunk.
|
|
||||||
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
|
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return err
|
return err
|
||||||
@@ -711,7 +698,6 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
|
|||||||
bodyLen += 8 + int64(size) + pad
|
bodyLen += 8 + int64(size) + pad
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the new tag chunk.
|
|
||||||
newSize := len(id3)
|
newSize := len(id3)
|
||||||
chunkHdr := make([]byte, 8)
|
chunkHdr := make([]byte, 8)
|
||||||
copy(chunkHdr[0:4], chunkID)
|
copy(chunkHdr[0:4], chunkID)
|
||||||
@@ -890,8 +876,6 @@ func WriteAIFFTags(filePath string, fields map[string]string) error {
|
|||||||
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
|
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- library scan integration ----------
|
|
||||||
|
|
||||||
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
||||||
applyAudioMetadataToScan(metadata, result)
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import Gobackend // Import Go framework
|
import Gobackend
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -17,6 +17,8 @@ import Gobackend // Import Go framework
|
|||||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||||
private var lastLibraryScanProgressPayload: String?
|
private var lastLibraryScanProgressPayload: String?
|
||||||
|
private var backendChannel: FlutterMethodChannel?
|
||||||
|
private var pendingSessionGrantEvents: [[String: Any]] = []
|
||||||
|
|
||||||
/// Currently accessed security-scoped URL for library folder
|
/// Currently accessed security-scoped URL for library folder
|
||||||
private var activeSecurityScopedURL: URL?
|
private var activeSecurityScopedURL: URL?
|
||||||
@@ -39,6 +41,14 @@ import Gobackend // Import Go framework
|
|||||||
name: CHANNEL,
|
name: CHANNEL,
|
||||||
binaryMessenger: controller.binaryMessenger
|
binaryMessenger: controller.binaryMessenger
|
||||||
)
|
)
|
||||||
|
backendChannel = channel
|
||||||
|
if !pendingSessionGrantEvents.isEmpty {
|
||||||
|
let events = pendingSessionGrantEvents
|
||||||
|
pendingSessionGrantEvents.removeAll()
|
||||||
|
for event in events {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
let downloadProgressEvents = FlutterEventChannel(
|
let downloadProgressEvents = FlutterEventChannel(
|
||||||
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||||
binaryMessenger: controller.binaryMessenger
|
binaryMessenger: controller.binaryMessenger
|
||||||
@@ -83,20 +93,25 @@ import Gobackend // Import Go framework
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
|
/// Extension return URLs:
|
||||||
|
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
|
||||||
|
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||||
let host = (url.host ?? "").lowercased()
|
let host = (url.host ?? "").lowercased()
|
||||||
let path = url.path.lowercased()
|
let path = url.path.lowercased()
|
||||||
|
let isSessionGrant = host == "session-grant"
|
||||||
let ok =
|
let ok =
|
||||||
host == "callback" || host == "spotify-callback" || path.contains("callback")
|
isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||||
guard ok else { return false }
|
guard ok else { return false }
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let q = components.queryItems ?? []
|
let q = components.queryItems ?? []
|
||||||
let code =
|
let code =
|
||||||
|
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
|
||||||
|
in: .whitespacesAndNewlines) ??
|
||||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||||
in: .whitespacesAndNewlines) ?? ""
|
in: .whitespacesAndNewlines) ?? ""
|
||||||
let state =
|
let state =
|
||||||
@@ -109,16 +124,37 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
streamQueue.async {
|
streamQueue.async {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
GobackendSetExtensionAuthCodeByID(state, code)
|
if isSessionGrant {
|
||||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
GobackendSetExtensionSessionGrantByID(state, code)
|
||||||
|
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
|
||||||
|
} else {
|
||||||
|
GobackendSetExtensionAuthCodeByID(state, code)
|
||||||
|
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||||
|
}
|
||||||
if let err = err {
|
if let err = err {
|
||||||
NSLog(
|
NSLog(
|
||||||
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
|
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
|
||||||
|
} else if isSessionGrant {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.notifySessionGrantCompleted(extensionId: state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func notifySessionGrantCompleted(extensionId: String) {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"extension_id": extensionId,
|
||||||
|
"success": true,
|
||||||
|
]
|
||||||
|
if let channel = backendChannel {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
|
||||||
|
} else {
|
||||||
|
pendingSessionGrantEvents.append(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ app: UIApplication,
|
_ app: UIApplication,
|
||||||
open url: URL,
|
open url: URL,
|
||||||
@@ -358,6 +394,12 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "setAllowPrivateNetwork":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let allowed = args["allowed"] as? Bool ?? false
|
||||||
|
GobackendSetAllowPrivateNetwork(allowed)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "checkDuplicate":
|
case "checkDuplicate":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let outputDir = args["output_dir"] as! String
|
let outputDir = args["output_dir"] as! String
|
||||||
@@ -590,7 +632,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Log methods
|
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
return response
|
return response
|
||||||
@@ -615,7 +656,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetLoggingEnabled(enabled)
|
GobackendSetLoggingEnabled(enabled)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension System methods
|
|
||||||
case "initExtensionSystem":
|
case "initExtensionSystem":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionsDir = args["extensions_dir"] as! String
|
let extensionsDir = args["extensions_dir"] as! String
|
||||||
@@ -780,7 +820,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupExtensions()
|
GobackendCleanupExtensions()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension Auth API
|
|
||||||
case "getExtensionPendingAuth":
|
case "getExtensionPendingAuth":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -821,7 +860,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension FFmpeg API
|
|
||||||
case "getPendingFFmpegCommand":
|
case "getPendingFFmpegCommand":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let commandId = args["command_id"] as! String
|
let commandId = args["command_id"] as! String
|
||||||
@@ -843,7 +881,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Custom Search API
|
|
||||||
case "customSearchWithExtension":
|
case "customSearchWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -865,7 +902,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension URL Handler API
|
|
||||||
case "handleURLWithExtension":
|
case "handleURLWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -884,7 +920,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Post-Processing API
|
|
||||||
case "runPostProcessing":
|
case "runPostProcessing":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let filePath = args["file_path"] as! String
|
let filePath = args["file_path"] as! String
|
||||||
@@ -906,7 +941,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Store
|
|
||||||
case "initExtensionStore":
|
case "initExtensionStore":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cacheDir = args["cache_dir"] as! String
|
let cacheDir = args["cache_dir"] as! String
|
||||||
@@ -964,7 +998,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension Home Feed API
|
|
||||||
case "getExtensionHomeFeed":
|
case "getExtensionHomeFeed":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -980,7 +1013,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Local Library Scanning
|
|
||||||
case "setLibraryCoverCacheDir":
|
case "setLibraryCoverCacheDir":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cacheDir = args["cache_dir"] as! String
|
let cacheDir = args["cache_dir"] as! String
|
||||||
@@ -1017,7 +1049,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// iOS Security-Scoped Bookmark for Local Library
|
|
||||||
case "resolveIosBookmark":
|
case "resolveIosBookmark":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let bookmarkBase64 = args["bookmark"] as! String
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
@@ -1037,7 +1068,6 @@ import Gobackend // Import Go framework
|
|||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
return try createIosBookmarkFromPath(path)
|
return try createIosBookmarkFromPath(path)
|
||||||
|
|
||||||
// Lyrics Provider Settings
|
|
||||||
case "setLyricsProviders":
|
case "setLyricsProviders":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let providersJson = args["providers_json"] as? String ?? "[]"
|
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||||
@@ -1067,7 +1097,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// CUE Sheet Parsing
|
|
||||||
case "parseCueSheet":
|
case "parseCueSheet":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cuePath = args["cue_path"] as! String
|
let cuePath = args["cue_path"] as! String
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart';
|
|||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/app_navigation_service.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: AppNavigationService.rootNavigatorKey,
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '4.6.0';
|
static const String version = '4.7.1';
|
||||||
static const String buildNumber = '135';
|
static const String buildNumber = '137';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|||||||
@@ -1202,6 +1202,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Download'**
|
/// **'Download'**
|
||||||
String get dialogDownload;
|
String get dialogDownload;
|
||||||
|
|
||||||
|
/// Tooltip for the button that plays a short track preview snippet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play preview'**
|
||||||
|
String get previewPlay;
|
||||||
|
|
||||||
|
/// Tooltip for the button that stops the playing track preview snippet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stop preview'**
|
||||||
|
String get previewStop;
|
||||||
|
|
||||||
|
/// Snackbar shown when a track preview snippet cannot be played
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Preview unavailable'**
|
||||||
|
String get previewUnavailable;
|
||||||
|
|
||||||
/// Dialog button - discard changes
|
/// Dialog button - discard changes
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2954,6 +2972,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Album Folder Structure'**
|
/// **'Album Folder Structure'**
|
||||||
String get downloadAlbumFolderStructure;
|
String get downloadAlbumFolderStructure;
|
||||||
|
|
||||||
|
/// Album folder structure picker description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose how album folders are structured'**
|
||||||
|
String get albumFolderStructureDescription;
|
||||||
|
|
||||||
/// Setting - choose whether artist folders use Album Artist or Track Artist
|
/// Setting - choose whether artist folders use Album Artist or Track Artist
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4993,6 +5017,198 @@ abstract class AppLocalizations {
|
|||||||
/// **'Buy the developer a coffee'**
|
/// **'Buy the developer a coffee'**
|
||||||
String get settingsDonateSubtitle;
|
String get settingsDonateSubtitle;
|
||||||
|
|
||||||
|
/// Settings menu item - backup and restore page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Backup & Restore'**
|
||||||
|
String get settingsBackup;
|
||||||
|
|
||||||
|
/// Subtitle for backup and restore settings item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Move your library, history and settings to a new device'**
|
||||||
|
String get settingsBackupSubtitle;
|
||||||
|
|
||||||
|
/// App bar title for the backup and restore page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Backup & Restore'**
|
||||||
|
String get backupTitle;
|
||||||
|
|
||||||
|
/// Section title for the export/backup card
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create backup'**
|
||||||
|
String get backupExportSectionTitle;
|
||||||
|
|
||||||
|
/// Description of what a backup contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.'**
|
||||||
|
String get backupExportSectionDescription;
|
||||||
|
|
||||||
|
/// Button to create and share a backup file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create backup file'**
|
||||||
|
String get backupExportButton;
|
||||||
|
|
||||||
|
/// Section title for the import/restore card
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Restore backup'**
|
||||||
|
String get backupImportSectionTitle;
|
||||||
|
|
||||||
|
/// Description for the restore action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.'**
|
||||||
|
String get backupImportSectionDescription;
|
||||||
|
|
||||||
|
/// Button to pick a backup file to restore
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose backup file'**
|
||||||
|
String get backupImportButton;
|
||||||
|
|
||||||
|
/// Progress text while a backup is being created
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Creating backup...'**
|
||||||
|
String get backupCreating;
|
||||||
|
|
||||||
|
/// Snackbar after a backup file is created
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Backup created'**
|
||||||
|
String get backupCreated;
|
||||||
|
|
||||||
|
/// Snackbar when backup creation fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to create backup'**
|
||||||
|
String get backupCreateFailed;
|
||||||
|
|
||||||
|
/// Snackbar when there is no data to back up
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'There is nothing to back up yet'**
|
||||||
|
String get backupEmpty;
|
||||||
|
|
||||||
|
/// Confirmation dialog title before restoring a backup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Restore this backup?'**
|
||||||
|
String get backupRestoreConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message before restoring a backup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.'**
|
||||||
|
String get backupRestoreConfirmMessage;
|
||||||
|
|
||||||
|
/// Confirm button to proceed with restore
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Restore'**
|
||||||
|
String get backupRestoreConfirmButton;
|
||||||
|
|
||||||
|
/// Progress text while restoring a backup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Restoring backup...'**
|
||||||
|
String get backupRestoring;
|
||||||
|
|
||||||
|
/// Snackbar after a successful restore
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Backup restored successfully'**
|
||||||
|
String get backupRestored;
|
||||||
|
|
||||||
|
/// Snackbar when restore fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to restore backup'**
|
||||||
|
String get backupRestoreFailed;
|
||||||
|
|
||||||
|
/// Snackbar when the chosen file is not a valid backup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This file is not a valid SpotiFLAC backup'**
|
||||||
|
String get backupInvalidFile;
|
||||||
|
|
||||||
|
/// Hint shown after restoring that an app restart is recommended
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Restart the app to make sure every change is applied.'**
|
||||||
|
String get backupRestoreRestartHint;
|
||||||
|
|
||||||
|
/// Header above the list summarizing what the backup contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Backup contents'**
|
||||||
|
String get backupContentsTitle;
|
||||||
|
|
||||||
|
/// Backup contents row label for settings
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'App settings'**
|
||||||
|
String get backupContentsSettings;
|
||||||
|
|
||||||
|
/// Backup contents row for history count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} history {count, plural, =1{item} other{items}}'**
|
||||||
|
String backupContentsHistory(int count);
|
||||||
|
|
||||||
|
/// Backup contents row for liked tracks count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} liked {count, plural, =1{track} other{tracks}}'**
|
||||||
|
String backupContentsLiked(int count);
|
||||||
|
|
||||||
|
/// Backup contents row for wishlist tracks count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} wishlist {count, plural, =1{track} other{tracks}}'**
|
||||||
|
String backupContentsWishlist(int count);
|
||||||
|
|
||||||
|
/// Backup contents row for playlist count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
|
||||||
|
String backupContentsPlaylists(int count);
|
||||||
|
|
||||||
|
/// Backup contents row for favorite artists count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 favorite artist} other{{count} favorite artists}}'**
|
||||||
|
String backupContentsArtists(int count);
|
||||||
|
|
||||||
|
/// Backup contents row for installed extensions count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 extension} other{{count} extensions}}'**
|
||||||
|
String backupContentsExtensions(int count);
|
||||||
|
|
||||||
|
/// Toggle to include secret extension settings (tokens, API keys) in the backup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Include extension credentials'**
|
||||||
|
String get backupIncludeSecrets;
|
||||||
|
|
||||||
|
/// Explanation for the include-credentials toggle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'**
|
||||||
|
String get backupIncludeSecretsDescription;
|
||||||
|
|
||||||
|
/// Snackbar/hint when some extensions failed to reinstall during restore
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.'**
|
||||||
|
String backupExtensionsRestoreFailed(int count);
|
||||||
|
|
||||||
/// Tooltip for the Love All button on album/playlist screens
|
/// Tooltip for the Love All button on album/playlist screens
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5205,6 +5421,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Using standard network settings'**
|
/// **'Using standard network settings'**
|
||||||
String get downloadNetworkCompatibilityModeDisabled;
|
String get downloadNetworkCompatibilityModeDisabled;
|
||||||
|
|
||||||
|
/// Setting title for allowing requests to private/local network targets
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Allow Local Network Access'**
|
||||||
|
String get downloadAllowLocalNetwork;
|
||||||
|
|
||||||
|
/// Subtitle when allow local network access is on
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Requests to local/private addresses are allowed (for local proxy or custom DNS)'**
|
||||||
|
String get downloadAllowLocalNetworkEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when allow local network access is off
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Local/private addresses are blocked for security'**
|
||||||
|
String get downloadAllowLocalNetworkDisabled;
|
||||||
|
|
||||||
/// Subtitle when quality picker is disabled due to extension service
|
/// Subtitle when quality picker is disabled due to extension service
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -7116,6 +7350,526 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{service} link copied'**
|
/// **'{service} link copied'**
|
||||||
String shareSheetLinkCopied(Object service);
|
String shareSheetLinkCopied(Object service);
|
||||||
|
|
||||||
|
/// Section header for playback settings in library settings
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playback'**
|
||||||
|
String get libraryPlayback;
|
||||||
|
|
||||||
|
/// Setting option to use an external music player
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'External player'**
|
||||||
|
String get libraryExternalPlayer;
|
||||||
|
|
||||||
|
/// Subtitle for external player option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recommended for listening, best quality, gapless playback, EQ, and wider format support'**
|
||||||
|
String get libraryExternalPlayerSubtitle;
|
||||||
|
|
||||||
|
/// Setting option to use the built-in preview player
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Built-in preview player'**
|
||||||
|
String get libraryBuiltInPreviewPlayer;
|
||||||
|
|
||||||
|
/// Subtitle for built-in preview player option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening'**
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle;
|
||||||
|
|
||||||
|
/// Info note explaining the built-in player is for previews only
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.'**
|
||||||
|
String get libraryBuiltInPlayerInfo;
|
||||||
|
|
||||||
|
/// Title for the now playing screen
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Now Playing'**
|
||||||
|
String get nowPlayingTitle;
|
||||||
|
|
||||||
|
/// Empty state when no track is currently playing
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nothing is playing'**
|
||||||
|
String get nowPlayingNothingPlaying;
|
||||||
|
|
||||||
|
/// Tooltip for minimizing the now playing screen
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Minimize'**
|
||||||
|
String get nowPlayingMinimize;
|
||||||
|
|
||||||
|
/// Title for the playback queue sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Up next'**
|
||||||
|
String get nowPlayingUpNext;
|
||||||
|
|
||||||
|
/// Menu item and section title for track metadata details
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Details'**
|
||||||
|
String get nowPlayingDetails;
|
||||||
|
|
||||||
|
/// Menu item to open the current track in an external player
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open in external player'**
|
||||||
|
String get nowPlayingOpenInExternalPlayer;
|
||||||
|
|
||||||
|
/// Tab label for the player view
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Player'**
|
||||||
|
String get nowPlayingTabPlayer;
|
||||||
|
|
||||||
|
/// Tab label for the lyrics view
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics'**
|
||||||
|
String get nowPlayingTabLyrics;
|
||||||
|
|
||||||
|
/// Empty state when the playing file has no embedded lyrics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No lyrics in this file'**
|
||||||
|
String get nowPlayingNoLyrics;
|
||||||
|
|
||||||
|
/// Snackbar when shuffle library is requested but library has no tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your library is empty'**
|
||||||
|
String get nowPlayingLibraryEmpty;
|
||||||
|
|
||||||
|
/// Snackbar when shuffling the library fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not shuffle library: {error}'**
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error);
|
||||||
|
|
||||||
|
/// Tooltip when shuffle mode is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shuffle on'**
|
||||||
|
String get nowPlayingShuffleOn;
|
||||||
|
|
||||||
|
/// Tooltip when shuffle mode is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play in order'**
|
||||||
|
String get nowPlayingPlayInOrder;
|
||||||
|
|
||||||
|
/// Button label to shuffle and play the entire local library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shuffle library'**
|
||||||
|
String get nowPlayingShuffleLibrary;
|
||||||
|
|
||||||
|
/// Empty state when the playback queue has no items
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Queue is empty'**
|
||||||
|
String get nowPlayingQueueEmpty;
|
||||||
|
|
||||||
|
/// Empty state when track metadata cannot be loaded
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No metadata available'**
|
||||||
|
String get nowPlayingNoMetadata;
|
||||||
|
|
||||||
|
/// Snackbar shown when an announcement CTA link cannot be opened
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unable to open link. Please try again.'**
|
||||||
|
String get announcementUnableToOpenLink;
|
||||||
|
|
||||||
|
/// Hint shown when lossless conversion will cap bit depth or sample rate
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossless output with {quality} cap'**
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality);
|
||||||
|
|
||||||
|
/// Confirmation dialog message for capped lossless conversion of a single file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Confirmation dialog message for capped lossless batch conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.'**
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Convert button label for lossless conversion with quality cap
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{sourceFormat} → {targetFormat} ({quality})'**
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Convert button label for lossy conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{sourceFormat} → {targetFormat} @ {bitrate}'**
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Subtitle for Paxsenix special thanks entry on the about page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius'**
|
||||||
|
String get aboutPaxsenixSubtitle;
|
||||||
|
|
||||||
|
/// Snackbar when a track is inserted as the next queue item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playing next'**
|
||||||
|
String get snackbarPlayingNext;
|
||||||
|
|
||||||
|
/// Snackbar when a track is added to the playback queue without naming it
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added to queue'**
|
||||||
|
String get snackbarAddedToQueueGeneric;
|
||||||
|
|
||||||
|
/// Button label for deleting multiple selected playlists
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete {count} {count, plural, =1{playlist} other{playlists}}'**
|
||||||
|
String selectionDeletePlaylistsCount(int count);
|
||||||
|
|
||||||
|
/// Tooltip for shuffle playback action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shuffle'**
|
||||||
|
String get actionShuffle;
|
||||||
|
|
||||||
|
/// Status label when primary-artist-only folder naming is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary only: On'**
|
||||||
|
String get downloadPrimaryArtistOnlyOn;
|
||||||
|
|
||||||
|
/// Status label when primary-artist-only folder naming is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary only: Off'**
|
||||||
|
String get downloadPrimaryArtistOnlyOff;
|
||||||
|
|
||||||
|
/// Status label when album-artist folder filtering uses primary artist only
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album Artist metadata: Primary only'**
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly;
|
||||||
|
|
||||||
|
/// Status label when album-artist folder filtering uses full metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album Artist metadata: Full'**
|
||||||
|
String get downloadAlbumArtistMetadataFull;
|
||||||
|
|
||||||
|
/// Label for keeping original bit depth or sample rate during conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Original'**
|
||||||
|
String get trackConvertOriginal;
|
||||||
|
|
||||||
|
/// Label when no bit depth or sample rate cap is applied during lossless conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Original quality'**
|
||||||
|
String get trackConvertOriginalQuality;
|
||||||
|
|
||||||
|
/// Suffix used in converted lossless quality labels
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossless'**
|
||||||
|
String get trackConvertLosslessSuffix;
|
||||||
|
|
||||||
|
/// Section label for lossless conversion dithering options
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dithering'**
|
||||||
|
String get trackConvertDithering;
|
||||||
|
|
||||||
|
/// Section label for lossless conversion resampler options
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Resampler'**
|
||||||
|
String get trackConvertResampler;
|
||||||
|
|
||||||
|
/// Lossless conversion dither option with no dithering applied
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None'**
|
||||||
|
String get trackConvertDitherNone;
|
||||||
|
|
||||||
|
/// Lossless conversion triangular probability density function dither option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TPDF'**
|
||||||
|
String get trackConvertDitherTriangular;
|
||||||
|
|
||||||
|
/// Lossless conversion high-pass triangular dither option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Triangular HP'**
|
||||||
|
String get trackConvertDitherTriangularHp;
|
||||||
|
|
||||||
|
/// Lossless conversion default FFmpeg swresample resampler option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SWR'**
|
||||||
|
String get trackConvertResamplerSwr;
|
||||||
|
|
||||||
|
/// Lossless conversion SoX resampler option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SoXr'**
|
||||||
|
String get trackConvertResamplerSoxr;
|
||||||
|
|
||||||
|
/// Fallback changelog text when release notes cannot be parsed
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'See release notes for details.'**
|
||||||
|
String get updateSeeReleaseNotes;
|
||||||
|
|
||||||
|
/// Fallback track title when metadata is missing
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unknown title'**
|
||||||
|
String get unknownTitle;
|
||||||
|
|
||||||
|
/// Menu action to play a track as the next queue item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play next'**
|
||||||
|
String get trackPlayNext;
|
||||||
|
|
||||||
|
/// Menu action to add a track to the playback queue
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add to queue'**
|
||||||
|
String get trackAddToQueue;
|
||||||
|
|
||||||
|
/// Snackbar after installing an extension from the repo tab
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{extensionName} installed. Enable it in Settings > Extensions'**
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName);
|
||||||
|
|
||||||
|
/// Snackbar after updating an extension from the repo tab
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{extensionName} updated to v{version}'**
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version);
|
||||||
|
|
||||||
|
/// Snackbar when extension install fails in the repo tab
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to install {extensionName}'**
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName);
|
||||||
|
|
||||||
|
/// Snackbar when extension update fails in the repo tab
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to update {extensionName}'**
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName);
|
||||||
|
|
||||||
|
/// Badge label for EP releases
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'EP'**
|
||||||
|
String get releaseTypeEp;
|
||||||
|
|
||||||
|
/// Badge label for single releases
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Single'**
|
||||||
|
String get releaseTypeSingle;
|
||||||
|
|
||||||
|
/// Label shown when metadata autofill downloaded cover art from the internet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Online cover'**
|
||||||
|
String get trackCoverOnline;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'United States'**
|
||||||
|
String get regionCountryUS;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'United Kingdom'**
|
||||||
|
String get regionCountryGB;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'France'**
|
||||||
|
String get regionCountryFR;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Germany'**
|
||||||
|
String get regionCountryDE;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Japan'**
|
||||||
|
String get regionCountryJP;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'South Korea'**
|
||||||
|
String get regionCountryKR;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'India'**
|
||||||
|
String get regionCountryIN;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Indonesia'**
|
||||||
|
String get regionCountryID;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Brazil'**
|
||||||
|
String get regionCountryBR;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Mexico'**
|
||||||
|
String get regionCountryMX;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Australia'**
|
||||||
|
String get regionCountryAU;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Canada'**
|
||||||
|
String get regionCountryCA;
|
||||||
|
|
||||||
|
/// Country name for SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kosovo'**
|
||||||
|
String get regionCountryXK;
|
||||||
|
|
||||||
|
/// Settings option title for extension verification browser preference
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verification browser'**
|
||||||
|
String get extensionVerificationBrowserTitle;
|
||||||
|
|
||||||
|
/// Subtitle when external browser is preferred for extension verification
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open challenges in the default browser first'**
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal;
|
||||||
|
|
||||||
|
/// Subtitle when in-app browser is preferred for extension verification
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open challenges in the in-app browser first'**
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp;
|
||||||
|
|
||||||
|
/// Chip label for external browser verification mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'External'**
|
||||||
|
String get extensionVerificationBrowserExternal;
|
||||||
|
|
||||||
|
/// Chip label for in-app browser verification mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In-app'**
|
||||||
|
String get extensionVerificationBrowserInApp;
|
||||||
|
|
||||||
|
/// Dialog title when automatic browser launch for verification fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open verification manually'**
|
||||||
|
String get extensionVerificationHelpTitleManual;
|
||||||
|
|
||||||
|
/// Dialog title when verification is taking longer than expected
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verification still waiting'**
|
||||||
|
String get extensionVerificationHelpTitleWaiting;
|
||||||
|
|
||||||
|
/// Dialog message when automatic browser launch for verification fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.'**
|
||||||
|
String get extensionVerificationHelpMessageManual;
|
||||||
|
|
||||||
|
/// Dialog message when verification may need manual browser help
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.'**
|
||||||
|
String get extensionVerificationHelpMessageWaiting;
|
||||||
|
|
||||||
|
/// Button to dismiss the extension verification help dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Close'**
|
||||||
|
String get extensionVerificationClose;
|
||||||
|
|
||||||
|
/// Button to copy the extension verification URL
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copy link'**
|
||||||
|
String get extensionVerificationCopyLink;
|
||||||
|
|
||||||
|
/// Snackbar after copying the extension verification URL
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verification link copied'**
|
||||||
|
String get extensionVerificationLinkCopied;
|
||||||
|
|
||||||
|
/// Button to open the extension verification URL in a browser
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open browser'**
|
||||||
|
String get extensionVerificationOpenBrowser;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4274,4 +4456,318 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,6 +612,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Herunterladen';
|
String get dialogDownload => 'Herunterladen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Verwerfen';
|
String get dialogDiscard => 'Verwerfen';
|
||||||
|
|
||||||
@@ -1615,6 +1624,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Ordnerstruktur für Alben festlegen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Album-Künstler für Ordner verwenden';
|
'Album-Künstler für Ordner verwenden';
|
||||||
@@ -2922,6 +2935,164 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Kaufe dem Entwickler einen Kaffee';
|
String get settingsDonateSubtitle => 'Kaufe dem Entwickler einen Kaffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Alle lieben';
|
String get tooltipLoveAll => 'Alle lieben';
|
||||||
|
|
||||||
@@ -3054,6 +3225,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Standard-Netzwerkeinstellungen verwenden';
|
'Standard-Netzwerkeinstellungen verwenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4323,4 +4505,318 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4274,4 +4456,318 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4268,6 +4450,320 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
@@ -5842,6 +6338,10 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum';
|
String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Elige cómo se estructuran las carpetas de los álbumes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Usar álbum de artista cómo carpeta';
|
'Usar álbum de artista cómo carpeta';
|
||||||
|
|||||||
@@ -620,6 +620,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Télécharger';
|
String get dialogDownload => 'Télécharger';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Ignorer';
|
String get dialogDiscard => 'Ignorer';
|
||||||
|
|
||||||
@@ -1637,6 +1646,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Structure du dossier de l\'album';
|
String get downloadAlbumFolderStructure => 'Structure du dossier de l\'album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choisir la structure des dossiers d\'album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Utilisez l\'artiste de l\'album pour les dossiers';
|
'Utilisez l\'artiste de l\'album pour les dossiers';
|
||||||
@@ -2962,6 +2975,164 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Offrez un café au développeur';
|
String get settingsDonateSubtitle => 'Offrez un café au développeur';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Tout aimer';
|
String get tooltipLoveAll => 'Tout aimer';
|
||||||
|
|
||||||
@@ -3099,6 +3270,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Utilisation des paramètres réseau par défaut';
|
'Utilisation des paramètres réseau par défaut';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4388,4 +4570,318 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return 'Lien $service copié';
|
return 'Lien $service copié';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4274,4 +4456,318 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Buang';
|
String get dialogDiscard => 'Buang';
|
||||||
|
|
||||||
@@ -1598,6 +1607,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription => 'Pilih struktur folder album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Gunakan Artis Album untuk folder';
|
'Gunakan Artis Album untuk folder';
|
||||||
@@ -2892,6 +2904,141 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Cadangkan & Pulihkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Pindahkan pustaka, riwayat, dan pengaturan ke perangkat baru';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Cadangkan & Pulihkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Buat cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Simpan pengaturan, riwayat unduhan, lagu disukai, wishlist, artis favorit, dan playlist ke dalam satu file yang bisa kamu simpan atau pindahkan ke ponsel lain.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Buat file cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Pulihkan cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pilih file cadangan untuk memulihkan data. Ini akan menggantikan pengaturan, riwayat, dan pustaka di perangkat ini.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Pilih file cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Membuat cadangan...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Cadangan berhasil dibuat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Gagal membuat cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'Belum ada data untuk dicadangkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Pulihkan cadangan ini?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'Ini akan menggantikan pengaturan, riwayat unduhan, lagu disukai, wishlist, dan playlist saat ini dengan isi cadangan. Tindakan ini tidak bisa dibatalkan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Pulihkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Memulihkan cadangan...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Cadangan berhasil dipulihkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Gagal memulihkan cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile =>
|
||||||
|
'File ini bukan cadangan SpotiFLAC yang valid';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Mulai ulang aplikasi untuk memastikan semua perubahan diterapkan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Isi cadangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'Pengaturan aplikasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
return '$count item riwayat';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
return '$count lagu disukai';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
return '$count lagu di wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlist',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count artis favorit',
|
||||||
|
one: '1 artis favorit',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extension',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Sertakan kredensial extension';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
return '$count extension gagal dipasang ulang. Pasang manual dari store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3021,6 +3168,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Izinkan Akses Jaringan Lokal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Alamat lokal/privat diblokir demi keamanan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4281,4 +4439,319 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Pemutaran';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'Pemutar eksternal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Disarankan untuk mendengarkan, kualitas terbaik, pemutaran tanpa jeda, EQ, dan dukungan format lebih luas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Pemutar pratinjau bawaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Hanya untuk pratinjau lokal cepat di dalam SpotiFLAC Mobile, tidak disarankan untuk mendengarkan secara rutin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'Pemutar bawaan adalah alat pratinjau untuk memeriksa trek lokal dengan cepat. Gunakan pemutar musik eksternal untuk mendengarkan sebenarnya.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Sedang Diputar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Tidak ada yang diputar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimalkan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Berikutnya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Detail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Buka di pemutar eksternal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Pemutar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lirik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'Tidak ada lirik di file ini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Perpustakaan Anda kosong';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Tidak dapat mengacak perpustakaan: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Acak aktif';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Putar berurutan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Acak perpustakaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Antrean kosong';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'Metadata tidak tersedia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Tidak dapat membuka tautan. Silakan coba lagi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Output lossless dengan batas $quality';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Konversi dari $sourceFormat ke $targetFormat ($quality)?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'trek',
|
||||||
|
one: 'trek',
|
||||||
|
);
|
||||||
|
return 'Konversi $count $_temp0 ke $format ($quality)?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Proxy lirik untuk Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, dan Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Memutar berikutnya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Ditambahkan ke antrean';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlist',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Hapus $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Acak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Hanya utama: Aktif';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Hanya utama: Nonaktif';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Metadata Album Artist: Hanya utama';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull =>
|
||||||
|
'Metadata Album Artist: Lengkap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Asli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Kualitas asli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'Tidak ada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'Lihat catatan rilis untuk detail.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Judul tidak diketahui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Putar berikutnya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Tambah ke antrean';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName terpasang. Aktifkan di Pengaturan > Ekstensi';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName diperbarui ke v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Gagal memasang $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Gagal memperbarui $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Sampul daring';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'Amerika Serikat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'Britania Raya';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'Prancis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Jerman';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Jepang';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'Korea Selatan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brasil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Meksiko';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Kanada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Browser verifikasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Buka tantangan di browser default terlebih dahulu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Buka tantangan di browser dalam aplikasi terlebih dahulu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'Eksternal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'Dalam aplikasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Buka verifikasi secara manual';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verifikasi masih menunggu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Tutup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Salin tautan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Tautan verifikasi disalin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Buka browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -600,6 +600,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '破棄';
|
String get dialogDiscard => '破棄';
|
||||||
|
|
||||||
@@ -1582,6 +1591,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription => 'アルバムフォルダの構成を選択';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2873,6 +2885,164 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3002,6 +3172,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4262,4 +4443,318 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,6 +593,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '취소';
|
String get dialogDiscard => '취소';
|
||||||
|
|
||||||
@@ -1577,6 +1586,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2870,6 +2883,164 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -2999,6 +3170,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4259,4 +4441,318 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4274,4 +4456,318 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4268,6 +4450,320 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
@@ -5837,6 +6333,10 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum';
|
String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Escolher a estrutura das pastas dos álbuns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
|||||||
@@ -609,6 +609,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Скачать';
|
String get dialogDownload => 'Скачать';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Отменить';
|
String get dialogDiscard => 'Отменить';
|
||||||
|
|
||||||
@@ -1613,6 +1622,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Структура папок альбома';
|
String get downloadAlbumFolderStructure => 'Структура папок альбома';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Выберите структуру папок альбомов';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Использовать исполнителя альбома для папок';
|
'Использовать исполнителя альбома для папок';
|
||||||
@@ -2940,6 +2953,164 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3069,6 +3240,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4330,4 +4512,318 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,6 +610,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'İndir';
|
String get dialogDownload => 'İndir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Vazgeç';
|
String get dialogDiscard => 'Vazgeç';
|
||||||
|
|
||||||
@@ -1609,6 +1618,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Albüm Klasör Yapısı';
|
String get downloadAlbumFolderStructure => 'Albüm Klasör Yapısı';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription => 'Albüm klasör yapısını seçin';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Klasörler için Albüm Sanatçısı\'nı kullan';
|
'Klasörler için Albüm Sanatçısı\'nı kullan';
|
||||||
@@ -2914,6 +2926,164 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3046,6 +3216,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4306,4 +4487,318 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,6 +612,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Завантажити';
|
String get dialogDownload => 'Завантажити';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Відхилити';
|
String get dialogDiscard => 'Відхилити';
|
||||||
|
|
||||||
@@ -1615,6 +1624,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Структура папок альбому';
|
String get downloadAlbumFolderStructure => 'Структура папок альбому';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Виберіть структуру папок альбомів';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
'Використовувати виконавця альбому для папок';
|
'Використовувати виконавця альбому для папок';
|
||||||
@@ -2929,6 +2942,164 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Уподобати всіх';
|
String get tooltipLoveAll => 'Уподобати всіх';
|
||||||
|
|
||||||
@@ -3061,6 +3232,17 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4327,4 +4509,318 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,6 +603,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1592,6 +1601,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -2885,6 +2898,164 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -3014,6 +3185,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a provider with quality options to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
@@ -4268,6 +4450,320 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String shareSheetLinkCopied(Object service) {
|
String shareSheetLinkCopied(Object service) {
|
||||||
return '$service link copied';
|
return '$service link copied';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
@@ -5807,6 +6303,10 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -10038,6 +10538,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
|||||||
@@ -2037,6 +2037,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Ordnerstruktur für Alben festlegen",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -772,6 +772,18 @@
|
|||||||
"@dialogDownload": {
|
"@dialogDownload": {
|
||||||
"description": "Confirm button in Download All dialog"
|
"description": "Confirm button in Download All dialog"
|
||||||
},
|
},
|
||||||
|
"previewPlay": "Play preview",
|
||||||
|
"@previewPlay": {
|
||||||
|
"description": "Tooltip for the button that plays a short track preview snippet"
|
||||||
|
},
|
||||||
|
"previewStop": "Stop preview",
|
||||||
|
"@previewStop": {
|
||||||
|
"description": "Tooltip for the button that stops the playing track preview snippet"
|
||||||
|
},
|
||||||
|
"previewUnavailable": "Preview unavailable",
|
||||||
|
"@previewUnavailable": {
|
||||||
|
"description": "Snackbar shown when a track preview snippet cannot be played"
|
||||||
|
},
|
||||||
"dialogDiscard": "Discard",
|
"dialogDiscard": "Discard",
|
||||||
"@dialogDiscard": {
|
"@dialogDiscard": {
|
||||||
"description": "Dialog button - discard changes"
|
"description": "Dialog button - discard changes"
|
||||||
@@ -2095,6 +2107,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
@@ -3827,6 +3843,169 @@
|
|||||||
"@settingsDonateSubtitle": {
|
"@settingsDonateSubtitle": {
|
||||||
"description": "Subtitle for donate menu item"
|
"description": "Subtitle for donate menu item"
|
||||||
},
|
},
|
||||||
|
"settingsBackup": "Backup & Restore",
|
||||||
|
"@settingsBackup": {
|
||||||
|
"description": "Settings menu item - backup and restore page"
|
||||||
|
},
|
||||||
|
"settingsBackupSubtitle": "Move your library, history and settings to a new device",
|
||||||
|
"@settingsBackupSubtitle": {
|
||||||
|
"description": "Subtitle for backup and restore settings item"
|
||||||
|
},
|
||||||
|
"backupTitle": "Backup & Restore",
|
||||||
|
"@backupTitle": {
|
||||||
|
"description": "App bar title for the backup and restore page"
|
||||||
|
},
|
||||||
|
"backupExportSectionTitle": "Create backup",
|
||||||
|
"@backupExportSectionTitle": {
|
||||||
|
"description": "Section title for the export/backup card"
|
||||||
|
},
|
||||||
|
"backupExportSectionDescription": "Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.",
|
||||||
|
"@backupExportSectionDescription": {
|
||||||
|
"description": "Description of what a backup contains"
|
||||||
|
},
|
||||||
|
"backupExportButton": "Create backup file",
|
||||||
|
"@backupExportButton": {
|
||||||
|
"description": "Button to create and share a backup file"
|
||||||
|
},
|
||||||
|
"backupImportSectionTitle": "Restore backup",
|
||||||
|
"@backupImportSectionTitle": {
|
||||||
|
"description": "Section title for the import/restore card"
|
||||||
|
},
|
||||||
|
"backupImportSectionDescription": "Pick a backup file to restore your data. This replaces the current settings, history and library on this device.",
|
||||||
|
"@backupImportSectionDescription": {
|
||||||
|
"description": "Description for the restore action"
|
||||||
|
},
|
||||||
|
"backupImportButton": "Choose backup file",
|
||||||
|
"@backupImportButton": {
|
||||||
|
"description": "Button to pick a backup file to restore"
|
||||||
|
},
|
||||||
|
"backupCreating": "Creating backup...",
|
||||||
|
"@backupCreating": {
|
||||||
|
"description": "Progress text while a backup is being created"
|
||||||
|
},
|
||||||
|
"backupCreated": "Backup created",
|
||||||
|
"@backupCreated": {
|
||||||
|
"description": "Snackbar after a backup file is created"
|
||||||
|
},
|
||||||
|
"backupCreateFailed": "Failed to create backup",
|
||||||
|
"@backupCreateFailed": {
|
||||||
|
"description": "Snackbar when backup creation fails"
|
||||||
|
},
|
||||||
|
"backupEmpty": "There is nothing to back up yet",
|
||||||
|
"@backupEmpty": {
|
||||||
|
"description": "Snackbar when there is no data to back up"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmTitle": "Restore this backup?",
|
||||||
|
"@backupRestoreConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title before restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmMessage": "This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.",
|
||||||
|
"@backupRestoreConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message before restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmButton": "Restore",
|
||||||
|
"@backupRestoreConfirmButton": {
|
||||||
|
"description": "Confirm button to proceed with restore"
|
||||||
|
},
|
||||||
|
"backupRestoring": "Restoring backup...",
|
||||||
|
"@backupRestoring": {
|
||||||
|
"description": "Progress text while restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestored": "Backup restored successfully",
|
||||||
|
"@backupRestored": {
|
||||||
|
"description": "Snackbar after a successful restore"
|
||||||
|
},
|
||||||
|
"backupRestoreFailed": "Failed to restore backup",
|
||||||
|
"@backupRestoreFailed": {
|
||||||
|
"description": "Snackbar when restore fails"
|
||||||
|
},
|
||||||
|
"backupInvalidFile": "This file is not a valid SpotiFLAC backup",
|
||||||
|
"@backupInvalidFile": {
|
||||||
|
"description": "Snackbar when the chosen file is not a valid backup"
|
||||||
|
},
|
||||||
|
"backupRestoreRestartHint": "Restart the app to make sure every change is applied.",
|
||||||
|
"@backupRestoreRestartHint": {
|
||||||
|
"description": "Hint shown after restoring that an app restart is recommended"
|
||||||
|
},
|
||||||
|
"backupContentsTitle": "Backup contents",
|
||||||
|
"@backupContentsTitle": {
|
||||||
|
"description": "Header above the list summarizing what the backup contains"
|
||||||
|
},
|
||||||
|
"backupContentsSettings": "App settings",
|
||||||
|
"@backupContentsSettings": {
|
||||||
|
"description": "Backup contents row label for settings"
|
||||||
|
},
|
||||||
|
"backupContentsHistory": "{count} history {count, plural, =1{item} other{items}}",
|
||||||
|
"@backupContentsHistory": {
|
||||||
|
"description": "Backup contents row for history count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsLiked": "{count} liked {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@backupContentsLiked": {
|
||||||
|
"description": "Backup contents row for liked tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsWishlist": "{count} wishlist {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@backupContentsWishlist": {
|
||||||
|
"description": "Backup contents row for wishlist tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlists}}",
|
||||||
|
"@backupContentsPlaylists": {
|
||||||
|
"description": "Backup contents row for playlist count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsArtists": "{count, plural, =1{1 favorite artist} other{{count} favorite artists}}",
|
||||||
|
"@backupContentsArtists": {
|
||||||
|
"description": "Backup contents row for favorite artists count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}",
|
||||||
|
"@backupContentsExtensions": {
|
||||||
|
"description": "Backup contents row for installed extensions count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupIncludeSecrets": "Include extension credentials",
|
||||||
|
"@backupIncludeSecrets": {
|
||||||
|
"description": "Toggle to include secret extension settings (tokens, API keys) in the backup"
|
||||||
|
},
|
||||||
|
"backupIncludeSecretsDescription": "Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.",
|
||||||
|
"@backupIncludeSecretsDescription": {
|
||||||
|
"description": "Explanation for the include-credentials toggle"
|
||||||
|
},
|
||||||
|
"backupExtensionsRestoreFailed": "{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.",
|
||||||
|
"@backupExtensionsRestoreFailed": {
|
||||||
|
"description": "Snackbar/hint when some extensions failed to reinstall during restore",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"tooltipLoveAll": "Love All",
|
"tooltipLoveAll": "Love All",
|
||||||
"@tooltipLoveAll": {
|
"@tooltipLoveAll": {
|
||||||
"description": "Tooltip for the Love All button on album/playlist screens"
|
"description": "Tooltip for the Love All button on album/playlist screens"
|
||||||
@@ -3983,6 +4162,18 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is off"
|
"description": "Subtitle when network compatibility mode is off"
|
||||||
},
|
},
|
||||||
|
"downloadAllowLocalNetwork": "Allow Local Network Access",
|
||||||
|
"@downloadAllowLocalNetwork": {
|
||||||
|
"description": "Setting title for allowing requests to private/local network targets"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkEnabled": "Requests to local/private addresses are allowed (for local proxy or custom DNS)",
|
||||||
|
"@downloadAllowLocalNetworkEnabled": {
|
||||||
|
"description": "Subtitle when allow local network access is on"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkDisabled": "Local/private addresses are blocked for security",
|
||||||
|
"@downloadAllowLocalNetworkDisabled": {
|
||||||
|
"description": "Subtitle when allow local network access is off"
|
||||||
|
},
|
||||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||||
@@ -5595,5 +5786,423 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"service": {}
|
"service": {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"libraryPlayback": "Playback",
|
||||||
|
"@libraryPlayback": {
|
||||||
|
"description": "Section header for playback settings in library settings"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayer": "External player",
|
||||||
|
"@libraryExternalPlayer": {
|
||||||
|
"description": "Setting option to use an external music player"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayerSubtitle": "Recommended for listening, best quality, gapless playback, EQ, and wider format support",
|
||||||
|
"@libraryExternalPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for external player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayer": "Built-in preview player",
|
||||||
|
"@libraryBuiltInPreviewPlayer": {
|
||||||
|
"description": "Setting option to use the built-in preview player"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayerSubtitle": "Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening",
|
||||||
|
"@libraryBuiltInPreviewPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for built-in preview player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPlayerInfo": "The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.",
|
||||||
|
"@libraryBuiltInPlayerInfo": {
|
||||||
|
"description": "Info note explaining the built-in player is for previews only"
|
||||||
|
},
|
||||||
|
"nowPlayingTitle": "Now Playing",
|
||||||
|
"@nowPlayingTitle": {
|
||||||
|
"description": "Title for the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingNothingPlaying": "Nothing is playing",
|
||||||
|
"@nowPlayingNothingPlaying": {
|
||||||
|
"description": "Empty state when no track is currently playing"
|
||||||
|
},
|
||||||
|
"nowPlayingMinimize": "Minimize",
|
||||||
|
"@nowPlayingMinimize": {
|
||||||
|
"description": "Tooltip for minimizing the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingUpNext": "Up next",
|
||||||
|
"@nowPlayingUpNext": {
|
||||||
|
"description": "Title for the playback queue sheet"
|
||||||
|
},
|
||||||
|
"nowPlayingDetails": "Details",
|
||||||
|
"@nowPlayingDetails": {
|
||||||
|
"description": "Menu item and section title for track metadata details"
|
||||||
|
},
|
||||||
|
"nowPlayingOpenInExternalPlayer": "Open in external player",
|
||||||
|
"@nowPlayingOpenInExternalPlayer": {
|
||||||
|
"description": "Menu item to open the current track in an external player"
|
||||||
|
},
|
||||||
|
"nowPlayingTabPlayer": "Player",
|
||||||
|
"@nowPlayingTabPlayer": {
|
||||||
|
"description": "Tab label for the player view"
|
||||||
|
},
|
||||||
|
"nowPlayingTabLyrics": "Lyrics",
|
||||||
|
"@nowPlayingTabLyrics": {
|
||||||
|
"description": "Tab label for the lyrics view"
|
||||||
|
},
|
||||||
|
"nowPlayingNoLyrics": "No lyrics in this file",
|
||||||
|
"@nowPlayingNoLyrics": {
|
||||||
|
"description": "Empty state when the playing file has no embedded lyrics"
|
||||||
|
},
|
||||||
|
"nowPlayingLibraryEmpty": "Your library is empty",
|
||||||
|
"@nowPlayingLibraryEmpty": {
|
||||||
|
"description": "Snackbar when shuffle library is requested but library has no tracks"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibraryFailed": "Could not shuffle library: {error}",
|
||||||
|
"@nowPlayingShuffleLibraryFailed": {
|
||||||
|
"description": "Snackbar when shuffling the library fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleOn": "Shuffle on",
|
||||||
|
"@nowPlayingShuffleOn": {
|
||||||
|
"description": "Tooltip when shuffle mode is enabled"
|
||||||
|
},
|
||||||
|
"nowPlayingPlayInOrder": "Play in order",
|
||||||
|
"@nowPlayingPlayInOrder": {
|
||||||
|
"description": "Tooltip when shuffle mode is disabled"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibrary": "Shuffle library",
|
||||||
|
"@nowPlayingShuffleLibrary": {
|
||||||
|
"description": "Button label to shuffle and play the entire local library"
|
||||||
|
},
|
||||||
|
"nowPlayingQueueEmpty": "Queue is empty",
|
||||||
|
"@nowPlayingQueueEmpty": {
|
||||||
|
"description": "Empty state when the playback queue has no items"
|
||||||
|
},
|
||||||
|
"nowPlayingNoMetadata": "No metadata available",
|
||||||
|
"@nowPlayingNoMetadata": {
|
||||||
|
"description": "Empty state when track metadata cannot be loaded"
|
||||||
|
},
|
||||||
|
"announcementUnableToOpenLink": "Unable to open link. Please try again.",
|
||||||
|
"@announcementUnableToOpenLink": {
|
||||||
|
"description": "Snackbar shown when an announcement CTA link cannot be opened"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessOutputWithCap": "Lossless output with {quality} cap",
|
||||||
|
"@trackConvertLosslessOutputWithCap": {
|
||||||
|
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
|
||||||
|
"placeholders": {
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConfirmMessageLosslessCapped": "Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless conversion of a single file",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessageLosslessCapped": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
|
||||||
|
"@trackConvertActionLabelLossless": {
|
||||||
|
"description": "Convert button label for lossless conversion with quality cap",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
|
||||||
|
"@trackConvertActionLabelLossy": {
|
||||||
|
"description": "Convert button label for lossy conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aboutPaxsenixSubtitle": "Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius",
|
||||||
|
"@aboutPaxsenixSubtitle": {
|
||||||
|
"description": "Subtitle for Paxsenix special thanks entry on the about page"
|
||||||
|
},
|
||||||
|
"snackbarPlayingNext": "Playing next",
|
||||||
|
"@snackbarPlayingNext": {
|
||||||
|
"description": "Snackbar when a track is inserted as the next queue item"
|
||||||
|
},
|
||||||
|
"snackbarAddedToQueueGeneric": "Added to queue",
|
||||||
|
"@snackbarAddedToQueueGeneric": {
|
||||||
|
"description": "Snackbar when a track is added to the playback queue without naming it"
|
||||||
|
},
|
||||||
|
"selectionDeletePlaylistsCount": "Delete {count} {count, plural, =1{playlist} other{playlists}}",
|
||||||
|
"@selectionDeletePlaylistsCount": {
|
||||||
|
"description": "Button label for deleting multiple selected playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionShuffle": "Shuffle",
|
||||||
|
"@actionShuffle": {
|
||||||
|
"description": "Tooltip for shuffle playback action"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOn": "Primary only: On",
|
||||||
|
"@downloadPrimaryArtistOnlyOn": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is enabled"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOff": "Primary only: Off",
|
||||||
|
"@downloadPrimaryArtistOnlyOff": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is disabled"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataPrimaryOnly": "Album Artist metadata: Primary only",
|
||||||
|
"@downloadAlbumArtistMetadataPrimaryOnly": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses primary artist only"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataFull": "Album Artist metadata: Full",
|
||||||
|
"@downloadAlbumArtistMetadataFull": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses full metadata"
|
||||||
|
},
|
||||||
|
"trackConvertOriginal": "Original",
|
||||||
|
"@trackConvertOriginal": {
|
||||||
|
"description": "Label for keeping original bit depth or sample rate during conversion"
|
||||||
|
},
|
||||||
|
"trackConvertOriginalQuality": "Original quality",
|
||||||
|
"@trackConvertOriginalQuality": {
|
||||||
|
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessSuffix": "Lossless",
|
||||||
|
"@trackConvertLosslessSuffix": {
|
||||||
|
"description": "Suffix used in converted lossless quality labels"
|
||||||
|
},
|
||||||
|
"trackConvertDithering": "Dithering",
|
||||||
|
"@trackConvertDithering": {
|
||||||
|
"description": "Section label for lossless conversion dithering options"
|
||||||
|
},
|
||||||
|
"trackConvertResampler": "Resampler",
|
||||||
|
"@trackConvertResampler": {
|
||||||
|
"description": "Section label for lossless conversion resampler options"
|
||||||
|
},
|
||||||
|
"trackConvertDitherNone": "None",
|
||||||
|
"@trackConvertDitherNone": {
|
||||||
|
"description": "Lossless conversion dither option with no dithering applied"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangular": "TPDF",
|
||||||
|
"@trackConvertDitherTriangular": {
|
||||||
|
"description": "Lossless conversion triangular probability density function dither option"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangularHp": "Triangular HP",
|
||||||
|
"@trackConvertDitherTriangularHp": {
|
||||||
|
"description": "Lossless conversion high-pass triangular dither option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSwr": "SWR",
|
||||||
|
"@trackConvertResamplerSwr": {
|
||||||
|
"description": "Lossless conversion default FFmpeg swresample resampler option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSoxr": "SoXr",
|
||||||
|
"@trackConvertResamplerSoxr": {
|
||||||
|
"description": "Lossless conversion SoX resampler option"
|
||||||
|
},
|
||||||
|
"updateSeeReleaseNotes": "See release notes for details.",
|
||||||
|
"@updateSeeReleaseNotes": {
|
||||||
|
"description": "Fallback changelog text when release notes cannot be parsed"
|
||||||
|
},
|
||||||
|
"unknownTitle": "Unknown title",
|
||||||
|
"@unknownTitle": {
|
||||||
|
"description": "Fallback track title when metadata is missing"
|
||||||
|
},
|
||||||
|
"trackPlayNext": "Play next",
|
||||||
|
"@trackPlayNext": {
|
||||||
|
"description": "Menu action to play a track as the next queue item"
|
||||||
|
},
|
||||||
|
"trackAddToQueue": "Add to queue",
|
||||||
|
"@trackAddToQueue": {
|
||||||
|
"description": "Menu action to add a track to the playback queue"
|
||||||
|
},
|
||||||
|
"snackbarExtensionInstalledEnable": "{extensionName} installed. Enable it in Settings > Extensions",
|
||||||
|
"@snackbarExtensionInstalledEnable": {
|
||||||
|
"description": "Snackbar after installing an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarExtensionUpdatedVersion": "{extensionName} updated to v{version}",
|
||||||
|
"@snackbarExtensionUpdatedVersion": {
|
||||||
|
"description": "Snackbar after updating an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToInstallNamed": "Failed to install {extensionName}",
|
||||||
|
"@snackbarFailedToInstallNamed": {
|
||||||
|
"description": "Snackbar when extension install fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToUpdateNamed": "Failed to update {extensionName}",
|
||||||
|
"@snackbarFailedToUpdateNamed": {
|
||||||
|
"description": "Snackbar when extension update fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"releaseTypeEp": "EP",
|
||||||
|
"@releaseTypeEp": {
|
||||||
|
"description": "Badge label for EP releases"
|
||||||
|
},
|
||||||
|
"releaseTypeSingle": "Single",
|
||||||
|
"@releaseTypeSingle": {
|
||||||
|
"description": "Badge label for single releases"
|
||||||
|
},
|
||||||
|
"trackCoverOnline": "Online cover",
|
||||||
|
"@trackCoverOnline": {
|
||||||
|
"description": "Label shown when metadata autofill downloaded cover art from the internet"
|
||||||
|
},
|
||||||
|
"regionCountryUS": "United States",
|
||||||
|
"@regionCountryUS": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryGB": "United Kingdom",
|
||||||
|
"@regionCountryGB": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryFR": "France",
|
||||||
|
"@regionCountryFR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryDE": "Germany",
|
||||||
|
"@regionCountryDE": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryJP": "Japan",
|
||||||
|
"@regionCountryJP": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryKR": "South Korea",
|
||||||
|
"@regionCountryKR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryIN": "India",
|
||||||
|
"@regionCountryIN": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryID": "Indonesia",
|
||||||
|
"@regionCountryID": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryBR": "Brazil",
|
||||||
|
"@regionCountryBR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryMX": "Mexico",
|
||||||
|
"@regionCountryMX": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryAU": "Australia",
|
||||||
|
"@regionCountryAU": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryCA": "Canada",
|
||||||
|
"@regionCountryCA": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryXK": "Kosovo",
|
||||||
|
"@regionCountryXK": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserTitle": "Verification browser",
|
||||||
|
"@extensionVerificationBrowserTitle": {
|
||||||
|
"description": "Settings option title for extension verification browser preference"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleExternal": "Open challenges in the default browser first",
|
||||||
|
"@extensionVerificationBrowserSubtitleExternal": {
|
||||||
|
"description": "Subtitle when external browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleInApp": "Open challenges in the in-app browser first",
|
||||||
|
"@extensionVerificationBrowserSubtitleInApp": {
|
||||||
|
"description": "Subtitle when in-app browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserExternal": "External",
|
||||||
|
"@extensionVerificationBrowserExternal": {
|
||||||
|
"description": "Chip label for external browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserInApp": "In-app",
|
||||||
|
"@extensionVerificationBrowserInApp": {
|
||||||
|
"description": "Chip label for in-app browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleManual": "Open verification manually",
|
||||||
|
"@extensionVerificationHelpTitleManual": {
|
||||||
|
"description": "Dialog title when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleWaiting": "Verification still waiting",
|
||||||
|
"@extensionVerificationHelpTitleWaiting": {
|
||||||
|
"description": "Dialog title when verification is taking longer than expected"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.",
|
||||||
|
"@extensionVerificationHelpMessageManual": {
|
||||||
|
"description": "Dialog message when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageWaiting": "If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.",
|
||||||
|
"@extensionVerificationHelpMessageWaiting": {
|
||||||
|
"description": "Dialog message when verification may need manual browser help"
|
||||||
|
},
|
||||||
|
"extensionVerificationClose": "Close",
|
||||||
|
"@extensionVerificationClose": {
|
||||||
|
"description": "Button to dismiss the extension verification help dialog"
|
||||||
|
},
|
||||||
|
"extensionVerificationCopyLink": "Copy link",
|
||||||
|
"@extensionVerificationCopyLink": {
|
||||||
|
"description": "Button to copy the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationLinkCopied": "Verification link copied",
|
||||||
|
"@extensionVerificationLinkCopied": {
|
||||||
|
"description": "Snackbar after copying the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationOpenBrowser": "Open browser",
|
||||||
|
"@extensionVerificationOpenBrowser": {
|
||||||
|
"description": "Button to open the extension verification URL in a browser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1592,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Elige cómo se estructuran las carpetas de los álbumes",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Usar álbum de artista cómo carpeta",
|
"downloadUseAlbumArtistForFolders": "Usar álbum de artista cómo carpeta",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choisir la structure des dossiers d'album",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Utilisez l'artiste de l'album pour les dossiers",
|
"downloadUseAlbumArtistForFolders": "Utilisez l'artiste de l'album pour les dossiers",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1800,6 +1800,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Pilih struktur folder album",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
@@ -3689,6 +3693,87 @@
|
|||||||
"@settingsDonateSubtitle": {
|
"@settingsDonateSubtitle": {
|
||||||
"description": "Subtitle for donate menu item"
|
"description": "Subtitle for donate menu item"
|
||||||
},
|
},
|
||||||
|
"settingsBackup": "Cadangkan & Pulihkan",
|
||||||
|
"settingsBackupSubtitle": "Pindahkan pustaka, riwayat, dan pengaturan ke perangkat baru",
|
||||||
|
"backupTitle": "Cadangkan & Pulihkan",
|
||||||
|
"backupExportSectionTitle": "Buat cadangan",
|
||||||
|
"backupExportSectionDescription": "Simpan pengaturan, riwayat unduhan, lagu disukai, wishlist, artis favorit, dan playlist ke dalam satu file yang bisa kamu simpan atau pindahkan ke ponsel lain.",
|
||||||
|
"backupExportButton": "Buat file cadangan",
|
||||||
|
"backupImportSectionTitle": "Pulihkan cadangan",
|
||||||
|
"backupImportSectionDescription": "Pilih file cadangan untuk memulihkan data. Ini akan menggantikan pengaturan, riwayat, dan pustaka di perangkat ini.",
|
||||||
|
"backupImportButton": "Pilih file cadangan",
|
||||||
|
"backupCreating": "Membuat cadangan...",
|
||||||
|
"backupCreated": "Cadangan berhasil dibuat",
|
||||||
|
"backupCreateFailed": "Gagal membuat cadangan",
|
||||||
|
"backupEmpty": "Belum ada data untuk dicadangkan",
|
||||||
|
"backupRestoreConfirmTitle": "Pulihkan cadangan ini?",
|
||||||
|
"backupRestoreConfirmMessage": "Ini akan menggantikan pengaturan, riwayat unduhan, lagu disukai, wishlist, dan playlist saat ini dengan isi cadangan. Tindakan ini tidak bisa dibatalkan.",
|
||||||
|
"backupRestoreConfirmButton": "Pulihkan",
|
||||||
|
"backupRestoring": "Memulihkan cadangan...",
|
||||||
|
"backupRestored": "Cadangan berhasil dipulihkan",
|
||||||
|
"backupRestoreFailed": "Gagal memulihkan cadangan",
|
||||||
|
"backupInvalidFile": "File ini bukan cadangan SpotiFLAC yang valid",
|
||||||
|
"backupRestoreRestartHint": "Mulai ulang aplikasi untuk memastikan semua perubahan diterapkan.",
|
||||||
|
"backupContentsTitle": "Isi cadangan",
|
||||||
|
"backupContentsSettings": "Pengaturan aplikasi",
|
||||||
|
"backupContentsHistory": "{count} item riwayat",
|
||||||
|
"@backupContentsHistory": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsLiked": "{count} lagu disukai",
|
||||||
|
"@backupContentsLiked": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsWishlist": "{count} lagu di wishlist",
|
||||||
|
"@backupContentsWishlist": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlist}}",
|
||||||
|
"@backupContentsPlaylists": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsArtists": "{count, plural, =1{1 artis favorit} other{{count} artis favorit}}",
|
||||||
|
"@backupContentsArtists": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extension}}",
|
||||||
|
"@backupContentsExtensions": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupIncludeSecrets": "Sertakan kredensial extension",
|
||||||
|
"backupIncludeSecretsDescription": "Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.",
|
||||||
|
"backupExtensionsRestoreFailed": "{count} extension gagal dipasang ulang. Pasang manual dari store.",
|
||||||
|
"@backupExtensionsRestoreFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"tooltipLoveAll": "Love All",
|
"tooltipLoveAll": "Love All",
|
||||||
"@tooltipLoveAll": {
|
"@tooltipLoveAll": {
|
||||||
"description": "Tooltip for the Love All button on album/playlist screens"
|
"description": "Tooltip for the Love All button on album/playlist screens"
|
||||||
@@ -3829,6 +3914,18 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is disabled"
|
"description": "Subtitle when network compatibility mode is disabled"
|
||||||
},
|
},
|
||||||
|
"downloadAllowLocalNetwork": "Izinkan Akses Jaringan Lokal",
|
||||||
|
"@downloadAllowLocalNetwork": {
|
||||||
|
"description": "Setting title for allowing requests to private/local network targets"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkEnabled": "Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)",
|
||||||
|
"@downloadAllowLocalNetworkEnabled": {
|
||||||
|
"description": "Subtitle when allow local network access is on"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkDisabled": "Alamat lokal/privat diblokir demi keamanan",
|
||||||
|
"@downloadAllowLocalNetworkDisabled": {
|
||||||
|
"description": "Subtitle when allow local network access is off"
|
||||||
|
},
|
||||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Hint shown instead of Ask-quality subtitle when selected provider has no quality options"
|
"description": "Hint shown instead of Ask-quality subtitle when selected provider has no quality options"
|
||||||
@@ -5533,5 +5630,423 @@
|
|||||||
"artistReleases": "Releases",
|
"artistReleases": "Releases",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Section header for all artist releases"
|
"description": "Section header for all artist releases"
|
||||||
|
},
|
||||||
|
"libraryPlayback": "Pemutaran",
|
||||||
|
"@libraryPlayback": {
|
||||||
|
"description": "Section header for playback settings in library settings"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayer": "Pemutar eksternal",
|
||||||
|
"@libraryExternalPlayer": {
|
||||||
|
"description": "Setting option to use an external music player"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayerSubtitle": "Disarankan untuk mendengarkan, kualitas terbaik, pemutaran tanpa jeda, EQ, dan dukungan format lebih luas",
|
||||||
|
"@libraryExternalPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for external player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayer": "Pemutar pratinjau bawaan",
|
||||||
|
"@libraryBuiltInPreviewPlayer": {
|
||||||
|
"description": "Setting option to use the built-in preview player"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayerSubtitle": "Hanya untuk pratinjau lokal cepat di dalam SpotiFLAC Mobile, tidak disarankan untuk mendengarkan secara rutin",
|
||||||
|
"@libraryBuiltInPreviewPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for built-in preview player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPlayerInfo": "Pemutar bawaan adalah alat pratinjau untuk memeriksa trek lokal dengan cepat. Gunakan pemutar musik eksternal untuk mendengarkan sebenarnya.",
|
||||||
|
"@libraryBuiltInPlayerInfo": {
|
||||||
|
"description": "Info note explaining the built-in player is for previews only"
|
||||||
|
},
|
||||||
|
"nowPlayingTitle": "Sedang Diputar",
|
||||||
|
"@nowPlayingTitle": {
|
||||||
|
"description": "Title for the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingNothingPlaying": "Tidak ada yang diputar",
|
||||||
|
"@nowPlayingNothingPlaying": {
|
||||||
|
"description": "Empty state when no track is currently playing"
|
||||||
|
},
|
||||||
|
"nowPlayingMinimize": "Minimalkan",
|
||||||
|
"@nowPlayingMinimize": {
|
||||||
|
"description": "Tooltip for minimizing the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingUpNext": "Berikutnya",
|
||||||
|
"@nowPlayingUpNext": {
|
||||||
|
"description": "Title for the playback queue sheet"
|
||||||
|
},
|
||||||
|
"nowPlayingDetails": "Detail",
|
||||||
|
"@nowPlayingDetails": {
|
||||||
|
"description": "Menu item and section title for track metadata details"
|
||||||
|
},
|
||||||
|
"nowPlayingOpenInExternalPlayer": "Buka di pemutar eksternal",
|
||||||
|
"@nowPlayingOpenInExternalPlayer": {
|
||||||
|
"description": "Menu item to open the current track in an external player"
|
||||||
|
},
|
||||||
|
"nowPlayingTabPlayer": "Pemutar",
|
||||||
|
"@nowPlayingTabPlayer": {
|
||||||
|
"description": "Tab label for the player view"
|
||||||
|
},
|
||||||
|
"nowPlayingTabLyrics": "Lirik",
|
||||||
|
"@nowPlayingTabLyrics": {
|
||||||
|
"description": "Tab label for the lyrics view"
|
||||||
|
},
|
||||||
|
"nowPlayingNoLyrics": "Tidak ada lirik di file ini",
|
||||||
|
"@nowPlayingNoLyrics": {
|
||||||
|
"description": "Empty state when the playing file has no embedded lyrics"
|
||||||
|
},
|
||||||
|
"nowPlayingLibraryEmpty": "Perpustakaan Anda kosong",
|
||||||
|
"@nowPlayingLibraryEmpty": {
|
||||||
|
"description": "Snackbar when shuffle library is requested but library has no tracks"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibraryFailed": "Tidak dapat mengacak perpustakaan: {error}",
|
||||||
|
"@nowPlayingShuffleLibraryFailed": {
|
||||||
|
"description": "Snackbar when shuffling the library fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleOn": "Acak aktif",
|
||||||
|
"@nowPlayingShuffleOn": {
|
||||||
|
"description": "Tooltip when shuffle mode is enabled"
|
||||||
|
},
|
||||||
|
"nowPlayingPlayInOrder": "Putar berurutan",
|
||||||
|
"@nowPlayingPlayInOrder": {
|
||||||
|
"description": "Tooltip when shuffle mode is disabled"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibrary": "Acak perpustakaan",
|
||||||
|
"@nowPlayingShuffleLibrary": {
|
||||||
|
"description": "Button label to shuffle and play the entire local library"
|
||||||
|
},
|
||||||
|
"nowPlayingQueueEmpty": "Antrean kosong",
|
||||||
|
"@nowPlayingQueueEmpty": {
|
||||||
|
"description": "Empty state when the playback queue has no items"
|
||||||
|
},
|
||||||
|
"nowPlayingNoMetadata": "Metadata tidak tersedia",
|
||||||
|
"@nowPlayingNoMetadata": {
|
||||||
|
"description": "Empty state when track metadata cannot be loaded"
|
||||||
|
},
|
||||||
|
"announcementUnableToOpenLink": "Tidak dapat membuka tautan. Silakan coba lagi.",
|
||||||
|
"@announcementUnableToOpenLink": {
|
||||||
|
"description": "Snackbar shown when an announcement CTA link cannot be opened"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessOutputWithCap": "Output lossless dengan batas {quality}",
|
||||||
|
"@trackConvertLosslessOutputWithCap": {
|
||||||
|
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
|
||||||
|
"placeholders": {
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConfirmMessageLosslessCapped": "Konversi dari {sourceFormat} ke {targetFormat} ({quality})?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.",
|
||||||
|
"@trackConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless conversion of a single file",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessageLosslessCapped": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} ({quality})?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.",
|
||||||
|
"@selectionBatchConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
|
||||||
|
"@trackConvertActionLabelLossless": {
|
||||||
|
"description": "Convert button label for lossless conversion with quality cap",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
|
||||||
|
"@trackConvertActionLabelLossy": {
|
||||||
|
"description": "Convert button label for lossy conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aboutPaxsenixSubtitle": "Proxy lirik untuk Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, dan Genius",
|
||||||
|
"@aboutPaxsenixSubtitle": {
|
||||||
|
"description": "Subtitle for Paxsenix special thanks entry on the about page"
|
||||||
|
},
|
||||||
|
"snackbarPlayingNext": "Memutar berikutnya",
|
||||||
|
"@snackbarPlayingNext": {
|
||||||
|
"description": "Snackbar when a track is inserted as the next queue item"
|
||||||
|
},
|
||||||
|
"snackbarAddedToQueueGeneric": "Ditambahkan ke antrean",
|
||||||
|
"@snackbarAddedToQueueGeneric": {
|
||||||
|
"description": "Snackbar when a track is added to the playback queue without naming it"
|
||||||
|
},
|
||||||
|
"selectionDeletePlaylistsCount": "Hapus {count} {count, plural, =1{playlist} other{playlist}}",
|
||||||
|
"@selectionDeletePlaylistsCount": {
|
||||||
|
"description": "Button label for deleting multiple selected playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionShuffle": "Acak",
|
||||||
|
"@actionShuffle": {
|
||||||
|
"description": "Tooltip for shuffle playback action"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOn": "Hanya utama: Aktif",
|
||||||
|
"@downloadPrimaryArtistOnlyOn": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is enabled"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOff": "Hanya utama: Nonaktif",
|
||||||
|
"@downloadPrimaryArtistOnlyOff": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is disabled"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataPrimaryOnly": "Metadata Album Artist: Hanya utama",
|
||||||
|
"@downloadAlbumArtistMetadataPrimaryOnly": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses primary artist only"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataFull": "Metadata Album Artist: Lengkap",
|
||||||
|
"@downloadAlbumArtistMetadataFull": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses full metadata"
|
||||||
|
},
|
||||||
|
"trackConvertOriginal": "Asli",
|
||||||
|
"@trackConvertOriginal": {
|
||||||
|
"description": "Label for keeping original bit depth or sample rate during conversion"
|
||||||
|
},
|
||||||
|
"trackConvertOriginalQuality": "Kualitas asli",
|
||||||
|
"@trackConvertOriginalQuality": {
|
||||||
|
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessSuffix": "Lossless",
|
||||||
|
"@trackConvertLosslessSuffix": {
|
||||||
|
"description": "Suffix used in converted lossless quality labels"
|
||||||
|
},
|
||||||
|
"trackConvertDithering": "Dithering",
|
||||||
|
"@trackConvertDithering": {
|
||||||
|
"description": "Section label for lossless conversion dithering options"
|
||||||
|
},
|
||||||
|
"trackConvertResampler": "Resampler",
|
||||||
|
"@trackConvertResampler": {
|
||||||
|
"description": "Section label for lossless conversion resampler options"
|
||||||
|
},
|
||||||
|
"trackConvertDitherNone": "Tidak ada",
|
||||||
|
"@trackConvertDitherNone": {
|
||||||
|
"description": "Lossless conversion dither option with no dithering applied"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangular": "TPDF",
|
||||||
|
"@trackConvertDitherTriangular": {
|
||||||
|
"description": "Lossless conversion triangular probability density function dither option"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangularHp": "Triangular HP",
|
||||||
|
"@trackConvertDitherTriangularHp": {
|
||||||
|
"description": "Lossless conversion high-pass triangular dither option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSwr": "SWR",
|
||||||
|
"@trackConvertResamplerSwr": {
|
||||||
|
"description": "Lossless conversion default FFmpeg swresample resampler option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSoxr": "SoXr",
|
||||||
|
"@trackConvertResamplerSoxr": {
|
||||||
|
"description": "Lossless conversion SoX resampler option"
|
||||||
|
},
|
||||||
|
"updateSeeReleaseNotes": "Lihat catatan rilis untuk detail.",
|
||||||
|
"@updateSeeReleaseNotes": {
|
||||||
|
"description": "Fallback changelog text when release notes cannot be parsed"
|
||||||
|
},
|
||||||
|
"unknownTitle": "Judul tidak diketahui",
|
||||||
|
"@unknownTitle": {
|
||||||
|
"description": "Fallback track title when metadata is missing"
|
||||||
|
},
|
||||||
|
"trackPlayNext": "Putar berikutnya",
|
||||||
|
"@trackPlayNext": {
|
||||||
|
"description": "Menu action to play a track as the next queue item"
|
||||||
|
},
|
||||||
|
"trackAddToQueue": "Tambah ke antrean",
|
||||||
|
"@trackAddToQueue": {
|
||||||
|
"description": "Menu action to add a track to the playback queue"
|
||||||
|
},
|
||||||
|
"snackbarExtensionInstalledEnable": "{extensionName} terpasang. Aktifkan di Pengaturan > Ekstensi",
|
||||||
|
"@snackbarExtensionInstalledEnable": {
|
||||||
|
"description": "Snackbar after installing an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarExtensionUpdatedVersion": "{extensionName} diperbarui ke v{version}",
|
||||||
|
"@snackbarExtensionUpdatedVersion": {
|
||||||
|
"description": "Snackbar after updating an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToInstallNamed": "Gagal memasang {extensionName}",
|
||||||
|
"@snackbarFailedToInstallNamed": {
|
||||||
|
"description": "Snackbar when extension install fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToUpdateNamed": "Gagal memperbarui {extensionName}",
|
||||||
|
"@snackbarFailedToUpdateNamed": {
|
||||||
|
"description": "Snackbar when extension update fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"releaseTypeEp": "EP",
|
||||||
|
"@releaseTypeEp": {
|
||||||
|
"description": "Badge label for EP releases"
|
||||||
|
},
|
||||||
|
"releaseTypeSingle": "Single",
|
||||||
|
"@releaseTypeSingle": {
|
||||||
|
"description": "Badge label for single releases"
|
||||||
|
},
|
||||||
|
"trackCoverOnline": "Sampul daring",
|
||||||
|
"@trackCoverOnline": {
|
||||||
|
"description": "Label shown when metadata autofill downloaded cover art from the internet"
|
||||||
|
},
|
||||||
|
"regionCountryUS": "Amerika Serikat",
|
||||||
|
"@regionCountryUS": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryGB": "Britania Raya",
|
||||||
|
"@regionCountryGB": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryFR": "Prancis",
|
||||||
|
"@regionCountryFR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryDE": "Jerman",
|
||||||
|
"@regionCountryDE": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryJP": "Jepang",
|
||||||
|
"@regionCountryJP": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryKR": "Korea Selatan",
|
||||||
|
"@regionCountryKR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryIN": "India",
|
||||||
|
"@regionCountryIN": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryID": "Indonesia",
|
||||||
|
"@regionCountryID": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryBR": "Brasil",
|
||||||
|
"@regionCountryBR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryMX": "Meksiko",
|
||||||
|
"@regionCountryMX": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryAU": "Australia",
|
||||||
|
"@regionCountryAU": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryCA": "Kanada",
|
||||||
|
"@regionCountryCA": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryXK": "Kosovo",
|
||||||
|
"@regionCountryXK": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserTitle": "Browser verifikasi",
|
||||||
|
"@extensionVerificationBrowserTitle": {
|
||||||
|
"description": "Settings option title for extension verification browser preference"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleExternal": "Buka tantangan di browser default terlebih dahulu",
|
||||||
|
"@extensionVerificationBrowserSubtitleExternal": {
|
||||||
|
"description": "Subtitle when external browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleInApp": "Buka tantangan di browser dalam aplikasi terlebih dahulu",
|
||||||
|
"@extensionVerificationBrowserSubtitleInApp": {
|
||||||
|
"description": "Subtitle when in-app browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserExternal": "Eksternal",
|
||||||
|
"@extensionVerificationBrowserExternal": {
|
||||||
|
"description": "Chip label for external browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserInApp": "Dalam aplikasi",
|
||||||
|
"@extensionVerificationBrowserInApp": {
|
||||||
|
"description": "Chip label for in-app browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleManual": "Buka verifikasi secara manual",
|
||||||
|
"@extensionVerificationHelpTitleManual": {
|
||||||
|
"description": "Dialog title when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleWaiting": "Verifikasi masih menunggu",
|
||||||
|
"@extensionVerificationHelpTitleWaiting": {
|
||||||
|
"description": "Dialog title when verification is taking longer than expected"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.",
|
||||||
|
"@extensionVerificationHelpMessageManual": {
|
||||||
|
"description": "Dialog message when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageWaiting": "Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.",
|
||||||
|
"@extensionVerificationHelpMessageWaiting": {
|
||||||
|
"description": "Dialog message when verification may need manual browser help"
|
||||||
|
},
|
||||||
|
"extensionVerificationClose": "Tutup",
|
||||||
|
"@extensionVerificationClose": {
|
||||||
|
"description": "Button to dismiss the extension verification help dialog"
|
||||||
|
},
|
||||||
|
"extensionVerificationCopyLink": "Salin tautan",
|
||||||
|
"@extensionVerificationCopyLink": {
|
||||||
|
"description": "Button to copy the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationLinkCopied": "Tautan verifikasi disalin",
|
||||||
|
"@extensionVerificationLinkCopied": {
|
||||||
|
"description": "Snackbar after copying the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationOpenBrowser": "Buka browser",
|
||||||
|
"@extensionVerificationOpenBrowser": {
|
||||||
|
"description": "Button to open the extension verification URL in a browser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1756,6 +1756,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "アルバムフォルダの構成を選択",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1592,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Escolher a estrutura das pastas dos álbuns",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Выберите структуру папок альбомов",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Использовать исполнителя альбома для папок",
|
"downloadUseAlbumArtistForFolders": "Использовать исполнителя альбома для папок",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1892,6 +1892,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Albüm klasör yapısını seçin",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Klasörler için Albüm Sanatçısı'nı kullan",
|
"downloadUseAlbumArtistForFolders": "Klasörler için Albüm Sanatçısı'nı kullan",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Виберіть структуру папок альбомів",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Використовувати виконавця альбому для папок",
|
"downloadUseAlbumArtistForFolders": "Використовувати виконавця альбому для папок",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1592,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -1980,6 +1980,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound,
|
||||||
|
rateLimit,
|
||||||
|
network,
|
||||||
|
permission,
|
||||||
|
verificationRequired,
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
@@ -22,14 +29,15 @@ class DownloadItem {
|
|||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
final double speedMBps;
|
final double speedMBps;
|
||||||
final int bytesReceived; // Bytes downloaded so far
|
final int bytesReceived;
|
||||||
final int bytesTotal; // Total bytes when the server provides content length
|
final int bytesTotal; // Total bytes when the server provides content length
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride;
|
||||||
final String? playlistName; // Playlist context for folder organization
|
final String? playlistName;
|
||||||
|
final int? playlistPosition; // 1-based position in the source playlist
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -46,6 +54,7 @@ class DownloadItem {
|
|||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.playlistPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -63,6 +72,7 @@ class DownloadItem {
|
|||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
int? playlistPosition,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -79,6 +89,7 @@ class DownloadItem {
|
|||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
playlistPosition: playlistPosition ?? this.playlistPosition,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +105,8 @@ class DownloadItem {
|
|||||||
return 'Connection failed, check your internet';
|
return 'Connection failed, check your internet';
|
||||||
case DownloadErrorType.permission:
|
case DownloadErrorType.permission:
|
||||||
return 'Cannot write to folder, check storage permission';
|
return 'Cannot write to folder, check storage permission';
|
||||||
|
case DownloadErrorType.verificationRequired:
|
||||||
|
return 'Verification required. Open the extension and complete the security check.';
|
||||||
default:
|
default:
|
||||||
return error ?? 'An error occurred';
|
return error ?? 'An error occurred';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
playlistName: json['playlistName'] as String?,
|
playlistName: json['playlistName'] as String?,
|
||||||
|
playlistPosition: (json['playlistPosition'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
@@ -41,6 +42,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'qualityOverride': instance.qualityOverride,
|
'qualityOverride': instance.qualityOverride,
|
||||||
'playlistName': instance.playlistName,
|
'playlistName': instance.playlistName,
|
||||||
|
'playlistPosition': instance.playlistPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
@@ -58,4 +60,5 @@ const _$DownloadErrorTypeEnumMap = {
|
|||||||
DownloadErrorType.rateLimit: 'rateLimit',
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
DownloadErrorType.network: 'network',
|
DownloadErrorType.network: 'network',
|
||||||
DownloadErrorType.permission: 'permission',
|
DownloadErrorType.permission: 'permission',
|
||||||
|
DownloadErrorType.verificationRequired: 'verificationRequired',
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-15
@@ -15,11 +15,11 @@ class AppSettings {
|
|||||||
final String storageMode; // 'app' or 'saf'
|
final String storageMode; // 'app' or 'saf'
|
||||||
final String downloadTreeUri; // SAF persistable tree URI
|
final String downloadTreeUri; // SAF persistable tree URI
|
||||||
final bool autoFallback;
|
final bool autoFallback;
|
||||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
final bool embedMetadata;
|
||||||
final String
|
final String
|
||||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
final bool embedReplayGain;
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final bool checkForUpdates;
|
final bool checkForUpdates;
|
||||||
@@ -43,37 +43,37 @@ class AppSettings {
|
|||||||
final String singleFilenameFormat;
|
final String singleFilenameFormat;
|
||||||
final String albumFolderStructure;
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
|
final String
|
||||||
|
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
|
||||||
final String locale;
|
final String locale;
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||||
final bool
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool autoExportFailedDownloads;
|
||||||
autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
|
||||||
final String
|
final String
|
||||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||||
final bool
|
final bool
|
||||||
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
||||||
|
final bool
|
||||||
|
allowLocalNetwork; // Allow requests to private/local network targets (local proxy / custom DNS)
|
||||||
final String
|
final String
|
||||||
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||||
final bool
|
final bool
|
||||||
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
|
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
|
||||||
|
|
||||||
final bool localLibraryEnabled; // Enable local library scanning
|
final bool localLibraryEnabled;
|
||||||
final String localLibraryPath; // Path to scan for audio files
|
final String localLibraryPath;
|
||||||
final String
|
final String
|
||||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||||
final bool
|
final bool localLibraryShowDuplicates;
|
||||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
|
||||||
final String
|
final String
|
||||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||||
|
|
||||||
final bool
|
final bool hasCompletedTutorial;
|
||||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
|
||||||
|
|
||||||
final List<String>
|
final List<String> lyricsProviders;
|
||||||
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
|
||||||
final bool
|
final bool
|
||||||
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
||||||
final bool
|
final bool
|
||||||
@@ -88,9 +88,10 @@ class AppSettings {
|
|||||||
final String
|
final String
|
||||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||||
|
|
||||||
final bool
|
final bool deduplicateDownloads;
|
||||||
deduplicateDownloads; // Skip downloading tracks already present in history
|
final bool saveDownloadHistory;
|
||||||
final bool saveDownloadHistory; // Record completed downloads in local history
|
|
||||||
|
final String playerMode;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = '',
|
this.defaultService = '',
|
||||||
@@ -128,6 +129,7 @@ class AppSettings {
|
|||||||
this.singleFilenameFormat = '{title} - {artist}',
|
this.singleFilenameFormat = '{title} - {artist}',
|
||||||
this.albumFolderStructure = 'artist_album',
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
|
this.extensionVerificationBrowserMode = 'in_app_first',
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
@@ -135,6 +137,7 @@ class AppSettings {
|
|||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
this.networkCompatibilityMode = false,
|
this.networkCompatibilityMode = false,
|
||||||
|
this.allowLocalNetwork = false,
|
||||||
this.songLinkRegion = 'US',
|
this.songLinkRegion = 'US',
|
||||||
this.nativeDownloadWorkerEnabled = false,
|
this.nativeDownloadWorkerEnabled = false,
|
||||||
this.localLibraryEnabled = false,
|
this.localLibraryEnabled = false,
|
||||||
@@ -152,6 +155,7 @@ class AppSettings {
|
|||||||
this.lastSeenVersion = '',
|
this.lastSeenVersion = '',
|
||||||
this.deduplicateDownloads = true,
|
this.deduplicateDownloads = true,
|
||||||
this.saveDownloadHistory = true,
|
this.saveDownloadHistory = true,
|
||||||
|
this.playerMode = 'external',
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -193,6 +197,7 @@ class AppSettings {
|
|||||||
String? singleFilenameFormat,
|
String? singleFilenameFormat,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
|
String? extensionVerificationBrowserMode,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
@@ -200,6 +205,7 @@ class AppSettings {
|
|||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
bool? networkCompatibilityMode,
|
bool? networkCompatibilityMode,
|
||||||
|
bool? allowLocalNetwork,
|
||||||
String? songLinkRegion,
|
String? songLinkRegion,
|
||||||
bool? nativeDownloadWorkerEnabled,
|
bool? nativeDownloadWorkerEnabled,
|
||||||
bool? localLibraryEnabled,
|
bool? localLibraryEnabled,
|
||||||
@@ -217,6 +223,7 @@ class AppSettings {
|
|||||||
String? lastSeenVersion,
|
String? lastSeenVersion,
|
||||||
bool? deduplicateDownloads,
|
bool? deduplicateDownloads,
|
||||||
bool? saveDownloadHistory,
|
bool? saveDownloadHistory,
|
||||||
|
String? playerMode,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -266,6 +273,9 @@ class AppSettings {
|
|||||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
extensionVerificationBrowserMode ??
|
||||||
|
this.extensionVerificationBrowserMode,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
@@ -275,6 +285,7 @@ class AppSettings {
|
|||||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||||
networkCompatibilityMode:
|
networkCompatibilityMode:
|
||||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||||
|
allowLocalNetwork: allowLocalNetwork ?? this.allowLocalNetwork,
|
||||||
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||||
nativeDownloadWorkerEnabled:
|
nativeDownloadWorkerEnabled:
|
||||||
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
|
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
|
||||||
@@ -300,6 +311,7 @@ class AppSettings {
|
|||||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||||
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
|
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
|
||||||
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
||||||
|
playerMode: playerMode ?? this.playerMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
albumFolderStructure:
|
albumFolderStructure:
|
||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
|
||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
@@ -56,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||||
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
||||||
|
allowLocalNetwork: json['allowLocalNetwork'] as bool? ?? false,
|
||||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||||
nativeDownloadWorkerEnabled:
|
nativeDownloadWorkerEnabled:
|
||||||
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
|
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
|
||||||
@@ -82,6 +85,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||||
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
||||||
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
||||||
|
playerMode: json['playerMode'] as String? ?? 'external',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(
|
Map<String, dynamic> _$AppSettingsToJson(
|
||||||
@@ -123,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
|
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
@@ -130,6 +135,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
||||||
|
'allowLocalNetwork': instance.allowLocalNetwork,
|
||||||
'songLinkRegion': instance.songLinkRegion,
|
'songLinkRegion': instance.songLinkRegion,
|
||||||
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
|
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
|
||||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||||
@@ -147,4 +153,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'lastSeenVersion': instance.lastSeenVersion,
|
'lastSeenVersion': instance.lastSeenVersion,
|
||||||
'deduplicateDownloads': instance.deduplicateDownloads,
|
'deduplicateDownloads': instance.deduplicateDownloads,
|
||||||
'saveDownloadHistory': instance.saveDownloadHistory,
|
'saveDownloadHistory': instance.saveDownloadHistory,
|
||||||
|
'playerMode': instance.playerMode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Track {
|
|||||||
final String? albumId;
|
final String? albumId;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? isrc;
|
final String? isrc;
|
||||||
|
final String? previewUrl;
|
||||||
final int duration;
|
final int duration;
|
||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
@@ -38,6 +39,7 @@ class Track {
|
|||||||
this.albumId,
|
this.albumId,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.isrc,
|
this.isrc,
|
||||||
|
this.previewUrl,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
@@ -81,6 +83,8 @@ class Track {
|
|||||||
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
|
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
|
||||||
|
|
||||||
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
|
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
|
||||||
|
|
||||||
|
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
albumId: json['albumId'] as String?,
|
albumId: json['albumId'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
isrc: json['isrc'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
|
previewUrl: json['previewUrl'] as String?,
|
||||||
duration: (json['duration'] as num).toInt(),
|
duration: (json['duration'] as num).toInt(),
|
||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
@@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'albumId': instance.albumId,
|
'albumId': instance.albumId,
|
||||||
'coverUrl': instance.coverUrl,
|
'coverUrl': instance.coverUrl,
|
||||||
'isrc': instance.isrc,
|
'isrc': instance.isrc,
|
||||||
|
'previewUrl': instance.previewUrl,
|
||||||
'duration': instance.duration,
|
'duration': instance.duration,
|
||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
|||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||||
|
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||||
|
|
||||||
export 'package:spotiflac_android/services/history_database.dart'
|
export 'package:spotiflac_android/services/history_database.dart'
|
||||||
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
||||||
@@ -480,6 +481,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
static const _startupSafRepairCursorKey =
|
static const _startupSafRepairCursorKey =
|
||||||
'history_startup_saf_repair_cursor_v1';
|
'history_startup_saf_repair_cursor_v1';
|
||||||
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
||||||
|
static const _startupOrphanSuspectPrefix =
|
||||||
|
'history_startup_orphan_suspect_v1_';
|
||||||
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
||||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
@@ -1540,24 +1543,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final result = await _inspectOrphanedEntries(entries);
|
final result = await _inspectOrphanedEntries(entries);
|
||||||
|
final confirmedOrphanIds = <String>[];
|
||||||
|
for (final id in result.orphanedIds) {
|
||||||
|
final key = '$_startupOrphanSuspectPrefix$id';
|
||||||
|
if (prefs.getBool(key) == true) {
|
||||||
|
confirmedOrphanIds.add(id);
|
||||||
|
await prefs.remove(key);
|
||||||
|
} else {
|
||||||
|
await prefs.setBool(key, true);
|
||||||
|
_historyLog.d(
|
||||||
|
'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (final replacement in result.replacementPaths.entries) {
|
for (final replacement in result.replacementPaths.entries) {
|
||||||
await _db.updateFilePath(replacement.key, replacement.value);
|
await _db.updateFilePath(replacement.key, replacement.value);
|
||||||
|
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
|
||||||
}
|
}
|
||||||
|
|
||||||
final deletedCount = result.orphanedIds.isEmpty
|
final deletedCount = confirmedOrphanIds.isEmpty
|
||||||
? 0
|
? 0
|
||||||
: await _db.deleteByIds(result.orphanedIds);
|
: await _db.deleteByIds(confirmedOrphanIds);
|
||||||
|
|
||||||
_applyHistoryPathAndDeletionChanges(
|
_applyHistoryPathAndDeletionChanges(
|
||||||
deletedIds: result.orphanedIds,
|
deletedIds: confirmedOrphanIds,
|
||||||
replacementPaths: result.replacementPaths,
|
replacementPaths: result.replacementPaths,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entries.length < maxItems) {
|
if (entries.length < maxItems) {
|
||||||
await prefs.remove(_startupOrphanCursorKey);
|
await prefs.remove(_startupOrphanCursorKey);
|
||||||
} else {
|
} else {
|
||||||
final nextCursor =
|
final nextCursor = result.orphanedIds.isNotEmpty
|
||||||
safeCursor + entries.length - result.orphanedIds.length;
|
? safeCursor
|
||||||
|
: safeCursor + entries.length;
|
||||||
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1633,6 +1651,17 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
Future<int> getDatabaseCount() async {
|
Future<int> getDatabaseCount() async {
|
||||||
return await _db.getCount();
|
return await _db.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces all download history with [items] (each in the
|
||||||
|
/// [DownloadHistoryItem.toJson] shape) from a restored backup, then reloads
|
||||||
|
/// the in-memory state from storage.
|
||||||
|
Future<void> restoreFromBackup(List<Map<String, dynamic>> items) async {
|
||||||
|
await _db.clearAll();
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
await _db.upsertBatch(items);
|
||||||
|
}
|
||||||
|
await reloadFromStorage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final downloadHistoryProvider =
|
final downloadHistoryProvider =
|
||||||
@@ -1905,6 +1934,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
int _lastNotifQueueCount = -1;
|
int _lastNotifQueueCount = -1;
|
||||||
final Set<String> _locallyCancelledItemIds = {};
|
final Set<String> _locallyCancelledItemIds = {};
|
||||||
final Set<String> _pausePendingItemIds = {};
|
final Set<String> _pausePendingItemIds = {};
|
||||||
|
final Set<String> _verificationRetriedItemIds = {};
|
||||||
|
final Set<String> _rateLimitRetriedItemIds = {};
|
||||||
String? _activeNativeWorkerRunId;
|
String? _activeNativeWorkerRunId;
|
||||||
|
|
||||||
// Album ReplayGain accumulator: keyed by album identifier.
|
// Album ReplayGain accumulator: keyed by album identifier.
|
||||||
@@ -1912,6 +1943,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// then computes and writes album gain/peak to every track in the album.
|
// then computes and writes album gain/peak to every track in the album.
|
||||||
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
|
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
|
||||||
|
|
||||||
|
String _verificationRetryKey(String itemId, String service) =>
|
||||||
|
'$itemId::${service.trim().toLowerCase()}';
|
||||||
|
|
||||||
double _normalizeProgressForUi(double value) {
|
double _normalizeProgressForUi(double value) {
|
||||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||||
if (clamped <= 0) return 0;
|
if (clamped <= 0) return 0;
|
||||||
@@ -2047,6 +2081,166 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _openVerificationAndWait(String extensionId) async {
|
||||||
|
final normalizedExtensionId = extensionId.trim();
|
||||||
|
if (normalizedExtensionId.isEmpty) return false;
|
||||||
|
|
||||||
|
final grantEventFuture = PlatformBridge.extensionSessionGrantEvents()
|
||||||
|
.where((event) => event.extensionId == normalizedExtensionId)
|
||||||
|
.first
|
||||||
|
.timeout(
|
||||||
|
const Duration(minutes: 5),
|
||||||
|
onTimeout: () => ExtensionSessionGrantEvent(
|
||||||
|
extensionId: normalizedExtensionId,
|
||||||
|
success: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final browserMode = ref
|
||||||
|
.read(settingsProvider)
|
||||||
|
.extensionVerificationBrowserMode;
|
||||||
|
Uri? authUri;
|
||||||
|
Timer? helpDialogTimer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final opened = await openPendingExtensionVerification(
|
||||||
|
normalizedExtensionId,
|
||||||
|
browserMode: browserMode,
|
||||||
|
onAuthUri: (uri) => authUri = uri,
|
||||||
|
);
|
||||||
|
if (!opened) return false;
|
||||||
|
|
||||||
|
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
|
||||||
|
normalizedExtensionId,
|
||||||
|
authUri,
|
||||||
|
browserMode: browserMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
final event = await grantEventFuture;
|
||||||
|
return event.success;
|
||||||
|
} finally {
|
||||||
|
helpDialogTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleVerificationRequiredDownload(
|
||||||
|
DownloadItem item,
|
||||||
|
String errorMsg,
|
||||||
|
String? verificationService,
|
||||||
|
) async {
|
||||||
|
final targetService = (verificationService ?? '').trim().isNotEmpty
|
||||||
|
? verificationService!.trim()
|
||||||
|
: item.service;
|
||||||
|
final verificationRetryKey = _verificationRetryKey(item.id, targetService);
|
||||||
|
if (_verificationRetriedItemIds.contains(verificationRetryKey)) {
|
||||||
|
_log.e(
|
||||||
|
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
|
||||||
|
);
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: errorMsg,
|
||||||
|
errorType: DownloadErrorType.verificationRequired,
|
||||||
|
);
|
||||||
|
_failedInSession++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_verificationRetriedItemIds.add(verificationRetryKey);
|
||||||
|
|
||||||
|
_log.i(
|
||||||
|
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
|
||||||
|
);
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
error: 'Waiting for verification',
|
||||||
|
errorType: DownloadErrorType.verificationRequired,
|
||||||
|
);
|
||||||
|
|
||||||
|
final verified = await _openVerificationAndWait(targetService);
|
||||||
|
final current = _findItemById(item.id);
|
||||||
|
if (current == null || _isLocallyCancelled(item.id, item: current)) {
|
||||||
|
_log.i('Verification completed after item was removed or cancelled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
_log.i(
|
||||||
|
'Verification complete for $targetService; retrying ${item.track.name}',
|
||||||
|
);
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.queued,
|
||||||
|
progress: 0,
|
||||||
|
speedMBps: 0,
|
||||||
|
error: 'Retrying after verification',
|
||||||
|
errorType: DownloadErrorType.verificationRequired,
|
||||||
|
);
|
||||||
|
_saveQueueToStorage();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('Verification did not complete for $targetService');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: errorMsg,
|
||||||
|
errorType: DownloadErrorType.verificationRequired,
|
||||||
|
);
|
||||||
|
_failedInSession++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration _rateLimitBackoffDelay(String errorMsg) {
|
||||||
|
final lower = errorMsg.toLowerCase();
|
||||||
|
final retryAfterMatch = RegExp(
|
||||||
|
r'retry[- ]?after(?: seconds)?[:= ]+(\d+)',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(lower);
|
||||||
|
final parsedSeconds = retryAfterMatch == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(retryAfterMatch.group(1) ?? '');
|
||||||
|
final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt();
|
||||||
|
return Duration(seconds: seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _handleRateLimitedDownload(
|
||||||
|
DownloadItem item,
|
||||||
|
String errorMsg,
|
||||||
|
) async {
|
||||||
|
if (_rateLimitRetriedItemIds.contains(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_rateLimitRetriedItemIds.add(item.id);
|
||||||
|
|
||||||
|
final delay = _rateLimitBackoffDelay(errorMsg);
|
||||||
|
_log.i(
|
||||||
|
'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s',
|
||||||
|
);
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
error: 'Rate limited, retrying after ${delay.inSeconds}s',
|
||||||
|
errorType: DownloadErrorType.rateLimit,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
final current = _findItemById(item.id);
|
||||||
|
if (current == null || _isLocallyCancelled(item.id, item: current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.queued,
|
||||||
|
progress: 0,
|
||||||
|
speedMBps: 0,
|
||||||
|
error: 'Retrying after rate limit',
|
||||||
|
errorType: DownloadErrorType.rateLimit,
|
||||||
|
);
|
||||||
|
_saveQueueToStorage();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void _saveQueueToStorage() {
|
void _saveQueueToStorage() {
|
||||||
_queuePersistDebounce?.cancel();
|
_queuePersistDebounce?.cancel();
|
||||||
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
|
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
|
||||||
@@ -3191,6 +3385,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (lower.endsWith(ext)) return ext;
|
if (lower.endsWith(ext)) return ext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic safety net: when neither an explicit extension field nor a
|
||||||
|
// recognizable path suffix is available (e.g. SAF content URIs that drop
|
||||||
|
// the suffix), fall back to the actual audio codec reported by the backend
|
||||||
|
// probe. This keeps any extension that returns a non-FLAC container (Opus,
|
||||||
|
// MP3, AAC) from being mislabeled as FLAC.
|
||||||
|
final codec = _normalizeAudioFormatValue(
|
||||||
|
result['audio_codec']?.toString() ??
|
||||||
|
result['actual_audio_codec']?.toString() ??
|
||||||
|
result['format']?.toString(),
|
||||||
|
);
|
||||||
|
switch (codec) {
|
||||||
|
case 'opus':
|
||||||
|
return '.opus';
|
||||||
|
case 'mp3':
|
||||||
|
return '.mp3';
|
||||||
|
case 'aac':
|
||||||
|
case 'alac':
|
||||||
|
case 'm4a':
|
||||||
|
return '.m4a';
|
||||||
|
case 'flac':
|
||||||
|
return '.flac';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3606,6 +3823,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String service, {
|
String service, {
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
int? playlistPosition,
|
||||||
}) {
|
}) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
@@ -3619,6 +3837,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
qualityOverride: qualityOverride,
|
qualityOverride: qualityOverride,
|
||||||
playlistName: playlistName,
|
playlistName: playlistName,
|
||||||
|
playlistPosition: playlistPosition,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, item]);
|
state = state.copyWith(items: [...state.items, item]);
|
||||||
@@ -3636,12 +3855,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String service, {
|
String service, {
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
List<int?>? playlistPositions,
|
||||||
}) {
|
}) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
final takenIds = state.items.map((item) => item.id).toSet();
|
final takenIds = state.items.map((item) => item.id).toSet();
|
||||||
final newItems = tracks.map((track) {
|
final shouldAssignPlaylistPositions =
|
||||||
|
playlistName != null && playlistName.trim().isNotEmpty;
|
||||||
|
final newItems = tracks.asMap().entries.map((entry) {
|
||||||
|
final track = entry.value;
|
||||||
|
final index = entry.key;
|
||||||
|
final explicitPosition =
|
||||||
|
playlistPositions != null &&
|
||||||
|
index < playlistPositions.length &&
|
||||||
|
(playlistPositions[index] ?? 0) > 0
|
||||||
|
? playlistPositions[index]
|
||||||
|
: null;
|
||||||
final id = _newQueueItemId(track, takenIds: takenIds);
|
final id = _newQueueItemId(track, takenIds: takenIds);
|
||||||
takenIds.add(id);
|
takenIds.add(id);
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
@@ -3651,6 +3881,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
qualityOverride: qualityOverride,
|
qualityOverride: qualityOverride,
|
||||||
playlistName: playlistName,
|
playlistName: playlistName,
|
||||||
|
playlistPosition:
|
||||||
|
explicitPosition ??
|
||||||
|
(shouldAssignPlaylistPositions ? index + 1 : null),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -3662,6 +3895,45 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _validPlaylistPosition(DownloadItem item) {
|
||||||
|
final position = item.playlistPosition;
|
||||||
|
if (position == null || position <= 0) return 0;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _filenameFormatForItem(DownloadItem item, String baseFormat) {
|
||||||
|
if (_validPlaylistPosition(item) == 0 ||
|
||||||
|
item.playlistName == null ||
|
||||||
|
item.playlistName!.trim().isEmpty) {
|
||||||
|
return baseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lower = baseFormat.toLowerCase();
|
||||||
|
if (lower.contains('{playlist_position') ||
|
||||||
|
lower.contains('{playlist position') ||
|
||||||
|
lower.contains('{playlistposition')) {
|
||||||
|
return baseFormat;
|
||||||
|
}
|
||||||
|
return '{playlist_position:02} - $baseFormat';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _filenameMetadataForTrack(
|
||||||
|
Track track, {
|
||||||
|
int playlistPosition = 0,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'title': track.name,
|
||||||
|
'artist': track.artistName,
|
||||||
|
'album': track.albumName,
|
||||||
|
'track': track.trackNumber ?? 0,
|
||||||
|
'disc': track.discNumber ?? 0,
|
||||||
|
'year': _extractYear(track.releaseDate) ?? '',
|
||||||
|
'date': track.releaseDate ?? '',
|
||||||
|
'playlist_position': playlistPosition,
|
||||||
|
'playlistPosition': playlistPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void updateItemStatus(
|
void updateItemStatus(
|
||||||
String id,
|
String id,
|
||||||
DownloadStatus status, {
|
DownloadStatus status, {
|
||||||
@@ -3977,6 +4249,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||||
_locallyCancelledItemIds.remove(id);
|
_locallyCancelledItemIds.remove(id);
|
||||||
|
_verificationRetriedItemIds.removeWhere(
|
||||||
|
(retryKey) => retryKey == id || retryKey.startsWith('$id::'),
|
||||||
|
);
|
||||||
|
_rateLimitRetriedItemIds.remove(id);
|
||||||
|
|
||||||
// Purge stale ReplayGain entry for this track so a re-scan doesn't
|
// Purge stale ReplayGain entry for this track so a re-scan doesn't
|
||||||
// produce duplicate entries that bias album gain.
|
// produce duplicate entries that bias album gain.
|
||||||
@@ -4310,14 +4586,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.where((item) => _albumRgKey(item.track) == key)
|
.where((item) => _albumRgKey(item.track) == key)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// If any item is still in-flight, the album isn't complete yet.
|
|
||||||
final pending = albumItemsInQueue.where(
|
final pending = albumItemsInQueue.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.status == DownloadStatus.queued ||
|
item.status == DownloadStatus.queued ||
|
||||||
item.status == DownloadStatus.downloading ||
|
item.status == DownloadStatus.downloading ||
|
||||||
item.status == DownloadStatus.finalizing,
|
item.status == DownloadStatus.finalizing,
|
||||||
);
|
);
|
||||||
if (pending.isNotEmpty) return; // still in progress
|
if (pending.isNotEmpty) return;
|
||||||
|
|
||||||
// If any item is failed/skipped, the user might retry it later.
|
// If any item is failed/skipped, the user might retry it later.
|
||||||
// Don't finalize album RG with partial data — wait until all album
|
// Don't finalize album RG with partial data — wait until all album
|
||||||
@@ -4327,7 +4602,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
item.status == DownloadStatus.failed ||
|
item.status == DownloadStatus.failed ||
|
||||||
item.status == DownloadStatus.skipped,
|
item.status == DownloadStatus.skipped,
|
||||||
);
|
);
|
||||||
if (retryable.isNotEmpty) return; // still retryable
|
if (retryable.isNotEmpty) return;
|
||||||
|
|
||||||
// The accumulator entries represent successfully scanned tracks. Entries
|
// The accumulator entries represent successfully scanned tracks. Entries
|
||||||
// are only added after a successful ReplayGain scan, removed on retry or
|
// are only added after a successful ReplayGain scan, removed on retry or
|
||||||
@@ -4467,7 +4742,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// If any representative item is available, use its track.
|
|
||||||
final representative = albumItems.first;
|
final representative = albumItems.first;
|
||||||
_checkAndWriteAlbumReplayGain(representative.track);
|
_checkAndWriteAlbumReplayGain(representative.track);
|
||||||
}
|
}
|
||||||
@@ -4860,6 +5134,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
scannedReplayGain = rgResult;
|
scannedReplayGain = rgResult;
|
||||||
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
||||||
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
||||||
|
if (format == 'opus') {
|
||||||
|
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
|
||||||
|
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
|
||||||
|
}
|
||||||
_log.d(
|
_log.d(
|
||||||
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
||||||
);
|
);
|
||||||
@@ -4874,6 +5152,48 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? coverPath
|
? coverPath
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as
|
||||||
|
// QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a
|
||||||
|
// no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg.
|
||||||
|
if (isM4a) {
|
||||||
|
try {
|
||||||
|
final ac4Meta = <String, String>{
|
||||||
|
'title': track.name,
|
||||||
|
'artist': track.artistName,
|
||||||
|
'album': track.albumName,
|
||||||
|
'albumArtist': ?albumArtist,
|
||||||
|
if (track.releaseDate != null) 'date': track.releaseDate!,
|
||||||
|
if (genre != null && genre.isNotEmpty) 'genre': genre,
|
||||||
|
if (track.composer != null && track.composer!.isNotEmpty)
|
||||||
|
'composer': track.composer!,
|
||||||
|
if (track.trackNumber != null && track.trackNumber! > 0)
|
||||||
|
'trackNumber': track.trackNumber!.toString(),
|
||||||
|
if (track.totalTracks != null && track.totalTracks! > 0)
|
||||||
|
'totalTracks': track.totalTracks!.toString(),
|
||||||
|
if (track.discNumber != null && track.discNumber! > 0)
|
||||||
|
'discNumber': track.discNumber!.toString(),
|
||||||
|
if (track.totalDiscs != null && track.totalDiscs! > 0)
|
||||||
|
'totalDiscs': track.totalDiscs!.toString(),
|
||||||
|
if (track.isrc != null) 'isrc': track.isrc!,
|
||||||
|
if (label != null && label.isNotEmpty) 'label': label,
|
||||||
|
if (copyright != null && copyright.isNotEmpty)
|
||||||
|
'copyright': copyright,
|
||||||
|
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
|
||||||
|
};
|
||||||
|
final ac4Result = await PlatformBridge.writeAC4Metadata(
|
||||||
|
filePath,
|
||||||
|
ac4Meta,
|
||||||
|
validCover ?? '',
|
||||||
|
);
|
||||||
|
if (ac4Result['handled'] == true) {
|
||||||
|
_log.d('AC-4 metadata embedded natively for $format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('AC-4 metadata path failed, falling back to FFmpeg: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? ffmpegResult;
|
String? ffmpegResult;
|
||||||
if (isFlac) {
|
if (isFlac) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadata(
|
ffmpegResult = await FFmpegService.embedMetadata(
|
||||||
@@ -5515,19 +5835,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
String? safFileName;
|
String? safFileName;
|
||||||
final safOutputExt = isSafMode ? outputExt : '';
|
final safOutputExt = isSafMode ? outputExt : '';
|
||||||
|
final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track)
|
||||||
|
? state.singleFilenameFormat
|
||||||
|
: state.filenameFormat;
|
||||||
|
final effectiveFilenameFormat = _filenameFormatForItem(
|
||||||
|
item,
|
||||||
|
baseFilenameFormat,
|
||||||
|
);
|
||||||
if (isSafMode) {
|
if (isSafMode) {
|
||||||
final effectiveFormat = _shouldTreatAsSingleRelease(item.track)
|
final baseName = await PlatformBridge.buildFilename(
|
||||||
? state.singleFilenameFormat
|
effectiveFilenameFormat,
|
||||||
: state.filenameFormat;
|
_filenameMetadataForTrack(
|
||||||
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
|
item.track,
|
||||||
'title': item.track.name,
|
playlistPosition: _validPlaylistPosition(item),
|
||||||
'artist': item.track.artistName,
|
),
|
||||||
'album': item.track.albumName,
|
);
|
||||||
'track': item.track.trackNumber ?? 0,
|
|
||||||
'disc': item.track.discNumber ?? 0,
|
|
||||||
'year': _extractYear(item.track.releaseDate) ?? '',
|
|
||||||
'date': item.track.releaseDate ?? '',
|
|
||||||
});
|
|
||||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5615,9 +5937,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
albumArtist: resolvedAlbumArtist ?? '',
|
albumArtist: resolvedAlbumArtist ?? '',
|
||||||
coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '',
|
coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '',
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: _shouldTreatAsSingleRelease(trackForPayload)
|
filenameFormat: effectiveFilenameFormat,
|
||||||
? state.singleFilenameFormat
|
|
||||||
: state.filenameFormat,
|
|
||||||
quality: quality,
|
quality: quality,
|
||||||
embedMetadata: settings.embedMetadata,
|
embedMetadata: settings.embedMetadata,
|
||||||
artistTagMode: settings.artistTagMode,
|
artistTagMode: settings.artistTagMode,
|
||||||
@@ -5634,6 +5954,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
postProcessingEnabled: postProcessingEnabled,
|
postProcessingEnabled: postProcessingEnabled,
|
||||||
tidalHighFormat: settings.tidalHighFormat,
|
tidalHighFormat: settings.tidalHighFormat,
|
||||||
trackNumber: normalizedTrackNumber,
|
trackNumber: normalizedTrackNumber,
|
||||||
|
playlistPosition: _validPlaylistPosition(item),
|
||||||
discNumber: normalizedDiscNumber,
|
discNumber: normalizedDiscNumber,
|
||||||
totalTracks: trackForPayload.totalTracks ?? 0,
|
totalTracks: trackForPayload.totalTracks ?? 0,
|
||||||
totalDiscs: trackForPayload.totalDiscs ?? 0,
|
totalDiscs: trackForPayload.totalDiscs ?? 0,
|
||||||
@@ -5766,15 +6087,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (status == 'skipped') {
|
if (status == 'skipped') {
|
||||||
updateItemStatus(itemId, DownloadStatus.skipped);
|
updateItemStatus(itemId, DownloadStatus.skipped);
|
||||||
} else {
|
} else {
|
||||||
final errorType = result is Map
|
final resultMap = result is Map
|
||||||
? _downloadErrorTypeFromBackend(
|
? Map<String, dynamic>.from(result)
|
||||||
Map<String, dynamic>.from(result)['error_type']?.toString(),
|
: null;
|
||||||
)
|
final errorMsg = (error == null || error.isEmpty)
|
||||||
: DownloadErrorType.unknown;
|
? (resultMap?['error']?.toString() ?? 'Download failed')
|
||||||
|
: error;
|
||||||
|
final backendErrorType = resultMap == null
|
||||||
|
? DownloadErrorType.unknown
|
||||||
|
: _downloadErrorTypeFromBackend(
|
||||||
|
resultMap['error_type']?.toString(),
|
||||||
|
);
|
||||||
|
final errorType = backendErrorType == DownloadErrorType.unknown
|
||||||
|
? _downloadErrorTypeFromMessage(errorMsg)
|
||||||
|
: backendErrorType;
|
||||||
|
if (errorType == DownloadErrorType.verificationRequired) {
|
||||||
|
_log.i(
|
||||||
|
'Android native worker requires verification for ${current.track.name}; switching back to interactive queue',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await PlatformBridge.cancelNativeDownloadWorker();
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to cancel native worker before verification: $e');
|
||||||
|
}
|
||||||
|
await _handleVerificationRequiredDownload(
|
||||||
|
current,
|
||||||
|
errorMsg,
|
||||||
|
_nativeWorkerVerificationService(resultMap, context),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
itemId,
|
itemId,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: error == null || error.isEmpty ? 'Download failed' : error,
|
error: errorMsg,
|
||||||
errorType: errorType,
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
@@ -6591,13 +6937,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return DownloadErrorType.network;
|
return DownloadErrorType.network;
|
||||||
case 'permission':
|
case 'permission':
|
||||||
return DownloadErrorType.permission;
|
return DownloadErrorType.permission;
|
||||||
|
case 'verification_required':
|
||||||
|
return DownloadErrorType.verificationRequired;
|
||||||
default:
|
default:
|
||||||
return DownloadErrorType.unknown;
|
return DownloadErrorType.unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _nativeWorkerVerificationService(
|
||||||
|
Map<String, dynamic>? result,
|
||||||
|
_NativeWorkerRequestContext context,
|
||||||
|
) {
|
||||||
|
if (result != null) {
|
||||||
|
for (final key in const [
|
||||||
|
'service',
|
||||||
|
'verification_service',
|
||||||
|
'provider',
|
||||||
|
'source',
|
||||||
|
]) {
|
||||||
|
final value = result[key]?.toString().trim() ?? '';
|
||||||
|
if (value.isNotEmpty) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.item.service;
|
||||||
|
}
|
||||||
|
|
||||||
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||||
final lowerMsg = errorMsg.toLowerCase();
|
final lowerMsg = errorMsg.toLowerCase();
|
||||||
|
if (isExtensionVerificationRequired(errorMsg)) {
|
||||||
|
return DownloadErrorType.verificationRequired;
|
||||||
|
}
|
||||||
if (errorMsg.contains('429') ||
|
if (errorMsg.contains('429') ||
|
||||||
lowerMsg.contains('rate limit') ||
|
lowerMsg.contains('rate limit') ||
|
||||||
lowerMsg.contains('too many requests')) {
|
lowerMsg.contains('too many requests')) {
|
||||||
@@ -6922,6 +7291,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final remainingIds = state.items.map((item) => item.id).toSet();
|
final remainingIds = state.items.map((item) => item.id).toSet();
|
||||||
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||||
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
|
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||||
|
_verificationRetriedItemIds.removeWhere((retryKey) {
|
||||||
|
final itemId = retryKey.split('::').first;
|
||||||
|
return !remainingIds.contains(itemId);
|
||||||
|
});
|
||||||
|
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||||
@@ -7133,19 +7507,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String? safFileName;
|
String? safFileName;
|
||||||
String? safBaseName;
|
String? safBaseName;
|
||||||
String safOutputExt = _determineOutputExt(quality, item.service);
|
String safOutputExt = _determineOutputExt(quality, item.service);
|
||||||
|
final baseFilenameFormat = _shouldTreatAsSingleRelease(trackToDownload)
|
||||||
|
? state.singleFilenameFormat
|
||||||
|
: state.filenameFormat;
|
||||||
|
final effectiveFilenameFormat = _filenameFormatForItem(
|
||||||
|
item,
|
||||||
|
baseFilenameFormat,
|
||||||
|
);
|
||||||
if (isSafMode) {
|
if (isSafMode) {
|
||||||
final effectiveFormat = _shouldTreatAsSingleRelease(trackToDownload)
|
final baseName = await PlatformBridge.buildFilename(
|
||||||
? state.singleFilenameFormat
|
effectiveFilenameFormat,
|
||||||
: state.filenameFormat;
|
_filenameMetadataForTrack(
|
||||||
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
|
trackToDownload,
|
||||||
'title': trackToDownload.name,
|
playlistPosition: _validPlaylistPosition(item),
|
||||||
'artist': trackToDownload.artistName,
|
),
|
||||||
'album': trackToDownload.albumName,
|
);
|
||||||
'track': trackToDownload.trackNumber ?? 0,
|
|
||||||
'disc': trackToDownload.discNumber ?? 0,
|
|
||||||
'year': _extractYear(trackToDownload.releaseDate) ?? '',
|
|
||||||
'date': trackToDownload.releaseDate ?? '',
|
|
||||||
});
|
|
||||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||||
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||||
}
|
}
|
||||||
@@ -7334,9 +7710,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? (trackToDownload.coverUrl ?? '')
|
? (trackToDownload.coverUrl ?? '')
|
||||||
: '',
|
: '',
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: _shouldTreatAsSingleRelease(trackToDownload)
|
filenameFormat: effectiveFilenameFormat,
|
||||||
? state.singleFilenameFormat
|
|
||||||
: state.filenameFormat,
|
|
||||||
quality: quality,
|
quality: quality,
|
||||||
embedMetadata: metadataEmbeddingEnabled,
|
embedMetadata: metadataEmbeddingEnabled,
|
||||||
artistTagMode: settings.artistTagMode,
|
artistTagMode: settings.artistTagMode,
|
||||||
@@ -7354,6 +7728,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
postProcessingEnabled: postProcessingEnabled,
|
postProcessingEnabled: postProcessingEnabled,
|
||||||
tidalHighFormat: settings.tidalHighFormat,
|
tidalHighFormat: settings.tidalHighFormat,
|
||||||
trackNumber: normalizedTrackNumber,
|
trackNumber: normalizedTrackNumber,
|
||||||
|
playlistPosition: _validPlaylistPosition(item),
|
||||||
discNumber: normalizedDiscNumber,
|
discNumber: normalizedDiscNumber,
|
||||||
totalTracks: trackToDownload.totalTracks ?? 0,
|
totalTracks: trackToDownload.totalTracks ?? 0,
|
||||||
totalDiscs: trackToDownload.totalDiscs ?? 0,
|
totalDiscs: trackToDownload.totalDiscs ?? 0,
|
||||||
@@ -7561,6 +7936,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
|
||||||
|
// source. No-op for other codecs.
|
||||||
|
try {
|
||||||
|
await PlatformBridge.ensureAC4Config(
|
||||||
|
decryptedTempPath,
|
||||||
|
tempPath,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('AC-4 container repair skipped: $e');
|
||||||
|
}
|
||||||
|
|
||||||
final dotIndex = decryptedTempPath.lastIndexOf('.');
|
final dotIndex = decryptedTempPath.lastIndexOf('.');
|
||||||
final decryptedExt = dotIndex >= 0
|
final decryptedExt = dotIndex >= 0
|
||||||
? decryptedTempPath.substring(dotIndex).toLowerCase()
|
? decryptedTempPath.substring(dotIndex).toLowerCase()
|
||||||
@@ -7613,10 +7999,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
final encryptedSource = filePath;
|
||||||
final decryptedPath = await FFmpegService.decryptWithDescriptor(
|
final decryptedPath = await FFmpegService.decryptWithDescriptor(
|
||||||
inputPath: filePath,
|
inputPath: encryptedSource,
|
||||||
descriptor: decryptionDescriptor,
|
descriptor: decryptionDescriptor,
|
||||||
deleteOriginal: true,
|
deleteOriginal: false,
|
||||||
);
|
);
|
||||||
if (decryptedPath == null) {
|
if (decryptedPath == null) {
|
||||||
_log.e('FFmpeg decrypt failed for local file');
|
_log.e('FFmpeg decrypt failed for local file');
|
||||||
@@ -7627,10 +8014,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
errorType: DownloadErrorType.unknown,
|
errorType: DownloadErrorType.unknown,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await deleteFile(filePath);
|
await deleteFile(encryptedSource);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
|
||||||
|
// source before discarding it. No-op for other codecs.
|
||||||
|
try {
|
||||||
|
await PlatformBridge.ensureAC4Config(
|
||||||
|
decryptedPath,
|
||||||
|
encryptedSource,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('AC-4 container repair skipped: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteFile(encryptedSource);
|
||||||
|
} catch (_) {}
|
||||||
filePath = decryptedPath;
|
filePath = decryptedPath;
|
||||||
_log.i('Local decryption completed');
|
_log.i('Local decryption completed');
|
||||||
}
|
}
|
||||||
@@ -8768,8 +9168,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
var errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||||
|
final retryAfterSeconds = readPositiveInt(
|
||||||
|
result['retry_after_seconds'],
|
||||||
|
);
|
||||||
|
if (retryAfterSeconds != null && retryAfterSeconds > 0) {
|
||||||
|
errorMsg = '$errorMsg retry-after: $retryAfterSeconds';
|
||||||
|
}
|
||||||
if (errorTypeStr == 'cancelled') {
|
if (errorTypeStr == 'cancelled') {
|
||||||
if (_isPausePending(item.id)) {
|
if (_isPausePending(item.id)) {
|
||||||
pausedDuringThisRun = true;
|
pausedDuringThisRun = true;
|
||||||
@@ -8798,10 +9204,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
case 'permission':
|
case 'permission':
|
||||||
errorType = DownloadErrorType.permission;
|
errorType = DownloadErrorType.permission;
|
||||||
break;
|
break;
|
||||||
|
case 'verification_required':
|
||||||
|
errorType = DownloadErrorType.verificationRequired;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorType == DownloadErrorType.verificationRequired) {
|
||||||
|
await _handleVerificationRequiredDownload(
|
||||||
|
item,
|
||||||
|
errorMsg,
|
||||||
|
result['service'] as String?,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (errorType == DownloadErrorType.rateLimit &&
|
||||||
|
await _handleRateLimitedDownload(item, errorMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
@@ -8857,6 +9279,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorType == DownloadErrorType.verificationRequired) {
|
||||||
|
await _handleVerificationRequiredDownload(item, errorMsg, item.service);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (errorType == DownloadErrorType.rateLimit &&
|
||||||
|
await _handleRateLimitedDownload(item, errorMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ final _log = AppLogger('ExtensionProvider');
|
|||||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||||
const _providerPriorityKey = 'provider_priority';
|
const _providerPriorityKey = 'provider_priority';
|
||||||
const _spotifyWebExtensionId = 'spotify-web';
|
const _spotifyWebExtensionId = 'spotify-web';
|
||||||
|
const _storeRegistryUrlPrefKey = 'store_registry_url';
|
||||||
|
|
||||||
|
/// Result of restoring extensions from a backup.
|
||||||
|
class ExtensionRestoreResult {
|
||||||
|
final int installed;
|
||||||
|
final int alreadyPresent;
|
||||||
|
final int failed;
|
||||||
|
final List<String> failedIds;
|
||||||
|
|
||||||
|
const ExtensionRestoreResult({
|
||||||
|
this.installed = 0,
|
||||||
|
this.alreadyPresent = 0,
|
||||||
|
this.failed = 0,
|
||||||
|
this.failedIds = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bool _stringListEquals(List<String> a, List<String> b) {
|
bool _stringListEquals(List<String> a, List<String> b) {
|
||||||
if (identical(a, b)) return true;
|
if (identical(a, b)) return true;
|
||||||
@@ -792,12 +808,15 @@ class ExtensionInstallBatchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||||
static const _extensionHealthCacheTtl = Duration(seconds: 60);
|
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
|
||||||
|
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
|
||||||
|
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
|
||||||
AppLifecycleListener? _appLifecycleListener;
|
AppLifecycleListener? _appLifecycleListener;
|
||||||
bool _cleanupInFlight = false;
|
bool _cleanupInFlight = false;
|
||||||
Completer<void>? _initializationCompleter;
|
Completer<void>? _initializationCompleter;
|
||||||
final Map<String, DateTime> _healthExpiresAt = {};
|
final Map<String, DateTime> _healthExpiresAt = {};
|
||||||
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
|
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
|
||||||
|
final Map<String, int> _healthRequestSerial = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ExtensionState build() {
|
ExtensionState build() {
|
||||||
@@ -809,6 +828,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
_appLifecycleListener = null;
|
_appLifecycleListener = null;
|
||||||
_healthExpiresAt.clear();
|
_healthExpiresAt.clear();
|
||||||
_healthInFlight.clear();
|
_healthInFlight.clear();
|
||||||
|
_healthRequestSerial.clear();
|
||||||
});
|
});
|
||||||
return const ExtensionState();
|
return const ExtensionState();
|
||||||
}
|
}
|
||||||
@@ -938,15 +958,46 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
|
void _scheduleExtensionHealthRefresh(
|
||||||
|
List<Extension> extensions, {
|
||||||
|
bool force = false,
|
||||||
|
}) {
|
||||||
for (final ext in extensions) {
|
for (final ext in extensions) {
|
||||||
if (!ext.enabled || !ext.hasServiceHealth) continue;
|
if (!ext.enabled || !ext.hasServiceHealth) continue;
|
||||||
unawaited(checkExtensionHealth(ext.id));
|
unawaited(checkExtensionHealth(ext.id, force: force));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshEnabledExtensionHealth() {
|
void refreshEnabledExtensionHealth({bool force = false}) {
|
||||||
_scheduleExtensionHealthRefresh(state.extensions);
|
_scheduleExtensionHealthRefresh(state.extensions, force: force);
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration _extensionHealthCacheTtlFor(Extension extension) {
|
||||||
|
var ttl = _extensionHealthDefaultCacheTtl;
|
||||||
|
for (final check in extension.serviceHealth) {
|
||||||
|
final seconds = check.cacheTtlSeconds;
|
||||||
|
if (seconds == null || seconds <= 0) continue;
|
||||||
|
|
||||||
|
var checkTtl = Duration(seconds: seconds);
|
||||||
|
if (checkTtl < _extensionHealthMinimumCacheTtl) {
|
||||||
|
checkTtl = _extensionHealthMinimumCacheTtl;
|
||||||
|
}
|
||||||
|
if (checkTtl < ttl) {
|
||||||
|
ttl = checkTtl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration _extensionHealthCacheTtlForStatus(
|
||||||
|
Extension extension,
|
||||||
|
String status,
|
||||||
|
) {
|
||||||
|
final ttl = _extensionHealthCacheTtlFor(extension);
|
||||||
|
if (status == 'unknown' && ttl > _extensionHealthUnknownCacheTtl) {
|
||||||
|
return _extensionHealthUnknownCacheTtl;
|
||||||
|
}
|
||||||
|
return ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ExtensionHealthStatus?> checkExtensionHealth(
|
Future<ExtensionHealthStatus?> checkExtensionHealth(
|
||||||
@@ -974,17 +1025,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
|
||||||
|
_healthRequestSerial[extensionId] = requestSerial;
|
||||||
|
|
||||||
final future = () async {
|
final future = () async {
|
||||||
try {
|
try {
|
||||||
final result = await PlatformBridge.checkExtensionHealth(extensionId);
|
final result = await PlatformBridge.checkExtensionHealth(extensionId);
|
||||||
final status = ExtensionHealthStatus.fromJson(result);
|
final status = ExtensionHealthStatus.fromJson(result);
|
||||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||||
state.healthStatuses,
|
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||||
)..[extensionId] = status;
|
state.healthStatuses,
|
||||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
)..[extensionId] = status;
|
||||||
_extensionHealthCacheTtl,
|
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||||
);
|
_extensionHealthCacheTtlForStatus(ext, status.status),
|
||||||
state = state.copyWith(healthStatuses: updated);
|
);
|
||||||
|
state = state.copyWith(healthStatuses: updated);
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to check extension health for $extensionId: $e');
|
_log.w('Failed to check extension health for $extensionId: $e');
|
||||||
@@ -994,16 +1050,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
checkedAt: DateTime.now(),
|
checkedAt: DateTime.now(),
|
||||||
checks: const [],
|
checks: const [],
|
||||||
);
|
);
|
||||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||||
state.healthStatuses,
|
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||||
)..[extensionId] = status;
|
state.healthStatuses,
|
||||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
)..[extensionId] = status;
|
||||||
const Duration(seconds: 20),
|
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||||
);
|
_extensionHealthUnknownCacheTtl,
|
||||||
state = state.copyWith(healthStatuses: updated);
|
);
|
||||||
|
state = state.copyWith(healthStatuses: updated);
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
} finally {
|
} finally {
|
||||||
_healthInFlight.remove(extensionId);
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||||
|
_healthInFlight.remove(extensionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
|
|
||||||
@@ -1640,7 +1700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Extension> get enabledExtensions {
|
List<Extension> enabledExtensions() {
|
||||||
return state.extensions.where((ext) => ext.enabled).toList();
|
return state.extensions.where((ext) => ext.enabled).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1717,11 +1777,208 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Extension> get searchProviders {
|
List<Extension> searchProviders() {
|
||||||
return state.extensions
|
return state.extensions
|
||||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collects the keys flagged as `secret` in an extension's manifest schema
|
||||||
|
/// (top-level settings and quality-specific settings).
|
||||||
|
Set<String> _secretKeysFromManifest(Map<String, dynamic> raw) {
|
||||||
|
final keys = <String>{};
|
||||||
|
|
||||||
|
void scan(Object? settingsList) {
|
||||||
|
if (settingsList is! List) return;
|
||||||
|
for (final entry in settingsList) {
|
||||||
|
if (entry is Map && entry['secret'] == true && entry['key'] is String) {
|
||||||
|
keys.add(entry['key'] as String);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(raw['settings']);
|
||||||
|
final quality = raw['quality_options'];
|
||||||
|
if (quality is List) {
|
||||||
|
for (final option in quality) {
|
||||||
|
if (option is Map) {
|
||||||
|
scan(option['settings']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the extensions section of a backup: the store registry URL plus the
|
||||||
|
/// installed extensions with their id, version, enabled flag and settings.
|
||||||
|
/// Secret-flagged settings (tokens, API keys) are only included when
|
||||||
|
/// [includeSecrets] is true.
|
||||||
|
Future<Map<String, dynamic>> exportBackup({
|
||||||
|
required bool includeSecrets,
|
||||||
|
}) async {
|
||||||
|
if (!PlatformBridge.supportsExtensionSystem) {
|
||||||
|
return {'registry_url': '', 'items': const <Map<String, dynamic>>[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
String registryUrl = '';
|
||||||
|
try {
|
||||||
|
registryUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> installed;
|
||||||
|
try {
|
||||||
|
installed = await PlatformBridge.getInstalledExtensions();
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Backup: failed to list extensions: $e');
|
||||||
|
installed = const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = <Map<String, dynamic>>[];
|
||||||
|
for (final raw in installed) {
|
||||||
|
final id = raw['id'] as String?;
|
||||||
|
if (id == null || id.isEmpty) continue;
|
||||||
|
final secretKeys = _secretKeysFromManifest(raw);
|
||||||
|
|
||||||
|
Map<String, dynamic> settings = {};
|
||||||
|
try {
|
||||||
|
settings = await PlatformBridge.getExtensionSettings(id);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final filtered = <String, dynamic>{};
|
||||||
|
var omittedSecret = false;
|
||||||
|
settings.forEach((key, value) {
|
||||||
|
if (secretKeys.contains(key)) {
|
||||||
|
if (!includeSecrets) {
|
||||||
|
omittedSecret = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
items.add({
|
||||||
|
'id': id,
|
||||||
|
'version': raw['version']?.toString() ?? '',
|
||||||
|
'enabled': raw['enabled'] == true,
|
||||||
|
'settings': filtered,
|
||||||
|
if (omittedSecret) 'secrets_omitted': true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'registry_url': registryUrl, 'items': items};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores extensions from a backup section produced by [exportBackup]:
|
||||||
|
/// re-applies the store registry URL, reinstalls each extension from the
|
||||||
|
/// store when missing, then merges settings and restores the enabled flag.
|
||||||
|
/// Missing settings (e.g. omitted secrets) are merged with the current values
|
||||||
|
/// so they are not wiped.
|
||||||
|
Future<ExtensionRestoreResult> restoreFromBackup(
|
||||||
|
Map<String, dynamic> data,
|
||||||
|
) async {
|
||||||
|
if (!PlatformBridge.supportsExtensionSystem) {
|
||||||
|
return const ExtensionRestoreResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
|
||||||
|
final itemsRaw = data['items'];
|
||||||
|
final items = itemsRaw is List
|
||||||
|
? itemsRaw
|
||||||
|
.whereType<Map<Object?, Object?>>()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
Directory? destDir;
|
||||||
|
try {
|
||||||
|
final tmp = await getTemporaryDirectory();
|
||||||
|
destDir = await Directory(
|
||||||
|
'${tmp.path}/spotiflac_restore_ext',
|
||||||
|
).create(recursive: true);
|
||||||
|
await PlatformBridge.initExtensionStore(destDir.path);
|
||||||
|
if (registryUrl.isNotEmpty) {
|
||||||
|
await PlatformBridge.setStoreRegistryUrl(registryUrl);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_storeRegistryUrlPrefKey, registryUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Restore: failed to prepare extension store: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshExtensions();
|
||||||
|
final installedIds = state.extensions
|
||||||
|
.map((e) => e.id.toLowerCase())
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
var installedCount = 0;
|
||||||
|
var alreadyPresent = 0;
|
||||||
|
var failed = 0;
|
||||||
|
final failedIds = <String>[];
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
final id = item['id'] as String?;
|
||||||
|
if (id == null || id.isEmpty) continue;
|
||||||
|
final enabled = item['enabled'] != false;
|
||||||
|
var present = installedIds.contains(id.toLowerCase());
|
||||||
|
|
||||||
|
if (!present) {
|
||||||
|
if (destDir == null) {
|
||||||
|
failed++;
|
||||||
|
failedIds.add(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final path = await PlatformBridge.downloadStoreExtension(
|
||||||
|
id,
|
||||||
|
destDir.path,
|
||||||
|
);
|
||||||
|
final ok = await installExtension(path);
|
||||||
|
if (ok) {
|
||||||
|
installedCount++;
|
||||||
|
present = true;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
failedIds.add(id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Restore: failed to install extension $id: $e');
|
||||||
|
failed++;
|
||||||
|
failedIds.add(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alreadyPresent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!present) continue;
|
||||||
|
|
||||||
|
final settings = item['settings'];
|
||||||
|
if (settings is Map && settings.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final current = await PlatformBridge.getExtensionSettings(id);
|
||||||
|
final merged = <String, dynamic>{
|
||||||
|
...current,
|
||||||
|
...Map<String, dynamic>.from(settings),
|
||||||
|
};
|
||||||
|
await PlatformBridge.setExtensionSettings(id, merged);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Restore: failed to apply settings for $id: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setExtensionEnabled(id, enabled);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshExtensions();
|
||||||
|
|
||||||
|
return ExtensionRestoreResult(
|
||||||
|
installed: installedCount,
|
||||||
|
alreadyPresent: alreadyPresent,
|
||||||
|
failed: failed,
|
||||||
|
failedIds: failedIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
||||||
|
|||||||
@@ -953,6 +953,90 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
});
|
});
|
||||||
_invalidatePlaylistPickerSummaries();
|
_invalidatePlaylistPickerSummaries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the full collections snapshot (wishlist, loved, playlists,
|
||||||
|
/// favorite artists) for a backup, ensuring data is loaded first.
|
||||||
|
Future<Map<String, dynamic>> exportCollections() async {
|
||||||
|
await _ensureLoaded();
|
||||||
|
return state.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exports custom playlist cover images as base64, keyed by playlist id.
|
||||||
|
/// Each value contains the original file extension and the encoded bytes so a
|
||||||
|
/// restore on another device can recreate the cover files.
|
||||||
|
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
|
||||||
|
await _ensureLoaded();
|
||||||
|
final covers = <String, Map<String, String>>{};
|
||||||
|
for (final playlist in state.playlists) {
|
||||||
|
final path = playlist.coverImagePath;
|
||||||
|
if (path == null || path.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) continue;
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
if (bytes.isEmpty) continue;
|
||||||
|
covers[playlist.id] = {
|
||||||
|
'ext': p.extension(path).toLowerCase(),
|
||||||
|
'data': base64Encode(bytes),
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
// Skip unreadable cover; the rest of the backup still succeeds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return covers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces all collections (wishlist, loved, playlists, favorite artists)
|
||||||
|
/// with the contents of a backup. [collectionsJson] uses the
|
||||||
|
/// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced
|
||||||
|
/// by [exportPlaylistCovers]. Cover images are rewritten into this device's
|
||||||
|
/// covers directory and their paths fixed up before persisting.
|
||||||
|
Future<void> restoreFromBackup(
|
||||||
|
Map<String, dynamic> collectionsJson, {
|
||||||
|
Map<String, dynamic>? coverImages,
|
||||||
|
}) async {
|
||||||
|
final normalized = Map<String, dynamic>.from(collectionsJson);
|
||||||
|
final coversDir = await _playlistCoversDir();
|
||||||
|
|
||||||
|
final playlistsRaw = normalized['playlists'];
|
||||||
|
if (playlistsRaw is List) {
|
||||||
|
final rewritten = <Map<String, dynamic>>[];
|
||||||
|
for (final entry in playlistsRaw.whereType<Map<Object?, Object?>>()) {
|
||||||
|
final playlist = Map<String, dynamic>.from(entry);
|
||||||
|
final id = playlist['id'] as String?;
|
||||||
|
String? newCoverPath;
|
||||||
|
final coverEntry = (id != null && coverImages != null)
|
||||||
|
? coverImages[id]
|
||||||
|
: null;
|
||||||
|
if (id != null && coverEntry is Map) {
|
||||||
|
final data = coverEntry['data'] as String?;
|
||||||
|
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
|
||||||
|
if (data != null && data.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final destPath = p.join(coversDir.path, '$id$ext');
|
||||||
|
await File(destPath).writeAsBytes(base64Decode(data));
|
||||||
|
newCoverPath = destPath;
|
||||||
|
} catch (_) {
|
||||||
|
newCoverPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always replace the backup's device-specific path: either with the
|
||||||
|
// freshly written local cover, or drop it so a stale path is not kept.
|
||||||
|
if (newCoverPath != null) {
|
||||||
|
playlist['coverImagePath'] = newCoverPath;
|
||||||
|
} else {
|
||||||
|
playlist.remove('coverImagePath');
|
||||||
|
}
|
||||||
|
rewritten.add(playlist);
|
||||||
|
}
|
||||||
|
normalized['playlists'] = rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.replaceAllFromBackup(normalized);
|
||||||
|
await _load();
|
||||||
|
_invalidatePlaylistPickerSummaries();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final libraryCollectionsProvider =
|
final libraryCollectionsProvider =
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||||
|
|
||||||
|
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
|
||||||
|
return musicPlayerMediaItemEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
||||||
|
return musicPlayerPlaybackStateEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
|
||||||
|
return musicPlayerQueueEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
class MusicPlayerController {
|
||||||
|
const MusicPlayerController();
|
||||||
|
|
||||||
|
MusicPlayerHandler? get _handler => musicPlayerHandler;
|
||||||
|
|
||||||
|
bool get isAvailable => _handler != null;
|
||||||
|
|
||||||
|
Future<MusicPlayerHandler?> ensureInitialized() async {
|
||||||
|
try {
|
||||||
|
return await initMusicPlayer();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playAll(
|
||||||
|
List<PlayableMedia> items, {
|
||||||
|
int initialIndex = 0,
|
||||||
|
}) async {
|
||||||
|
final handler = await ensureInitialized();
|
||||||
|
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playSingle(PlayableMedia item) => playAll([item]);
|
||||||
|
|
||||||
|
Future<void> playHistory(
|
||||||
|
List<DownloadHistoryItem> items, {
|
||||||
|
int initialIndex = 0,
|
||||||
|
}) async {
|
||||||
|
final media = items
|
||||||
|
.where((i) => i.filePath.trim().isNotEmpty)
|
||||||
|
.map(playableFromHistory)
|
||||||
|
.toList();
|
||||||
|
if (media.isEmpty) return;
|
||||||
|
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playLocal(
|
||||||
|
List<LocalLibraryItem> items, {
|
||||||
|
int initialIndex = 0,
|
||||||
|
}) async {
|
||||||
|
final media = items
|
||||||
|
.where((i) => i.filePath.trim().isNotEmpty)
|
||||||
|
.map(playableFromLocal)
|
||||||
|
.toList();
|
||||||
|
if (media.isEmpty) return;
|
||||||
|
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() async => _handler?.play();
|
||||||
|
Future<void> pause() async => _handler?.pause();
|
||||||
|
Future<void> stop() async => _handler?.stop();
|
||||||
|
Future<void> seek(Duration position) async => _handler?.seek(position);
|
||||||
|
Future<void> next() async => _handler?.skipToNext();
|
||||||
|
Future<void> previous() async => _handler?.skipToPrevious();
|
||||||
|
|
||||||
|
Future<void> togglePlayPause(bool isPlaying) async {
|
||||||
|
if (isPlaying) {
|
||||||
|
await pause();
|
||||||
|
} else {
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setShuffle(bool enabled) async {
|
||||||
|
await _handler?.setShuffleMode(
|
||||||
|
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playNext(PlayableMedia item) async =>
|
||||||
|
(await ensureInitialized())?.enqueue(item, playNext: true);
|
||||||
|
|
||||||
|
Future<void> addToQueue(PlayableMedia item) async =>
|
||||||
|
(await ensureInitialized())?.enqueue(item);
|
||||||
|
|
||||||
|
Future<void> playNextHistory(DownloadHistoryItem item) async =>
|
||||||
|
playNext(playableFromHistory(item));
|
||||||
|
|
||||||
|
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
|
||||||
|
addToQueue(playableFromHistory(item));
|
||||||
|
|
||||||
|
Future<void> playNextLocal(LocalLibraryItem item) async =>
|
||||||
|
playNext(playableFromLocal(item));
|
||||||
|
|
||||||
|
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
|
||||||
|
addToQueue(playableFromLocal(item));
|
||||||
|
|
||||||
|
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
|
||||||
|
|
||||||
|
void moveQueueItem(int oldIndex, int newIndex) {
|
||||||
|
_handler?.moveQueueItem(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
|
||||||
|
(ref) => const MusicPlayerController(),
|
||||||
|
);
|
||||||
|
|
||||||
|
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
|
||||||
|
return PlayableMedia(
|
||||||
|
id: item.id,
|
||||||
|
source: item.filePath,
|
||||||
|
title: item.trackName,
|
||||||
|
artist: item.artistName,
|
||||||
|
album: item.albumName,
|
||||||
|
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
|
||||||
|
? item.coverUrl
|
||||||
|
: null,
|
||||||
|
duration: (item.duration != null && item.duration! > 0)
|
||||||
|
? Duration(seconds: item.duration!)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayableMedia playableFromLocal(LocalLibraryItem item) {
|
||||||
|
String? art;
|
||||||
|
final cover = item.coverPath;
|
||||||
|
if (cover != null && cover.trim().isNotEmpty) {
|
||||||
|
art = cover.startsWith('http') || cover.startsWith('content://')
|
||||||
|
? cover
|
||||||
|
: Uri.file(cover).toString();
|
||||||
|
}
|
||||||
|
return PlayableMedia(
|
||||||
|
id: item.id,
|
||||||
|
source: item.filePath,
|
||||||
|
title: item.trackName,
|
||||||
|
artist: item.artistName,
|
||||||
|
album: item.albumName,
|
||||||
|
artUri: art,
|
||||||
|
duration: (item.duration != null && item.duration! > 0)
|
||||||
|
? Duration(seconds: item.duration!)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
@@ -16,6 +19,24 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
@override
|
@override
|
||||||
PlaybackState build() => const PlaybackState();
|
PlaybackState build() => const PlaybackState();
|
||||||
|
|
||||||
|
Future<bool> _useInternalPlayer() async {
|
||||||
|
final mode = ref.read(settingsProvider).playerMode;
|
||||||
|
if (mode != 'internal') return false;
|
||||||
|
return await ref.read(musicPlayerControllerProvider).ensureInitialized() !=
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizeArtUri(String cover) {
|
||||||
|
final value = cover.trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
if (value.startsWith('http') ||
|
||||||
|
value.startsWith('content://') ||
|
||||||
|
value.startsWith('file://')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return Uri.file(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playLocalPath({
|
Future<void> playLocalPath({
|
||||||
required String path,
|
required String path,
|
||||||
required String title,
|
required String title,
|
||||||
@@ -27,14 +48,143 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
if (isCueVirtualPath(path)) {
|
if (isCueVirtualPath(path)) {
|
||||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await _useInternalPlayer()) {
|
||||||
|
_log.d('Playing "$title" in the internal player: $path');
|
||||||
|
await ref
|
||||||
|
.read(musicPlayerControllerProvider)
|
||||||
|
.playSingle(
|
||||||
|
PlayableMedia(
|
||||||
|
id: path,
|
||||||
|
source: path,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
artUri: _normalizeArtUri(coverUrl),
|
||||||
|
duration: (track != null && track.duration > 0)
|
||||||
|
? Duration(seconds: track.duration)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_log.d('Opening external player for "$title" by $artist: $path');
|
_log.d('Opening external player for "$title" by $artist: $path');
|
||||||
await openFile(path);
|
await openFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plays a local-library album/list starting at [startItem], queuing the rest
|
||||||
|
/// so playback continues to the next track automatically. Honors player mode.
|
||||||
|
Future<void> playLocalLibraryQueue(
|
||||||
|
List<LocalLibraryItem> items, {
|
||||||
|
required LocalLibraryItem startItem,
|
||||||
|
}) async {
|
||||||
|
final playable = items
|
||||||
|
.where(
|
||||||
|
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (playable.isEmpty) return;
|
||||||
|
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||||
|
if (startIndex < 0) startIndex = 0;
|
||||||
|
|
||||||
|
if (await _useInternalPlayer()) {
|
||||||
|
await ref
|
||||||
|
.read(musicPlayerControllerProvider)
|
||||||
|
.playLocal(playable, initialIndex: startIndex);
|
||||||
|
} else {
|
||||||
|
await openFile(playable[startIndex].filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays a downloaded-history album/list starting at [startItem], queuing the
|
||||||
|
/// rest. Honors player mode.
|
||||||
|
Future<void> playHistoryQueue(
|
||||||
|
List<DownloadHistoryItem> items, {
|
||||||
|
required DownloadHistoryItem startItem,
|
||||||
|
}) async {
|
||||||
|
final playable = items
|
||||||
|
.where(
|
||||||
|
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (playable.isEmpty) return;
|
||||||
|
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||||
|
if (startIndex < 0) startIndex = 0;
|
||||||
|
|
||||||
|
if (await _useInternalPlayer()) {
|
||||||
|
await ref
|
||||||
|
.read(musicPlayerControllerProvider)
|
||||||
|
.playHistory(playable, initialIndex: startIndex);
|
||||||
|
} else {
|
||||||
|
await openFile(playable[startIndex].filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays a prebuilt media queue starting at [startIndex]. Honors player mode
|
||||||
|
/// ([externalPath] is opened externally when the built-in player is off).
|
||||||
|
Future<void> playMediaQueue(
|
||||||
|
Iterable<PlayableMedia> queue, {
|
||||||
|
required int startIndex,
|
||||||
|
required String externalPath,
|
||||||
|
}) async {
|
||||||
|
if (await _useInternalPlayer()) {
|
||||||
|
final items = queue.toList(growable: false);
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
final i = startIndex.clamp(0, items.length - 1);
|
||||||
|
await ref
|
||||||
|
.read(musicPlayerControllerProvider)
|
||||||
|
.playAll(items, initialIndex: i);
|
||||||
|
} else {
|
||||||
|
await openFile(externalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
|
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||||
|
|
||||||
|
if (await _useInternalPlayer()) {
|
||||||
|
final queue = <PlayableMedia>[];
|
||||||
|
var skippedCueVirtualTrack = false;
|
||||||
|
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
|
||||||
|
for (var index = 0; index < orderedTracks.length; index++) {
|
||||||
|
final track = orderedTracks[index];
|
||||||
|
final resolvedPath = resolvedPaths[index];
|
||||||
|
if (resolvedPath == null) continue;
|
||||||
|
if (isCueVirtualPath(resolvedPath)) {
|
||||||
|
skippedCueVirtualTrack = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
queue.add(
|
||||||
|
PlayableMedia(
|
||||||
|
id: resolvedPath,
|
||||||
|
source: resolvedPath,
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artistName,
|
||||||
|
album: track.albumName,
|
||||||
|
artUri: _normalizeArtUri(track.coverUrl ?? ''),
|
||||||
|
duration: track.duration > 0
|
||||||
|
? Duration(seconds: track.duration)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.isNotEmpty) {
|
||||||
|
_log.d('Playing ${queue.length} tracks in the internal player');
|
||||||
|
await ref.read(musicPlayerControllerProvider).playAll(queue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (skippedCueVirtualTrack) {
|
||||||
|
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||||
|
}
|
||||||
|
throw Exception(
|
||||||
|
'No local audio file is available to play. Download the track first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
var skippedCueVirtualTrack = false;
|
var skippedCueVirtualTrack = false;
|
||||||
for (final track in orderedTracks) {
|
for (final track in orderedTracks) {
|
||||||
final resolvedPath = await _resolveTrackPath(track);
|
final resolvedPath = await _resolveTrackPath(track);
|
||||||
@@ -98,6 +248,23 @@ class PlaybackController extends Notifier<PlaybackState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<String?>> _resolveTrackPaths(List<Track> tracks) async {
|
||||||
|
if (tracks.isEmpty) return const [];
|
||||||
|
final results = List<String?>.filled(tracks.length, null);
|
||||||
|
var next = 0;
|
||||||
|
final workerCount = tracks.length < 4 ? tracks.length : 4;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (true) {
|
||||||
|
final index = next++;
|
||||||
|
if (index >= tracks.length) return;
|
||||||
|
results[index] = await _resolveTrackPath(tracks[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
|
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
|
||||||
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
|
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
|
||||||
if (isLocalSource) {
|
if (isLocalSource) {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('PreviewPlayer');
|
||||||
|
|
||||||
|
enum PreviewStatus { idle, loading, playing, paused }
|
||||||
|
|
||||||
|
class PreviewPlayerState {
|
||||||
|
final String? activeUrl;
|
||||||
|
final PreviewStatus status;
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const PreviewPlayerState({
|
||||||
|
this.activeUrl,
|
||||||
|
this.status = PreviewStatus.idle,
|
||||||
|
this.position = Duration.zero,
|
||||||
|
this.duration = Duration.zero,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isActive => activeUrl != null && activeUrl!.isNotEmpty;
|
||||||
|
|
||||||
|
bool isActiveUrl(String? url) =>
|
||||||
|
url != null && url.isNotEmpty && url == activeUrl;
|
||||||
|
|
||||||
|
double get progress {
|
||||||
|
final total = duration.inMilliseconds;
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return (position.inMilliseconds / total).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewPlayerState copyWith({
|
||||||
|
String? activeUrl,
|
||||||
|
bool clearActiveUrl = false,
|
||||||
|
PreviewStatus? status,
|
||||||
|
Duration? position,
|
||||||
|
Duration? duration,
|
||||||
|
}) {
|
||||||
|
return PreviewPlayerState(
|
||||||
|
activeUrl: clearActiveUrl ? null : (activeUrl ?? this.activeUrl),
|
||||||
|
status: status ?? this.status,
|
||||||
|
position: position ?? this.position,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreviewPlayerController extends Notifier<PreviewPlayerState> {
|
||||||
|
AudioPlayer? _player;
|
||||||
|
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||||
|
AppLifecycleListener? _lifecycleListener;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PreviewPlayerState build() {
|
||||||
|
_lifecycleListener = AppLifecycleListener(
|
||||||
|
onStateChange: _handleAppLifecycleState,
|
||||||
|
);
|
||||||
|
musicPlayerExclusiveAudioHook = () async {
|
||||||
|
if (state.isActive) await stop();
|
||||||
|
};
|
||||||
|
ref.onDispose(() {
|
||||||
|
musicPlayerExclusiveAudioHook = null;
|
||||||
|
_disposePlayer();
|
||||||
|
});
|
||||||
|
return const PreviewPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAppLifecycleState(AppLifecycleState lifecycleState) {
|
||||||
|
if (lifecycleState == AppLifecycleState.paused ||
|
||||||
|
lifecycleState == AppLifecycleState.hidden ||
|
||||||
|
lifecycleState == AppLifecycleState.detached) {
|
||||||
|
if (state.isActive) {
|
||||||
|
unawaited(stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioPlayer _ensurePlayer() {
|
||||||
|
final existing = _player;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final player = AudioPlayer(playerId: 'preview-player');
|
||||||
|
player.setReleaseMode(ReleaseMode.stop);
|
||||||
|
_attachListeners(player);
|
||||||
|
_player = player;
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attachListeners(AudioPlayer player) {
|
||||||
|
_subscriptions.add(
|
||||||
|
player.onPlayerStateChanged.listen(_handlePlayerStateChanged),
|
||||||
|
);
|
||||||
|
_subscriptions.add(
|
||||||
|
player.onPositionChanged.listen((position) {
|
||||||
|
if (state.status == PreviewStatus.playing ||
|
||||||
|
state.status == PreviewStatus.paused) {
|
||||||
|
state = state.copyWith(position: position);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_subscriptions.add(
|
||||||
|
player.onDurationChanged.listen((duration) {
|
||||||
|
state = state.copyWith(duration: duration);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_subscriptions.add(
|
||||||
|
player.onPlayerComplete.listen((_) {
|
||||||
|
_log.d('Preview playback completed');
|
||||||
|
state = const PreviewPlayerState();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _discardActivePlayer() {
|
||||||
|
for (final sub in _subscriptions) {
|
||||||
|
sub.cancel();
|
||||||
|
}
|
||||||
|
_subscriptions.clear();
|
||||||
|
final player = _player;
|
||||||
|
_player = null;
|
||||||
|
if (player != null) {
|
||||||
|
try {
|
||||||
|
player.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePlayerStateChanged(PlayerState playerState) {
|
||||||
|
switch (playerState) {
|
||||||
|
case PlayerState.playing:
|
||||||
|
state = state.copyWith(status: PreviewStatus.playing);
|
||||||
|
break;
|
||||||
|
case PlayerState.paused:
|
||||||
|
if (state.isActive) {
|
||||||
|
state = state.copyWith(status: PreviewStatus.paused);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PlayerState.stopped:
|
||||||
|
case PlayerState.completed:
|
||||||
|
break;
|
||||||
|
case PlayerState.disposed:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggle(String? url) async {
|
||||||
|
final trimmed = url?.trim() ?? '';
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
|
||||||
|
if (state.isActiveUrl(trimmed)) {
|
||||||
|
if (state.status == PreviewStatus.playing) {
|
||||||
|
await pause();
|
||||||
|
} else if (state.status == PreviewStatus.paused) {
|
||||||
|
await resume();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await play(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play(String url) async {
|
||||||
|
final trimmed = url.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await musicPlayerHandler?.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
state = PreviewPlayerState(
|
||||||
|
activeUrl: trimmed,
|
||||||
|
status: PreviewStatus.loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
_log.i('Starting preview playback');
|
||||||
|
await _playOnPlayer(_ensurePlayer(), trimmed);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Preview playback failed, recreating player and retrying: $e');
|
||||||
|
_discardActivePlayer();
|
||||||
|
try {
|
||||||
|
await _playOnPlayer(_ensurePlayer(), trimmed);
|
||||||
|
} catch (retryError) {
|
||||||
|
_log.e('Preview playback failed after retry', retryError);
|
||||||
|
_discardActivePlayer();
|
||||||
|
state = const PreviewPlayerState();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playOnPlayer(AudioPlayer player, String url) async {
|
||||||
|
await player.stop();
|
||||||
|
await player.play(UrlSource(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
final player = _player;
|
||||||
|
if (player == null) return;
|
||||||
|
try {
|
||||||
|
await player.pause();
|
||||||
|
state = state.copyWith(status: PreviewStatus.paused);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to pause preview: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
final player = _player;
|
||||||
|
if (player == null || !state.isActive) return;
|
||||||
|
try {
|
||||||
|
await player.resume();
|
||||||
|
state = state.copyWith(status: PreviewStatus.playing);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to resume preview: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
final player = _player;
|
||||||
|
if (player == null) {
|
||||||
|
state = const PreviewPlayerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await player.stop();
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to stop preview: $e');
|
||||||
|
}
|
||||||
|
state = const PreviewPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disposePlayer() {
|
||||||
|
_lifecycleListener?.dispose();
|
||||||
|
_lifecycleListener = null;
|
||||||
|
_discardActivePlayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewPlayerProvider =
|
||||||
|
NotifierProvider<PreviewPlayerController, PreviewPlayerState>(
|
||||||
|
PreviewPlayerController.new,
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -27,6 +28,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
'album',
|
'album',
|
||||||
'playlist',
|
'playlist',
|
||||||
};
|
};
|
||||||
|
static const Set<String> _extensionVerificationBrowserModeValues = {
|
||||||
|
'external_first',
|
||||||
|
'in_app_first',
|
||||||
|
};
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
@@ -78,6 +83,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||||
defaultService: loaded.defaultService,
|
defaultService: loaded.defaultService,
|
||||||
searchProvider: loaded.searchProvider,
|
searchProvider: loaded.searchProvider,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
_normalizeExtensionVerificationBrowserMode(
|
||||||
|
loaded.extensionVerificationBrowserMode,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
@@ -96,23 +105,29 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _syncLyricsSettingsToBackend() {
|
void _syncLyricsSettingsToBackend() {
|
||||||
|
unawaited(syncLyricsSettingsToBackend());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> syncLyricsSettingsToBackend() async {
|
||||||
if (!PlatformBridge.supportsCoreBackend) return;
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
|
try {
|
||||||
Object e,
|
await PlatformBridge.setLyricsProviders(state.lyricsProviders);
|
||||||
) {
|
} catch (e) {
|
||||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||||
});
|
}
|
||||||
|
|
||||||
PlatformBridge.setLyricsFetchOptions({
|
try {
|
||||||
'include_translation_netease': state.lyricsIncludeTranslationNetease,
|
await PlatformBridge.setLyricsFetchOptions({
|
||||||
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
'include_translation_netease': state.lyricsIncludeTranslationNetease,
|
||||||
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||||
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
|
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||||
'musixmatch_language': state.musixmatchLanguage,
|
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
|
||||||
}).catchError((Object e) {
|
'musixmatch_language': state.musixmatchLanguage,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||||
@@ -125,6 +140,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
).catchError((Object e) {
|
).catchError((Object e) {
|
||||||
_log.w('Failed to sync network compatibility options to backend: $e');
|
_log.w('Failed to sync network compatibility options to backend: $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
PlatformBridge.setAllowPrivateNetwork(state.allowLocalNetwork).catchError((
|
||||||
|
Object e,
|
||||||
|
) {
|
||||||
|
_log.w('Failed to sync allow local network option to backend: $e');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncExtensionFallbackSettingsToBackend() {
|
void _syncExtensionFallbackSettingsToBackend() {
|
||||||
@@ -194,6 +215,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores settings from a backup payload (the map produced by
|
||||||
|
/// [AppSettings.toJson]). Device-specific storage location fields
|
||||||
|
/// (download directory and SAF tree URI) are intentionally preserved from the
|
||||||
|
/// current device, because a SAF tree URI from another phone is not valid
|
||||||
|
/// here and would break downloads.
|
||||||
|
Future<void> restoreFromBackup(Map<String, dynamic> json) async {
|
||||||
|
final current = state;
|
||||||
|
AppSettings restored;
|
||||||
|
try {
|
||||||
|
restored = AppSettings.fromJson(Map<String, dynamic>.from(json));
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.e('Failed to parse settings from backup: $e', e, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = restored.copyWith(
|
||||||
|
// Always keep extension providers enabled (matches _loadSettings).
|
||||||
|
useExtensionProviders: true,
|
||||||
|
// Preserve this device's storage location; the backup's values point at
|
||||||
|
// the original device and would not resolve here.
|
||||||
|
downloadDirectory: current.downloadDirectory,
|
||||||
|
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
|
||||||
|
storageMode: current.storageMode,
|
||||||
|
downloadTreeUri: current.downloadTreeUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveSettings();
|
||||||
|
|
||||||
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
_syncNetworkCompatibilitySettingsToBackend();
|
||||||
|
_syncExtensionFallbackSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||||
if (!Platform.isIOS) return;
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
@@ -223,6 +278,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
return 'all';
|
return 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _normalizeExtensionVerificationBrowserMode(String value) {
|
||||||
|
final normalized = value.trim().toLowerCase();
|
||||||
|
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return 'in_app_first';
|
||||||
|
}
|
||||||
|
|
||||||
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
|
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
|
||||||
final normalized = providerId?.trim().toLowerCase();
|
final normalized = providerId?.trim().toLowerCase();
|
||||||
if (normalized == null || normalized.isEmpty) return providerId;
|
if (normalized == null || normalized.isEmpty) return providerId;
|
||||||
@@ -510,6 +573,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setExtensionVerificationBrowserMode(String mode) {
|
||||||
|
state = state.copyWith(
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
_normalizeExtensionVerificationBrowserMode(mode),
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setLocale(String locale) {
|
void setLocale(String locale) {
|
||||||
state = state.copyWith(locale: locale);
|
state = state.copyWith(locale: locale);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -541,6 +612,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_syncNetworkCompatibilitySettingsToBackend();
|
_syncNetworkCompatibilitySettingsToBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setAllowLocalNetwork(bool enabled) {
|
||||||
|
state = state.copyWith(allowLocalNetwork: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_syncNetworkCompatibilitySettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
void setSongLinkRegion(String region) {
|
void setSongLinkRegion(String region) {
|
||||||
final normalized = _normalizeSongLinkRegion(region);
|
final normalized = _normalizeSongLinkRegion(region);
|
||||||
state = state.copyWith(songLinkRegion: normalized);
|
state = state.copyWith(songLinkRegion: normalized);
|
||||||
@@ -599,6 +676,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(saveDownloadHistory: enabled);
|
state = state.copyWith(saveDownloadHistory: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setPlayerMode(String mode) {
|
||||||
|
final normalized = mode == 'internal' ? 'internal' : 'external';
|
||||||
|
state = state.copyWith(playerMode: normalized);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
|
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
@@ -20,6 +23,7 @@ class TrackState {
|
|||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl;
|
final String? headerImageUrl;
|
||||||
|
final String? headerVideoUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? artistAlbums;
|
final List<ArtistAlbum>? artistAlbums;
|
||||||
final List<Track>? artistTopTracks;
|
final List<Track>? artistTopTracks;
|
||||||
@@ -43,6 +47,7 @@ class TrackState {
|
|||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.headerImageUrl,
|
this.headerImageUrl,
|
||||||
|
this.headerVideoUrl,
|
||||||
this.monthlyListeners,
|
this.monthlyListeners,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
this.artistTopTracks,
|
this.artistTopTracks,
|
||||||
@@ -74,6 +79,7 @@ class TrackState {
|
|||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
String? headerImageUrl,
|
String? headerImageUrl,
|
||||||
|
String? headerVideoUrl,
|
||||||
int? monthlyListeners,
|
int? monthlyListeners,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
List<Track>? artistTopTracks,
|
List<Track>? artistTopTracks,
|
||||||
@@ -99,6 +105,7 @@ class TrackState {
|
|||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||||
|
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
|
||||||
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||||
@@ -304,6 +311,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
|
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
|
||||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||||
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
|
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
|
||||||
|
headerVideoUrl: normalizeRemoteHttpUrl(
|
||||||
|
result['header_video']?.toString(),
|
||||||
|
),
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -314,7 +324,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
final topTracksList =
|
||||||
|
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
final topTracks = topTracksList
|
final topTracks = topTracksList
|
||||||
.map(
|
.map(
|
||||||
(t) => _parseSearchTrack(
|
(t) => _parseSearchTrack(
|
||||||
@@ -335,6 +346,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
headerImageUrl: normalizeRemoteHttpUrl(
|
headerImageUrl: normalizeRemoteHttpUrl(
|
||||||
artistData['header_image']?.toString(),
|
artistData['header_image']?.toString(),
|
||||||
),
|
),
|
||||||
|
headerVideoUrl: normalizeRemoteHttpUrl(
|
||||||
|
artistData['header_video']?.toString(),
|
||||||
|
),
|
||||||
monthlyListeners: artistData['listeners'] as int?,
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||||
@@ -359,10 +373,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(
|
Future<void> search(String query, {String? filterOverride}) async {
|
||||||
String query, {
|
|
||||||
String? filterOverride,
|
|
||||||
}) async {
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
final requestFilter = currentFilter == 'all' ? null : currentFilter;
|
final requestFilter = currentFilter == 'all' ? null : currentFilter;
|
||||||
@@ -560,6 +571,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
String query, {
|
String query, {
|
||||||
Map<String, dynamic>? options,
|
Map<String, dynamic>? options,
|
||||||
String? selectedFilter,
|
String? selectedFilter,
|
||||||
|
bool allowVerificationRetry = true,
|
||||||
}) async {
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
|
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
|
||||||
@@ -602,6 +614,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final previewCount = tracks.where((t) => t.hasPreview).length;
|
||||||
|
_log.d(
|
||||||
|
'Custom search preview availability: $previewCount/${tracks.length} tracks have preview_url'
|
||||||
|
'${results.isNotEmpty ? '; first raw keys=${(results.first).keys.toList()}' : ''}',
|
||||||
|
);
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: [],
|
searchArtists: [],
|
||||||
@@ -614,6 +632,33 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Custom search failed: $e', e, stackTrace);
|
_log.e('Custom search failed: $e', e, stackTrace);
|
||||||
|
if (allowVerificationRetry && isExtensionVerificationRequired(e)) {
|
||||||
|
_log.i(
|
||||||
|
'Custom search requires verification; waiting for $extensionId grant',
|
||||||
|
);
|
||||||
|
state = TrackState(
|
||||||
|
isLoading: true,
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
|
searchExtensionId: extensionId,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
|
final verified = await _openVerificationAndWait(extensionId);
|
||||||
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
if (verified) {
|
||||||
|
_log.i(
|
||||||
|
'Verification complete for $extensionId; retrying custom search',
|
||||||
|
);
|
||||||
|
await customSearch(
|
||||||
|
extensionId,
|
||||||
|
query,
|
||||||
|
options: options,
|
||||||
|
selectedFilter: currentFilter,
|
||||||
|
allowVerificationRetry: false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
@@ -624,6 +669,55 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _openVerificationAndWait(String extensionId) async {
|
||||||
|
final normalizedExtensionId = extensionId.trim();
|
||||||
|
if (normalizedExtensionId.isEmpty) return false;
|
||||||
|
|
||||||
|
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
|
||||||
|
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
|
||||||
|
grantSub = PlatformBridge.extensionSessionGrantEvents()
|
||||||
|
.where((event) => event.extensionId.trim() == normalizedExtensionId)
|
||||||
|
.listen((event) {
|
||||||
|
if (!grantCompleter.isCompleted) {
|
||||||
|
grantCompleter.complete(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final browserMode = ref
|
||||||
|
.read(settingsProvider)
|
||||||
|
.extensionVerificationBrowserMode;
|
||||||
|
Uri? authUri;
|
||||||
|
Timer? helpDialogTimer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final opened = await openPendingExtensionVerification(
|
||||||
|
normalizedExtensionId,
|
||||||
|
browserMode: browserMode,
|
||||||
|
onAuthUri: (uri) => authUri = uri,
|
||||||
|
);
|
||||||
|
if (!opened) return false;
|
||||||
|
|
||||||
|
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
|
||||||
|
normalizedExtensionId,
|
||||||
|
authUri,
|
||||||
|
browserMode: browserMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
final event = await grantCompleter.future.timeout(
|
||||||
|
const Duration(minutes: 5),
|
||||||
|
);
|
||||||
|
return event.success;
|
||||||
|
} on TimeoutException {
|
||||||
|
_log.w(
|
||||||
|
'Timed out waiting for verification grant: $normalizedExtensionId',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
helpDialogTimer?.cancel();
|
||||||
|
await grantSub.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> checkAvailability(int index) async {
|
Future<void> checkAvailability(int index) async {
|
||||||
if (index < 0 || index >= state.tracks.length) return;
|
if (index < 0 || index >= state.tracks.length) return;
|
||||||
|
|
||||||
@@ -751,6 +845,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
audioQuality: data['audio_quality']?.toString(),
|
audioQuality: data['audio_quality']?.toString(),
|
||||||
audioModes: data['audio_modes']?.toString(),
|
audioModes: data['audio_modes']?.toString(),
|
||||||
|
previewUrl: data['preview_url']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +921,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
+341
-122
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
@@ -23,6 +24,8 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
|||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||||
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
|
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/preview_button.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||||
|
|
||||||
class _AlbumCache {
|
class _AlbumCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
@@ -53,6 +56,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
|||||||
final String albumId;
|
final String albumId;
|
||||||
final String albumName;
|
final String albumName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final String? headerVideoUrl;
|
||||||
|
final String? headerImageUrl;
|
||||||
|
final List<String>? audioTraits;
|
||||||
final List<Track>? tracks;
|
final List<Track>? tracks;
|
||||||
final String? extensionId;
|
final String? extensionId;
|
||||||
final String? artistId;
|
final String? artistId;
|
||||||
@@ -63,6 +69,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
|||||||
required this.albumId,
|
required this.albumId,
|
||||||
required this.albumName,
|
required this.albumName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerVideoUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.audioTraits,
|
||||||
this.tracks,
|
this.tracks,
|
||||||
this.extensionId,
|
this.extensionId,
|
||||||
this.artistId,
|
this.artistId,
|
||||||
@@ -81,6 +90,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
String? _artistId;
|
String? _artistId;
|
||||||
String? _albumType;
|
String? _albumType;
|
||||||
int? _albumTotalTracks;
|
int? _albumTotalTracks;
|
||||||
|
String? _headerVideoUrl;
|
||||||
|
String? _headerImageUrl;
|
||||||
|
List<String> _audioTraits = const [];
|
||||||
|
bool _tallHeader = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
String _legacyProviderIdFromResourceId(String value) {
|
String _legacyProviderIdFromResourceId(String value) {
|
||||||
@@ -139,6 +152,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_artistId = widget.artistId;
|
_artistId = widget.artistId;
|
||||||
_albumType = _tracks?.firstOrNull?.albumType;
|
_albumType = _tracks?.firstOrNull?.albumType;
|
||||||
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
|
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
|
||||||
|
_headerVideoUrl = widget.headerVideoUrl;
|
||||||
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
|
_audioTraits = widget.audioTraits ?? const [];
|
||||||
|
|
||||||
if (_tracks == null || _tracks!.isEmpty) {
|
if (_tracks == null || _tracks!.isEmpty) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
@@ -153,7 +169,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = _calculateExpandedHeight(context, tall: _tallHeader);
|
||||||
final shouldShow =
|
final shouldShow =
|
||||||
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
@@ -161,9 +177,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context) {
|
double _calculateExpandedHeight(BuildContext context, {bool tall = false}) {
|
||||||
final mediaSize = MediaQuery.of(context).size;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
if (tall) {
|
||||||
|
return (mediaSize.height * 0.68).clamp(440.0, 660.0);
|
||||||
|
}
|
||||||
|
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _highResCoverUrl(String? url) {
|
String? _highResCoverUrl(String? url) {
|
||||||
@@ -214,6 +233,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
albumInfo?['album_type']?.toString(),
|
albumInfo?['album_type']?.toString(),
|
||||||
);
|
);
|
||||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final headerVideo = albumInfo?['header_video']?.toString();
|
||||||
|
final headerImage = albumInfo?['header_image']?.toString();
|
||||||
|
final audioTraits = (albumInfo?['audio_traits'] as List?)
|
||||||
|
?.map((e) => e.toString())
|
||||||
|
.toList();
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
.map(
|
.map(
|
||||||
(t) => _parseTrack(
|
(t) => _parseTrack(
|
||||||
@@ -232,6 +256,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_albumType = albumType;
|
_albumType = albumType;
|
||||||
_albumTotalTracks = totalTracks;
|
_albumTotalTracks = totalTracks;
|
||||||
|
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||||
|
? headerVideo
|
||||||
|
: _headerVideoUrl;
|
||||||
|
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
|
||||||
|
? headerImage
|
||||||
|
: _headerImageUrl;
|
||||||
|
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
|
||||||
|
? audioTraits
|
||||||
|
: _audioTraits;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,6 +284,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
albumInfo?['album_type']?.toString(),
|
albumInfo?['album_type']?.toString(),
|
||||||
);
|
);
|
||||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final headerVideo =
|
||||||
|
(albumInfo?['header_video'] ?? result['header_video'])?.toString();
|
||||||
|
final headerImage =
|
||||||
|
(albumInfo?['header_image'] ?? result['header_image'])?.toString();
|
||||||
|
final audioTraits =
|
||||||
|
((albumInfo?['audio_traits'] ?? result['audio_traits']) as List?)
|
||||||
|
?.map((e) => e.toString())
|
||||||
|
.toList();
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
.map(
|
.map(
|
||||||
(t) => _parseTrack(
|
(t) => _parseTrack(
|
||||||
@@ -269,6 +310,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_albumType = albumType;
|
_albumType = albumType;
|
||||||
_albumTotalTracks = totalTracks;
|
_albumTotalTracks = totalTracks;
|
||||||
|
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
||||||
|
? headerVideo
|
||||||
|
: _headerVideoUrl;
|
||||||
|
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
|
||||||
|
? headerImage
|
||||||
|
: _headerImageUrl;
|
||||||
|
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
|
||||||
|
? audioTraits
|
||||||
|
: _audioTraits;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,6 +343,101 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return _stripPrefixedResourceId(widget.albumId);
|
return _stripPrefixedResourceId(widget.albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _albumTitleFontSize() {
|
||||||
|
final length = widget.albumName.trim().length;
|
||||||
|
if (length > 45) return 18;
|
||||||
|
if (length > 30) return 21;
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _metaInlineItem(IconData? icon, String label) {
|
||||||
|
const textStyle = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
);
|
||||||
|
if (icon == null) {
|
||||||
|
return Text(label, style: textStyle);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 15, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(label, style: textStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _audioTraitInline() {
|
||||||
|
final traits = _audioTraits
|
||||||
|
.map((t) => t.toLowerCase().trim())
|
||||||
|
.where((t) => t.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
if (traits.isEmpty) return const [];
|
||||||
|
|
||||||
|
bool has(List<String> keys) => keys.any(traits.contains);
|
||||||
|
|
||||||
|
final items = <Widget>[];
|
||||||
|
if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
|
||||||
|
items.add(_metaInlineItem(Icons.surround_sound, 'Dolby Atmos'));
|
||||||
|
} else if (has(['spatial'])) {
|
||||||
|
items.add(_metaInlineItem(Icons.surround_sound, 'Spatial Audio'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
|
||||||
|
items.add(_metaInlineItem(Icons.graphic_eq, 'Hi-Res Lossless'));
|
||||||
|
} else if (has(['lossless'])) {
|
||||||
|
items.add(_metaInlineItem(Icons.graphic_eq, 'Lossless'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
|
||||||
|
final items = <Widget>[];
|
||||||
|
|
||||||
|
void add(Widget widget) {
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
items.add(
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text(
|
||||||
|
'•',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.add(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = _releaseYear(releaseDate);
|
||||||
|
if (year != null) {
|
||||||
|
add(_metaInlineItem(null, year));
|
||||||
|
}
|
||||||
|
for (final trait in _audioTraitInline()) {
|
||||||
|
add(trait);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: 20),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 0,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: items,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _releaseYear(String? date) {
|
||||||
|
if (date == null || date.isEmpty) return null;
|
||||||
|
final match = RegExp(r'(\d{4})').firstMatch(date);
|
||||||
|
return match?.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
Track _parseTrack(
|
Track _parseTrack(
|
||||||
Map<String, dynamic> data, {
|
Map<String, dynamic> data, {
|
||||||
String? albumTypeFallback,
|
String? albumTypeFallback,
|
||||||
@@ -325,6 +470,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
composer: data['composer']?.toString(),
|
composer: data['composer']?.toString(),
|
||||||
audioQuality: data['audio_quality']?.toString(),
|
audioQuality: data['audio_quality']?.toString(),
|
||||||
audioModes: data['audio_modes']?.toString(),
|
audioModes: data['audio_modes']?.toString(),
|
||||||
|
previewUrl: data['preview_url']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +508,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
_buildTrackList(context, colorScheme, tracks),
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
_buildAlbumFooter(context, colorScheme, tracks),
|
||||||
],
|
],
|
||||||
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
|
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
|
||||||
],
|
],
|
||||||
@@ -374,7 +521,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
Color pageBackgroundColor,
|
Color pageBackgroundColor,
|
||||||
) {
|
) {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
|
||||||
final tracks = _tracks ?? [];
|
final tracks = _tracks ?? [];
|
||||||
final artistName =
|
final artistName =
|
||||||
widget.artistName ??
|
widget.artistName ??
|
||||||
@@ -383,6 +529,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
: null);
|
: null);
|
||||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
|
|
||||||
|
final motionUrl = _headerVideoUrl ?? widget.headerVideoUrl;
|
||||||
|
final hasMotion =
|
||||||
|
motionUrl != null &&
|
||||||
|
motionUrl.trim().isNotEmpty &&
|
||||||
|
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
||||||
|
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
|
||||||
|
final showSquareCover = !hasMotion;
|
||||||
|
_tallHeader = false;
|
||||||
|
final expandedHeight = _calculateExpandedHeight(context);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -410,33 +566,46 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
final cacheWidth = coverCacheWidthForViewport(context);
|
final cacheWidth = coverCacheWidthForViewport(context);
|
||||||
|
final headerBgUrl =
|
||||||
|
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
|
||||||
|
final Widget headerBgImage = headerBgUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: _highResCoverUrl(headerBgUrl) ?? headerBgUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||||
|
errorWidget: (_, _, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 80,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.pin,
|
collapseMode: CollapseMode.pin,
|
||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (widget.coverUrl != null)
|
if (hasMotion)
|
||||||
CachedNetworkImage(
|
MotionHeaderBanner(
|
||||||
imageUrl:
|
videoUrl: motionUrl,
|
||||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
fallback: headerBgImage,
|
||||||
fit: BoxFit.cover,
|
)
|
||||||
memCacheWidth: cacheWidth,
|
else if (showSquareCover)
|
||||||
cacheManager: CoverCacheManager.instance,
|
ImageFiltered(
|
||||||
placeholder: (_, _) =>
|
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||||
Container(color: colorScheme.surface),
|
child: headerBgImage,
|
||||||
errorWidget: (_, _, _) =>
|
|
||||||
Container(color: colorScheme.surface),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(
|
headerBgImage,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
if (showSquareCover)
|
||||||
child: Icon(
|
Container(color: Colors.black.withValues(alpha: 0.35)),
|
||||||
Icons.album,
|
|
||||||
size: 80,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -466,11 +635,75 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (showSquareCover) ...[
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5)
|
||||||
|
.clamp(150.0, 210.0)
|
||||||
|
.toDouble();
|
||||||
|
return Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: coverThumbUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl:
|
||||||
|
_highResCoverUrl(coverThumbUrl) ??
|
||||||
|
coverThumbUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
cacheManager:
|
||||||
|
CoverCacheManager.instance,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, _, _) => Container(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 48,
|
||||||
|
color:
|
||||||
|
colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 24,
|
fontSize: _albumTitleFontSize(),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
@@ -495,106 +728,42 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (tracks.isNotEmpty) ...[
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
_buildHeaderMeta(context, releaseDate),
|
||||||
Wrap(
|
const SizedBox(height: 16),
|
||||||
alignment: WrapAlignment.center,
|
Row(
|
||||||
spacing: 8,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
runSpacing: 8,
|
children: [
|
||||||
children: [
|
_buildLoveAllButton(),
|
||||||
Container(
|
const SizedBox(width: 12),
|
||||||
padding: const EdgeInsets.symmetric(
|
Flexible(
|
||||||
horizontal: 12,
|
child: FilledButton.icon(
|
||||||
vertical: 6,
|
onPressed: tracks.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _downloadAll(context),
|
||||||
|
icon: const Icon(Icons.download, size: 18),
|
||||||
|
label: Text(
|
||||||
|
context.l10n.downloadAllCount(tracks.length),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
style: FilledButton.styleFrom(
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
backgroundColor: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
foregroundColor: Colors.black87,
|
||||||
),
|
disabledBackgroundColor: Colors.white
|
||||||
child: Row(
|
.withValues(alpha: 0.45),
|
||||||
mainAxisSize: MainAxisSize.min,
|
disabledForegroundColor: Colors.black54,
|
||||||
children: [
|
minimumSize: const Size(0, 48),
|
||||||
const Icon(
|
shape: RoundedRectangleBorder(
|
||||||
Icons.music_note,
|
borderRadius: BorderRadius.circular(24),
|
||||||
size: 14,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
context.l10n.tracksCount(tracks.length),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.calendar_today,
|
|
||||||
size: 14,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatReleaseDate(releaseDate),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_buildLoveAllButton(),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Flexible(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: () => _downloadAll(context),
|
|
||||||
icon: Icon(Icons.download, size: 18),
|
|
||||||
label: Text(
|
|
||||||
context.l10n.downloadAllCount(
|
|
||||||
tracks.length,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black87,
|
|
||||||
minimumSize: const Size(0, 48),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
_buildAddToPlaylistButton(context),
|
const SizedBox(width: 12),
|
||||||
],
|
_buildAddToPlaylistButton(context),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -641,6 +810,49 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumFooter(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<Track> tracks,
|
||||||
|
) {
|
||||||
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
|
final totalSeconds = tracks.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
|
||||||
|
);
|
||||||
|
final totalMinutes = (totalSeconds / 60).round();
|
||||||
|
|
||||||
|
final lines = <String>[];
|
||||||
|
if (releaseDate != null && releaseDate.isNotEmpty) {
|
||||||
|
lines.add(_formatReleaseDate(releaseDate));
|
||||||
|
}
|
||||||
|
final countText = context.l10n.tracksCount(tracks.length);
|
||||||
|
lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText);
|
||||||
|
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final line in lines)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
line,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(
|
Widget _buildTrackList(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
@@ -1072,6 +1284,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
artistId: track.artistId,
|
artistId: track.artistId,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
extensionId: track.source,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
@@ -1116,7 +1329,13 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: TrackCollectionQuickActions(track: track),
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
PreviewButton(track: track),
|
||||||
|
TrackCollectionQuickActions(track: track),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: () => _handleTap(context, ref, isQueued: isQueued),
|
onTap: () => _handleTap(context, ref, isQueued: isQueued),
|
||||||
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
|||||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||||
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
|
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
|
||||||
|
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
@@ -46,6 +47,7 @@ class _ArtistCache {
|
|||||||
List<ArtistAlbum>? releases,
|
List<ArtistAlbum>? releases,
|
||||||
List<Track>? topTracks,
|
List<Track>? topTracks,
|
||||||
String? headerImageUrl,
|
String? headerImageUrl,
|
||||||
|
String? headerVideoUrl,
|
||||||
int? monthlyListeners,
|
int? monthlyListeners,
|
||||||
}) {
|
}) {
|
||||||
_cache[artistId] = _CacheEntry(
|
_cache[artistId] = _CacheEntry(
|
||||||
@@ -53,6 +55,7 @@ class _ArtistCache {
|
|||||||
releases: releases,
|
releases: releases,
|
||||||
topTracks: topTracks,
|
topTracks: topTracks,
|
||||||
headerImageUrl: headerImageUrl,
|
headerImageUrl: headerImageUrl,
|
||||||
|
headerVideoUrl: headerVideoUrl,
|
||||||
monthlyListeners: monthlyListeners,
|
monthlyListeners: monthlyListeners,
|
||||||
expiresAt: DateTime.now().add(_ttl),
|
expiresAt: DateTime.now().add(_ttl),
|
||||||
);
|
);
|
||||||
@@ -64,6 +67,7 @@ class _CacheEntry {
|
|||||||
final List<ArtistAlbum>? releases;
|
final List<ArtistAlbum>? releases;
|
||||||
final List<Track>? topTracks;
|
final List<Track>? topTracks;
|
||||||
final String? headerImageUrl;
|
final String? headerImageUrl;
|
||||||
|
final String? headerVideoUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
final DateTime expiresAt;
|
final DateTime expiresAt;
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ class _CacheEntry {
|
|||||||
this.releases,
|
this.releases,
|
||||||
this.topTracks,
|
this.topTracks,
|
||||||
this.headerImageUrl,
|
this.headerImageUrl,
|
||||||
|
this.headerVideoUrl,
|
||||||
this.monthlyListeners,
|
this.monthlyListeners,
|
||||||
required this.expiresAt,
|
required this.expiresAt,
|
||||||
});
|
});
|
||||||
@@ -82,6 +87,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl;
|
final String? headerImageUrl;
|
||||||
|
final String? headerVideoUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? albums;
|
final List<ArtistAlbum>? albums;
|
||||||
final List<Track>? topTracks;
|
final List<Track>? topTracks;
|
||||||
@@ -93,6 +99,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.headerImageUrl,
|
this.headerImageUrl,
|
||||||
|
this.headerVideoUrl,
|
||||||
this.monthlyListeners,
|
this.monthlyListeners,
|
||||||
this.albums,
|
this.albums,
|
||||||
this.topTracks,
|
this.topTracks,
|
||||||
@@ -109,6 +116,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
List<ArtistAlbum>? _releases;
|
List<ArtistAlbum>? _releases;
|
||||||
List<Track>? _topTracks;
|
List<Track>? _topTracks;
|
||||||
String? _headerImageUrl;
|
String? _headerImageUrl;
|
||||||
|
String? _headerVideoUrl;
|
||||||
int? _monthlyListeners;
|
int? _monthlyListeners;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@@ -217,6 +225,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_albums = widget.albums;
|
_albums = widget.albums;
|
||||||
_topTracks = widget.topTracks;
|
_topTracks = widget.topTracks;
|
||||||
_headerImageUrl = widget.headerImageUrl;
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
|
_headerVideoUrl = widget.headerVideoUrl;
|
||||||
_monthlyListeners = widget.monthlyListeners;
|
_monthlyListeners = widget.monthlyListeners;
|
||||||
|
|
||||||
if ((_albums == null || _albums!.isEmpty) ||
|
if ((_albums == null || _albums!.isEmpty) ||
|
||||||
@@ -232,6 +241,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_albums = widget.albums;
|
_albums = widget.albums;
|
||||||
_topTracks = widget.topTracks;
|
_topTracks = widget.topTracks;
|
||||||
_headerImageUrl = widget.headerImageUrl;
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
|
_headerVideoUrl = widget.headerVideoUrl;
|
||||||
_monthlyListeners = widget.monthlyListeners;
|
_monthlyListeners = widget.monthlyListeners;
|
||||||
|
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||||
@@ -242,6 +252,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_releases = cached.releases;
|
_releases = cached.releases;
|
||||||
_topTracks = cached.topTracks;
|
_topTracks = cached.topTracks;
|
||||||
_headerImageUrl = cached.headerImageUrl;
|
_headerImageUrl = cached.headerImageUrl;
|
||||||
|
_headerVideoUrl = cached.headerVideoUrl;
|
||||||
_monthlyListeners = cached.monthlyListeners;
|
_monthlyListeners = cached.monthlyListeners;
|
||||||
|
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||||
@@ -274,6 +285,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
List<ArtistAlbum>? releases;
|
List<ArtistAlbum>? releases;
|
||||||
List<Track>? topTracks;
|
List<Track>? topTracks;
|
||||||
String? headerImage;
|
String? headerImage;
|
||||||
|
String? headerVideo;
|
||||||
int? listeners;
|
int? listeners;
|
||||||
|
|
||||||
if (_directMetadataProviderId() != null) {
|
if (_directMetadataProviderId() != null) {
|
||||||
@@ -310,6 +322,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
artistData['header_image'] as String? ??
|
artistData['header_image'] as String? ??
|
||||||
artistData['cover_url'] as String? ??
|
artistData['cover_url'] as String? ??
|
||||||
artistData['image_url'] as String?;
|
artistData['image_url'] as String?;
|
||||||
|
headerVideo =
|
||||||
|
artistInfo?['header_video'] as String? ??
|
||||||
|
artistData['header_video'] as String?;
|
||||||
listeners =
|
listeners =
|
||||||
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
|
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
@@ -332,6 +347,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headerImage = artistData['header_image'] as String?;
|
headerImage = artistData['header_image'] as String?;
|
||||||
|
headerVideo = artistData['header_video'] as String?;
|
||||||
listeners = artistData['listeners'] as int?;
|
listeners = artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
throw StateError('Failed to load artist metadata from extension');
|
throw StateError('Failed to load artist metadata from extension');
|
||||||
@@ -340,6 +356,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
final finalHeaderImage =
|
final finalHeaderImage =
|
||||||
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
||||||
|
final finalHeaderVideo =
|
||||||
|
headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl;
|
||||||
final finalListeners =
|
final finalListeners =
|
||||||
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
||||||
|
|
||||||
@@ -349,6 +367,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
releases: releases,
|
releases: releases,
|
||||||
topTracks: topTracks,
|
topTracks: topTracks,
|
||||||
headerImageUrl: finalHeaderImage,
|
headerImageUrl: finalHeaderImage,
|
||||||
|
headerVideoUrl: finalHeaderVideo,
|
||||||
monthlyListeners: finalListeners,
|
monthlyListeners: finalListeners,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -358,6 +377,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_releases = releases;
|
_releases = releases;
|
||||||
_topTracks = topTracks;
|
_topTracks = topTracks;
|
||||||
_headerImageUrl = finalHeaderImage;
|
_headerImageUrl = finalHeaderImage;
|
||||||
|
_headerVideoUrl = finalHeaderVideo;
|
||||||
_monthlyListeners = finalListeners;
|
_monthlyListeners = finalListeners;
|
||||||
_isLoadingDiscography = false;
|
_isLoadingDiscography = false;
|
||||||
});
|
});
|
||||||
@@ -410,6 +430,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||||
composer: data['composer']?.toString(),
|
composer: data['composer']?.toString(),
|
||||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||||
|
previewUrl: data['preview_url']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,6 +1148,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
imageUrl.isNotEmpty &&
|
imageUrl.isNotEmpty &&
|
||||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
|
String? headerVideoUrl = _headerVideoUrl;
|
||||||
|
if (headerVideoUrl == null || headerVideoUrl.isEmpty) {
|
||||||
|
headerVideoUrl = widget.headerVideoUrl;
|
||||||
|
}
|
||||||
|
final hasMotionBanner =
|
||||||
|
headerVideoUrl != null &&
|
||||||
|
headerVideoUrl.isNotEmpty &&
|
||||||
|
Uri.tryParse(headerVideoUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
String? listenersText;
|
String? listenersText;
|
||||||
@@ -1174,7 +1204,37 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (hasValidImage)
|
if (hasMotionBanner)
|
||||||
|
MotionHeaderBanner(
|
||||||
|
videoUrl: headerVideoUrl,
|
||||||
|
fallback: hasValidImage
|
||||||
|
? CachedCoverImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
memCacheWidth: 800,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 80,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 80,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (hasValidImage)
|
||||||
CachedCoverImage(
|
CachedCoverImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -1907,7 +1967,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
album.albumType == 'ep' ? 'EP' : 'Single',
|
album.albumType == 'ep'
|
||||||
|
? context.l10n.releaseTypeEp
|
||||||
|
: context.l10n.releaseTypeSingle,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -20,6 +22,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|||||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
@@ -97,7 +100,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
double _calculateExpandedHeight(BuildContext context) {
|
double _calculateExpandedHeight(BuildContext context) {
|
||||||
final mediaSize = MediaQuery.of(context).size;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _highResCoverUrl(String? url) {
|
String? _highResCoverUrl(String? url) {
|
||||||
@@ -269,16 +272,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFile(DownloadHistoryItem track) async {
|
Future<void> _openFile(
|
||||||
|
DownloadHistoryItem track, {
|
||||||
|
List<DownloadHistoryItem> queueItems = const [],
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackProvider.notifier)
|
||||||
.playLocalPath(
|
.playHistoryQueue(
|
||||||
path: track.filePath,
|
queueItems.isNotEmpty ? queueItems : [track],
|
||||||
title: track.trackName,
|
startItem: track,
|
||||||
artist: track.artistName,
|
|
||||||
album: track.albumName,
|
|
||||||
coverUrl: track.coverUrl ?? '',
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -502,26 +505,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (embeddedCoverPath != null)
|
if (embeddedCoverPath != null)
|
||||||
Image.file(
|
ImageFiltered(
|
||||||
File(embeddedCoverPath),
|
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||||
fit: BoxFit.cover,
|
child: Image.file(
|
||||||
cacheWidth: cacheWidth,
|
File(embeddedCoverPath),
|
||||||
gaplessPlayback: true,
|
fit: BoxFit.cover,
|
||||||
filterQuality: FilterQuality.low,
|
cacheWidth: cacheWidth,
|
||||||
errorBuilder: (_, _, _) =>
|
gaplessPlayback: true,
|
||||||
Container(color: colorScheme.surface),
|
filterQuality: FilterQuality.low,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else if (widget.coverUrl != null)
|
else if (widget.coverUrl != null)
|
||||||
CachedNetworkImage(
|
ImageFiltered(
|
||||||
imageUrl:
|
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
child: CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
imageUrl:
|
||||||
memCacheWidth: cacheWidth,
|
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||||
cacheManager: CoverCacheManager.instance,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, _) =>
|
memCacheWidth: cacheWidth,
|
||||||
Container(color: colorScheme.surface),
|
cacheManager: CoverCacheManager.instance,
|
||||||
errorWidget: (_, _, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
|
errorWidget: (_, _, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
@@ -532,6 +541,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (embeddedCoverPath != null || widget.coverUrl != null)
|
||||||
|
Container(color: Colors.black.withValues(alpha: 0.35)),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -561,11 +572,43 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final coverSize = (constraints.maxWidth * 0.5)
|
||||||
|
.clamp(150.0, 210.0)
|
||||||
|
.toDouble();
|
||||||
|
return Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: _buildSquareCover(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
embeddedCoverPath,
|
||||||
|
coverSize,
|
||||||
|
cacheWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 24,
|
fontSize: _albumTitleFontSize(),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
@@ -587,62 +630,49 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
if (tracks.isNotEmpty) ...[
|
if (tracks.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
_buildDownloadedHeaderMeta(
|
||||||
alignment: WrapAlignment.center,
|
context,
|
||||||
spacing: 8,
|
tracks,
|
||||||
runSpacing: 8,
|
commonQuality,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Flexible(
|
||||||
padding: const EdgeInsets.symmetric(
|
child: FilledButton.icon(
|
||||||
horizontal: 12,
|
onPressed: () => _playAll(tracks),
|
||||||
vertical: 6,
|
icon: const Icon(Icons.play_arrow, size: 20),
|
||||||
),
|
label: Text(
|
||||||
decoration: BoxDecoration(
|
context.l10n.tooltipPlay,
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
maxLines: 1,
|
||||||
borderRadius: BorderRadius.circular(20),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
child: Row(
|
style: FilledButton.styleFrom(
|
||||||
mainAxisSize: MainAxisSize.min,
|
backgroundColor: Colors.white,
|
||||||
children: [
|
foregroundColor: Colors.black87,
|
||||||
const Icon(
|
minimumSize: const Size(0, 48),
|
||||||
Icons.download_done,
|
shape: RoundedRectangleBorder(
|
||||||
size: 14,
|
borderRadius: BorderRadius.circular(24),
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
Text(
|
|
||||||
context.l10n
|
|
||||||
.downloadedAlbumDownloadedCount(
|
|
||||||
tracks.length,
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (commonQuality != null)
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
decoration: BoxDecoration(
|
||||||
horizontal: 12,
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
vertical: 6,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
child: IconButton(
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
tooltip: context.l10n.actionShuffle,
|
||||||
borderRadius: BorderRadius.circular(20),
|
onPressed: () => _shuffleAll(tracks),
|
||||||
),
|
icon: const Icon(
|
||||||
child: Text(
|
Icons.shuffle,
|
||||||
commonQuality,
|
color: Colors.white,
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -671,6 +701,132 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSquareCover(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
String? embeddedCoverPath,
|
||||||
|
double coverSize,
|
||||||
|
int cacheWidth,
|
||||||
|
) {
|
||||||
|
Widget placeholder() => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embeddedCoverPath != null) {
|
||||||
|
return Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
cacheWidth: cacheWidth,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => placeholder(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final coverUrl = widget.coverUrl;
|
||||||
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
placeholder: (_, _) => placeholder(),
|
||||||
|
errorWidget: (_, _, _) => placeholder(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _albumTitleFontSize() {
|
||||||
|
final length = widget.albumName.trim().length;
|
||||||
|
if (length > 45) return 18;
|
||||||
|
if (length > 30) return 21;
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _metaWhiteItem(IconData? icon, String label) {
|
||||||
|
const textStyle = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
);
|
||||||
|
if (icon == null) return Text(label, style: textStyle);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 15, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(label, style: textStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadedHeaderMeta(
|
||||||
|
BuildContext context,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
String? commonQuality,
|
||||||
|
) {
|
||||||
|
final totalSeconds = tracks.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
|
||||||
|
);
|
||||||
|
final totalMinutes = (totalSeconds / 60).round();
|
||||||
|
|
||||||
|
final parts = <Widget>[];
|
||||||
|
void add(Widget w) {
|
||||||
|
if (parts.isNotEmpty) {
|
||||||
|
parts.add(
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text(
|
||||||
|
'•',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.add(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
_metaWhiteItem(
|
||||||
|
null,
|
||||||
|
context.l10n.downloadedAlbumDownloadedCount(tracks.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min'));
|
||||||
|
if (commonQuality != null && commonQuality.isNotEmpty) {
|
||||||
|
add(_metaWhiteItem(Icons.graphic_eq, commonQuality));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: parts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playAll(List<DownloadHistoryItem> tracks) async {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
await ref.read(musicPlayerControllerProvider).setShuffle(false);
|
||||||
|
await _openFile(tracks.first, queueItems: tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shuffleAll(List<DownloadHistoryItem> tracks) async {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
await ref.read(musicPlayerControllerProvider).setShuffle(true);
|
||||||
|
await _openFile(
|
||||||
|
tracks[Random().nextInt(tracks.length)],
|
||||||
|
queueItems: tracks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(
|
Widget _buildInfoCard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
@@ -888,7 +1044,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
tooltip: context.l10n.tooltipPlay,
|
tooltip: context.l10n.tooltipPlay,
|
||||||
onPressed: () => _openFile(track),
|
onPressed: () =>
|
||||||
|
_openFile(track, queueItems: navigationItems),
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||||
@@ -947,6 +1104,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
) {
|
) {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final sourceFormats = <String>{};
|
final sourceFormats = <String>{};
|
||||||
|
final sourceBitDepths = <int?>[];
|
||||||
|
final sourceSampleRates = <int?>[];
|
||||||
for (final id in _selectedIds) {
|
for (final id in _selectedIds) {
|
||||||
final item = tracksById[id];
|
final item = tracksById[id];
|
||||||
if (item == null) continue;
|
if (item == null) continue;
|
||||||
@@ -956,6 +1115,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
fileName: item.safFileName,
|
fileName: item.safFileName,
|
||||||
);
|
);
|
||||||
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||||
|
sourceBitDepths.add(item.bitDepth);
|
||||||
|
sourceSampleRates.add(item.sampleRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
final formats = audioConversionTargetFormats
|
final formats = audioConversionTargetFormats
|
||||||
@@ -979,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
@@ -986,12 +1148,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
formats: formats,
|
formats: formats,
|
||||||
title: sheetTitle,
|
title: sheetTitle,
|
||||||
confirmLabel: sheetConfirmLabel,
|
confirmLabel: sheetConfirmLabel,
|
||||||
onConvert: (format, bitrate) {
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
|
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_performBatchConversion(
|
_performBatchConversion(
|
||||||
allTracks: allTracks,
|
allTracks: allTracks,
|
||||||
targetFormat: format,
|
targetFormat: format,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1002,6 +1168,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
required List<DownloadHistoryItem> allTracks,
|
required List<DownloadHistoryItem> allTracks,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
required String bitrate,
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality =
|
||||||
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <DownloadHistoryItem>[];
|
final selected = <DownloadHistoryItem>[];
|
||||||
@@ -1033,12 +1203,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||||
|
final losslessLabels = context.l10n.losslessConversionLabels;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
isLossless
|
isLossless && losslessQuality.hasCaps
|
||||||
|
? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
losslessQualityLabel(
|
||||||
|
losslessQuality,
|
||||||
|
originalLabel: losslessLabels.original,
|
||||||
|
originalQualityLabel: losslessLabels.originalQuality,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: isLossless
|
||||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
selected.length,
|
selected.length,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
@@ -1067,10 +1248,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality =
|
|
||||||
isLosslessConversionTarget(targetFormat)
|
|
||||||
? '${targetFormat.toUpperCase()} Lossless'
|
|
||||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
@@ -1147,6 +1324,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
artistTagMode: settings.artistTagMode,
|
artistTagMode: settings.artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
|
sourceBitDepth: item.bitDepth,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -1164,6 +1344,39 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||||
|
int? convertedBitDepth;
|
||||||
|
int? convertedSampleRate;
|
||||||
|
if (isLosslessOutput) {
|
||||||
|
try {
|
||||||
|
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||||
|
newPath,
|
||||||
|
);
|
||||||
|
if (convertedMetadata['error'] == null) {
|
||||||
|
convertedBitDepth = readPositiveAudioInt(
|
||||||
|
convertedMetadata['bit_depth'],
|
||||||
|
);
|
||||||
|
convertedSampleRate = readPositiveAudioInt(
|
||||||
|
convertedMetadata['sample_rate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
convertedBitDepth ??= losslessQuality.effectiveBitDepth(
|
||||||
|
item.bitDepth,
|
||||||
|
);
|
||||||
|
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
|
||||||
|
item.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final newQuality = convertedAudioQualityLabel(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
labels: losslessLabels,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
actualBitDepth: convertedBitDepth,
|
||||||
|
actualSampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
final treeUri = item.downloadTreeUri;
|
final treeUri = item.downloadTreeUri;
|
||||||
final relativeDir = item.safRelativeDir ?? '';
|
final relativeDir = item.safRelativeDir ?? '';
|
||||||
@@ -1213,7 +1426,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1234,7 +1449,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,9 +1496,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||||
content: Text(
|
content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)),
|
||||||
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
@@ -1331,9 +1546,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
|
||||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/preview_button.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
part 'home_tab_helpers.dart';
|
part 'home_tab_helpers.dart';
|
||||||
@@ -177,7 +178,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch for new homeFeed extension being installed/enabled after init
|
|
||||||
_homeFeedExtSub = ref.listenManual<bool>(
|
_homeFeedExtSub = ref.listenManual<bool>(
|
||||||
extensionProvider.select(
|
extensionProvider.select(
|
||||||
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
|
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
|
||||||
@@ -821,6 +821,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
artistId: trackState.artistId!,
|
artistId: trackState.artistId!,
|
||||||
artistName: trackState.artistName!,
|
artistName: trackState.artistName!,
|
||||||
coverUrl: trackState.coverUrl,
|
coverUrl: trackState.coverUrl,
|
||||||
|
headerImageUrl: trackState.headerImageUrl,
|
||||||
|
headerVideoUrl: trackState.headerVideoUrl,
|
||||||
albums: trackState.artistAlbums!,
|
albums: trackState.artistAlbums!,
|
||||||
extensionId: extensionId,
|
extensionId: extensionId,
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user