Compare commits

..

128 Commits

Author SHA1 Message Date
Zarz Eleutherius 91c141b7ee New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 04:25:28 +07:00
Zarz Eleutherius 4577ce5ca7 New translations app_en.arb (Spanish)
[ci skip]
2026-07-03 03:26:55 +07:00
Zarz Eleutherius 7d843ca02a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-03 01:58:22 +07:00
Zarz Eleutherius 89de08e08c New translations app_en.arb (Korean)
[ci skip]
2026-07-03 00:04:38 +07:00
Zarz Eleutherius 02213d1edf New translations app_en.arb (Korean)
[ci skip]
2026-07-02 21:58:18 +07:00
Zarz Eleutherius 9dedb66800 New translations app_en.arb (French)
[ci skip]
2026-07-02 14:36:38 +07:00
Zarz Eleutherius 24408e0cba New translations app_en.arb (Korean)
[ci skip]
2026-07-02 12:18:01 +07:00
Zarz Eleutherius 4d251a2a0d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 10:04:54 +07:00
Zarz Eleutherius 52972eefef New translations app_en.arb (Korean)
[ci skip]
2026-07-02 09:09:05 +07:00
Zarz Eleutherius 471b412dc5 New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 07:02:34 +07:00
Zarz Eleutherius 4c85a8f05e New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 05:43:37 +07:00
Zarz Eleutherius 0c441b2c86 New translations app_en.arb (Hindi)
[ci skip]
2026-07-02 02:32:59 +07:00
Zarz Eleutherius f6ddd62b13 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-02 02:32:58 +07:00
Zarz Eleutherius 5fd67e53c4 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-02 02:32:56 +07:00
Zarz Eleutherius 1206c57d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-02 02:32:54 +07:00
Zarz Eleutherius 38815fa0aa New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-02 02:32:52 +07:00
Zarz Eleutherius 959206b0be New translations app_en.arb (Turkish)
[ci skip]
2026-07-02 02:32:51 +07:00
Zarz Eleutherius 7b7ee523fd New translations app_en.arb (Russian)
[ci skip]
2026-07-02 02:32:49 +07:00
Zarz Eleutherius b1220b4c19 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-02 02:32:47 +07:00
Zarz Eleutherius 62b894a7ba New translations app_en.arb (Dutch)
[ci skip]
2026-07-02 02:32:45 +07:00
Zarz Eleutherius 45188ef87d New translations app_en.arb (Korean)
[ci skip]
2026-07-02 02:32:43 +07:00
Zarz Eleutherius 2185ccf2c9 New translations app_en.arb (Japanese)
[ci skip]
2026-07-02 02:32:42 +07:00
Zarz Eleutherius e8d95178c7 New translations app_en.arb (German)
[ci skip]
2026-07-02 02:32:40 +07:00
Zarz Eleutherius a3ebe27b76 New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 02:32:38 +07:00
Zarz Eleutherius b39d4fa15a New translations app_en.arb (Spanish)
[ci skip]
2026-07-02 02:32:37 +07:00
Zarz Eleutherius f86b2b816b New translations app_en.arb (French)
[ci skip]
2026-07-02 02:32:35 +07:00
Zarz Eleutherius 308a75037e New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:58:11 +07:00
Zarz Eleutherius bda41a0c1a New translations app_en.arb (Korean)
[ci skip]
2026-07-02 00:01:11 +07:00
Zarz Eleutherius ec5e7ed1cd New translations app_en.arb (Arabic)
[ci skip]
2026-07-02 00:01:10 +07:00
Zarz Eleutherius 6528d445d3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 22:52:54 +07:00
Zarz Eleutherius aacf1bef9c New translations app_en.arb (Korean)
[ci skip]
2026-07-01 21:38:38 +07:00
Zarz Eleutherius 4262553036 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 20:35:15 +07:00
Zarz Eleutherius a3d6c6f916 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 19:03:56 +07:00
Zarz Eleutherius bce05eb071 New translations app_en.arb (French)
[ci skip]
2026-07-01 19:03:54 +07:00
Zarz Eleutherius e35ea061d5 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 17:52:36 +07:00
Zarz Eleutherius 8c7c168fdf New translations app_en.arb (French)
[ci skip]
2026-07-01 17:52:34 +07:00
Zarz Eleutherius 7a811ad55e New translations app_en.arb (Korean)
[ci skip]
2026-07-01 16:56:33 +07:00
Zarz Eleutherius 8d29be2a6d New translations app_en.arb (Korean)
[ci skip]
2026-07-01 15:56:58 +07:00
Zarz Eleutherius 36567f40a1 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 14:31:17 +07:00
Zarz Eleutherius 76e0de7d20 New translations app_en.arb (French)
[ci skip]
2026-07-01 14:31:15 +07:00
Zarz Eleutherius 74a41a2d49 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 12:53:58 +07:00
Zarz Eleutherius 1ba82df9e3 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 11:36:48 +07:00
Zarz Eleutherius ccf995ec11 New translations app_en.arb (French)
[ci skip]
2026-07-01 05:21:22 +07:00
Zarz Eleutherius 159b7c2ab6 New translations app_en.arb (Hindi)
[ci skip]
2026-07-01 04:22:04 +07:00
Zarz Eleutherius dc60ad1137 New translations app_en.arb (Indonesian)
[ci skip]
2026-07-01 04:22:02 +07:00
Zarz Eleutherius 1c46708303 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-01 04:22:01 +07:00
Zarz Eleutherius 532042e39e New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-01 04:21:59 +07:00
Zarz Eleutherius 5cb6faa2d6 New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-01 04:21:57 +07:00
Zarz Eleutherius 91b88a40ea New translations app_en.arb (Turkish)
[ci skip]
2026-07-01 04:21:56 +07:00
Zarz Eleutherius 72a9691c55 New translations app_en.arb (Russian)
[ci skip]
2026-07-01 04:21:54 +07:00
Zarz Eleutherius 00e8280d22 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-01 04:21:52 +07:00
Zarz Eleutherius ed2c7606c3 New translations app_en.arb (Dutch)
[ci skip]
2026-07-01 04:21:51 +07:00
Zarz Eleutherius c1a6262d96 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 04:21:49 +07:00
Zarz Eleutherius 59b953d577 New translations app_en.arb (Japanese)
[ci skip]
2026-07-01 04:21:47 +07:00
Zarz Eleutherius 0854174140 New translations app_en.arb (German)
[ci skip]
2026-07-01 04:21:45 +07:00
Zarz Eleutherius 3802df0abd New translations app_en.arb (Arabic)
[ci skip]
2026-07-01 04:21:44 +07:00
Zarz Eleutherius 57f09cc50c New translations app_en.arb (Spanish)
[ci skip]
2026-07-01 04:21:42 +07:00
Zarz Eleutherius 5257a035d7 New translations app_en.arb (French)
[ci skip]
2026-07-01 04:21:40 +07:00
Zarz Eleutherius 0cab036718 New translations app_en.arb (Korean)
[ci skip]
2026-07-01 01:47:32 +07:00
Zarz Eleutherius 4fabd8cfd0 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 23:54:56 +07:00
Zarz Eleutherius 6b7f63d784 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 23:54:55 +07:00
Zarz Eleutherius 95b56c80c5 New translations app_en.arb (Korean)
[ci skip]
2026-06-30 21:51:26 +07:00
Zarz Eleutherius f2020b7653 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-30 17:01:36 +07:00
Zarz Eleutherius 97bb4f6641 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 17:01:34 +07:00
Zarz Eleutherius adeed5ae13 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-30 14:43:55 +07:00
Zarz Eleutherius 5e9cce4b8c New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 08:55:15 +07:00
Zarz Eleutherius 22a8fab1c7 New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 03:44:33 +07:00
Zarz Eleutherius f6a0cc9fad New translations app_en.arb (Arabic)
[ci skip]
2026-06-30 02:11:11 +07:00
Zarz Eleutherius 765f0d63b6 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-29 23:32:47 +07:00
Zarz Eleutherius c193e88990 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 22:21:01 +07:00
Zarz Eleutherius 6398d8f7eb New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 22:20:59 +07:00
Zarz Eleutherius 030b8c5459 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 20:52:21 +07:00
Zarz Eleutherius 469af1ac5d New translations app_en.arb (Korean)
[ci skip]
2026-06-29 18:37:22 +07:00
Zarz Eleutherius 6c635dfd01 New translations app_en.arb (Spanish)
[ci skip]
2026-06-29 16:35:21 +07:00
Zarz Eleutherius 299e93ffe3 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 15:38:14 +07:00
Zarz Eleutherius 4dc5b2ee30 New translations app_en.arb (Korean)
[ci skip]
2026-06-29 13:39:38 +07:00
Zarz Eleutherius 6613461eea New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 09:49:23 +07:00
Zarz Eleutherius 9de442b0c8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 08:39:04 +07:00
Zarz Eleutherius 6bc65fe559 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 07:35:09 +07:00
Zarz Eleutherius 7da423ee1b New translations app_en.arb (Korean)
[ci skip]
2026-06-29 06:39:15 +07:00
Zarz Eleutherius c6ccefbd72 New translations app_en.arb (Arabic)
[ci skip]
2026-06-29 06:39:13 +07:00
Zarz Eleutherius 72d25f576c New translations app_en.arb (Korean)
[ci skip]
2026-06-29 01:20:27 +07:00
Zarz Eleutherius 7ab9481fab New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 22:07:34 +07:00
Zarz Eleutherius f494e03a6e New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 20:57:58 +07:00
Zarz Eleutherius a90dce95a3 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 19:35:19 +07:00
Zarz Eleutherius a35f6ad939 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 18:37:44 +07:00
Zarz Eleutherius 80e704fd26 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 11:03:47 +07:00
Zarz Eleutherius 88fc958729 New translations app_en.arb (Arabic)
[ci skip]
2026-06-28 09:34:15 +07:00
Zarz Eleutherius 12d6940a6e New translations app_en.arb (Korean)
[ci skip]
2026-06-27 22:36:44 +07:00
Zarz Eleutherius 34bf726cc1 New translations app_en.arb (Korean)
[ci skip]
2026-06-27 21:33:08 +07:00
Zarz Eleutherius 68af217b7b New translations app_en.arb (Korean)
[ci skip]
2026-06-27 01:30:31 +07:00
Zarz Eleutherius 5eda1c4cb0 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 22:10:10 +07:00
Zarz Eleutherius f5c934f744 New translations app_en.arb (Turkish)
[ci skip]
2026-06-26 20:46:23 +07:00
Zarz Eleutherius 89c71f6b16 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 20:46:21 +07:00
Zarz Eleutherius 4523bc5532 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-26 19:18:41 +07:00
Zarz Eleutherius b9c66e0c7b New translations app_en.arb (Korean)
[ci skip]
2026-06-26 14:12:22 +07:00
Zarz Eleutherius f26c7cfe02 New translations app_en.arb (Korean)
[ci skip]
2026-06-26 11:13:48 +07:00
Zarz Eleutherius 3bbe29c2e8 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 19:21:07 +07:00
Zarz Eleutherius e0cc1f7cb2 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 07:08:49 +07:00
Zarz Eleutherius 9faa1b7961 New translations app_en.arb (Korean)
[ci skip]
2026-06-25 06:01:55 +07:00
Zarz Eleutherius 142d7e639b New translations app_en.arb (Korean)
[ci skip]
2026-06-25 00:45:57 +07:00
Zarz Eleutherius 9e115902b7 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 23:08:31 +07:00
Zarz Eleutherius d2ec68808c New translations app_en.arb (Korean)
[ci skip]
2026-06-24 21:57:39 +07:00
Zarz Eleutherius 352186eb40 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 11:55:48 +07:00
Zarz Eleutherius 68e742b670 New translations app_en.arb (Korean)
[ci skip]
2026-06-24 10:55:05 +07:00
Zarz Eleutherius d7c4586358 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 19:57:10 +07:00
Zarz Eleutherius 84f784e538 New translations app_en.arb (Korean)
[ci skip]
2026-06-23 16:02:14 +07:00
Zarz Eleutherius 5f9822f726 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 23:06:40 +07:00
Zarz Eleutherius 41a1b94811 New translations app_en.arb (Korean)
[ci skip]
2026-06-22 22:05:04 +07:00
Zarz Eleutherius e4b1d39c4e New translations app_en.arb (Korean)
[ci skip]
2026-06-22 20:58:17 +07:00
Zarz Eleutherius 136de95290 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-22 19:25:42 +07:00
Zarz Eleutherius 1afe66cb66 New translations app_en.arb (French)
[ci skip]
2026-06-21 23:58:16 +07:00
Zarz Eleutherius bdf1626273 New translations app_en.arb (French)
[ci skip]
2026-06-21 22:50:25 +07:00
Zarz Eleutherius 87131ad633 New translations app_en.arb (Hindi)
[ci skip]
2026-06-21 21:33:23 +07:00
Zarz Eleutherius 439418e419 New translations app_en.arb (Indonesian)
[ci skip]
2026-06-21 21:33:21 +07:00
Zarz Eleutherius 0eb4e15a8f New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-21 21:33:20 +07:00
Zarz Eleutherius 5f3928399c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-21 21:33:18 +07:00
Zarz Eleutherius 0c0f2d54ad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-21 21:33:17 +07:00
Zarz Eleutherius 2f54fe6cf6 New translations app_en.arb (Turkish)
[ci skip]
2026-06-21 21:33:15 +07:00
Zarz Eleutherius e827a26458 New translations app_en.arb (Russian)
[ci skip]
2026-06-21 21:33:14 +07:00
Zarz Eleutherius c886d55317 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-21 21:33:13 +07:00
Zarz Eleutherius 0189f576c7 New translations app_en.arb (Dutch)
[ci skip]
2026-06-21 21:33:11 +07:00
Zarz Eleutherius 13c46d0f5e New translations app_en.arb (Korean)
[ci skip]
2026-06-21 21:33:10 +07:00
Zarz Eleutherius 60175108df New translations app_en.arb (Japanese)
[ci skip]
2026-06-21 21:33:08 +07:00
Zarz Eleutherius 7c2b87f49a New translations app_en.arb (German)
[ci skip]
2026-06-21 21:33:07 +07:00
Zarz Eleutherius f33ee40779 New translations app_en.arb (Arabic)
[ci skip]
2026-06-21 21:33:05 +07:00
Zarz Eleutherius b9142bc40c New translations app_en.arb (Spanish)
[ci skip]
2026-06-21 21:33:04 +07:00
Zarz Eleutherius 680e0e0976 New translations app_en.arb (French)
[ci skip]
2026-06-21 21:33:02 +07:00
154 changed files with 26548 additions and 27231 deletions
+7 -5
View File
@@ -66,7 +66,7 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "25"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v6
@@ -388,6 +388,8 @@ jobs:
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
@@ -397,7 +399,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -563,7 +565,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -572,7 +574,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -582,7 +584,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+1 -3
View File
@@ -60,9 +60,7 @@ ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
# Extension folder
extension/*
extension/v2/
extension/v2/**
extension/
# Agent instructions
AGENTS.md
+5 -7
View File
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = 37
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
@@ -26,13 +26,13 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
@@ -50,7 +50,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 37
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -62,8 +62,6 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
-27
View File
@@ -100,12 +100,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</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>
<!-- Download Service -->
@@ -114,23 +108,6 @@
android:exported="false"
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 -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -147,10 +124,6 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,7 +1,6 @@
package com.zarz.spotiflac
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -18,7 +17,6 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -38,10 +36,6 @@ import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine {
return AudioServicePlugin.getFlutterEngine(context)
}
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
@@ -53,8 +47,6 @@ class MainActivity: FlutterFragmentActivity() {
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
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 val safScanLock = Any()
private val safDirLock = Any()
@@ -156,15 +148,8 @@ class MainActivity: FlutterFragmentActivity() {
"mali-t7",
"powervr sgx",
"powervr ge8320",
"vivante",
"gc1000",
"gc2000",
"gc4000",
"gc5000",
"gc7000",
"gc8000",
"gc820",
"gc880",
)
private val PROBLEMATIC_CHIPSETS = listOf(
@@ -178,15 +163,6 @@ class MainActivity: FlutterFragmentActivity() {
"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(
"sm-t220",
"sm-t225",
@@ -197,14 +173,6 @@ class MainActivity: FlutterFragmentActivity() {
val board = Build.BOARD.lowercase(Locale.ROOT)
val model = Build.MODEL.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) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
@@ -2081,22 +2049,14 @@ class MainActivity: FlutterFragmentActivity() {
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isSessionGrant = host == "session-grant"
val isCallback =
isSessionGrant ||
host == "callback" ||
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = (
if (isSessionGrant) {
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
} else {
uri.getQueryParameter("code")
}
)?.trim().orEmpty()
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
@@ -2108,43 +2068,15 @@ class MainActivity: FlutterFragmentActivity() {
intent.data = null
scope.launch(Dispatchers.IO) {
try {
val json = if (isSessionGrant) {
Gobackend.setExtensionSessionGrantByID(extId, code)
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)
}
}
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
if (isSessionGrant) {
withContext(Dispatchers.Main) {
notifySessionGrantCompleted(extId, false)
}
}
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
}
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() {
try {
Gobackend.cleanupExtensions()
@@ -2208,17 +2140,7 @@ class MainActivity: FlutterFragmentActivity() {
},
)
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 ->
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
@@ -2303,13 +2225,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setAllowPrivateNetwork" -> {
val allowed = call.argument<Boolean>("allowed") ?: false
withContext(Dispatchers.IO) {
Gobackend.setAllowPrivateNetwork(allowed)
}
result.success(null)
}
"checkDuplicate" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2728,46 +2643,6 @@ class MainActivity: FlutterFragmentActivity() {
}
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" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
@@ -334,6 +334,7 @@ object NativeDownloadFinalizer {
}
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
// Kept as a narrow hook for future richer progress snapshots.
}
private fun cleanupFailedFinalizationOutput(
@@ -421,19 +422,16 @@ object NativeDownloadFinalizer {
try {
for (candidate in decryptionKeyCandidates(key)) {
checkCancelled(shouldCancel)
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
val attempts = mutableListOf<Pair<String, Boolean>>()
attempts.add(outputPath to (preferredExt == ".flac"))
if (preferredExt == ".flac") {
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
attempts.add(buildOutputPath(localInput, ".m4a") to false)
}
if (preferredExt == ".flac" || preferredExt == ".m4a") {
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
attempts.add(buildOutputPath(localInput, ".mp4") to 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, forceMov) in attempts) {
for ((candidateOutput, mapAudioOnly) in attempts) {
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
// Force the flac muxer when the target extension is
@@ -441,11 +439,7 @@ object NativeDownloadFinalizer {
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = when {
forceMov -> "-f mov "
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
else -> ""
}
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
@@ -1165,28 +1159,18 @@ object NativeDownloadFinalizer {
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
var adoptedTemp = false
var originalDeleted = false
fun buildEmbedCommand(forceMov: Boolean): String {
return if (isM4a && coverFile != null) {
try {
val command = if (isM4a && coverFile != null) {
"-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 " +
"-disposition:v:0 attached_pic " +
"-metadata:s:v ${q("title=Album cover")} " +
"-metadata:s:v ${q("comment=Cover (front)")} " +
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
} else {
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))
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
}
val result = runFFmpeg(command)
if (result.first && temp.exists()) {
if (inputFile.delete()) {
originalDeleted = true
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Enable multidex for all subprojects
@@ -27,7 +27,7 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
-312
View File
@@ -1,312 +0,0 @@
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.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) int64 {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 8 + 20
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
switch version {
case 1:
return 8 + 20 + 16
case 2:
return 8 + 20 + 36
default:
return 8 + 20
}
}
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)
}
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
// 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
delta := int64(-16)
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 := audioSampleEntryHeaderLen(dst, loc.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)
}
-182
View File
@@ -1,182 +0,0 @@
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
}
+1
View File
@@ -314,6 +314,7 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
+12 -23
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -784,6 +783,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -1267,7 +1267,16 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
}
lastErr = err
if !isDeezerRetryableError(err) {
errStr := err.Error()
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
}
@@ -1277,26 +1286,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
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 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -1317,7 +1306,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
}
return json.Unmarshal(body, dst)
+15 -142
View File
@@ -283,7 +283,6 @@ type DownloadRequest struct {
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
TotalDiscs int `json:"total_discs,omitempty"`
@@ -311,7 +310,6 @@ type DownloadResponse struct {
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
@@ -1380,6 +1378,7 @@ func ReadFileMetadata(filePath string) (string, error) {
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
// APE, WavPack, Musepack: read APEv2 tags
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
@@ -1511,48 +1510,6 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
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.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
@@ -1612,6 +1569,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// APE/WV/MPC: write APEv2 tags natively
if isApeFile {
trackNum := 0
totalTracks := 0
@@ -2077,7 +2035,6 @@ func normalizeExtensionTrackMetadataMap(
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": trackNum,
"total_tracks": track.TotalTracks,
@@ -2106,12 +2063,9 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
"artist_id": album.ArtistID,
"images": album.CoverURL,
"cover_url": album.CoverURL,
"header_image": album.HeaderImage,
"header_video": album.HeaderVideo,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"audio_traits": album.AudioTraits,
"provider_id": album.ProviderID,
}
}
@@ -2196,13 +2150,11 @@ func getExtensionProviderMetadataResponse(
return map[string]interface{}{
"playlist_info": map[string]interface{}{
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"header_image": playlist.HeaderImage,
"header_video": playlist.HeaderVideo,
"provider_id": playlist.ProviderID,
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"provider_id": playlist.ProviderID,
"owner": map[string]interface{}{
"name": playlist.Artists,
"images": playlist.CoverURL,
@@ -2231,7 +2183,6 @@ func getExtensionProviderMetadataResponse(
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
"cover_url": artist.ImageURL,
"header_image": artist.HeaderImage,
"header_video": artist.HeaderVideo,
"provider_id": artist.ProviderID,
},
"albums": albums,
@@ -2281,16 +2232,6 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
switch strings.ToLower(trimmedProviderID) {
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)
default:
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
@@ -2306,19 +2247,6 @@ 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) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
@@ -2542,19 +2470,8 @@ func classifyDownloadErrorType(msg string) string {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
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") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
@@ -2579,15 +2496,6 @@ func classifyDownloadErrorType(msg string) string {
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 {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -2740,6 +2648,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
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 {
found := false
@@ -2908,6 +2818,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
if isFlac {
// Native Go FLAC metadata embedding.
// Only populate Metadata fields for selected update groups; empty/zero
// values cause EmbedMetadata's setComment() to skip those tags,
// preserving whatever is already in the file.
@@ -3310,10 +3221,6 @@ func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
func SetExtensionSessionGrantByID(extensionID, grant string) {
setPendingSignedSessionGrant(extensionID, grant)
}
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time
if expiresIn > 0 {
@@ -3480,7 +3387,6 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3546,8 +3452,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"extension_id": extensionID,
"name": result.Name,
"cover_url": result.CoverURL,
"header_image": result.HeaderImage,
"header_video": result.HeaderVideo,
}
if result.Track != nil {
@@ -3559,7 +3463,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": result.Track.AlbumArtist,
"duration_ms": result.Track.DurationMS,
"images": result.Track.ResolvedCoverURL(),
"preview_url": result.Track.PreviewURL,
"release_date": result.Track.ReleaseDate,
"track_number": result.Track.TrackNumber,
"total_tracks": result.Track.TotalTracks,
@@ -3582,7 +3485,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3604,9 +3506,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Album.Name,
"artists": result.Album.Artists,
"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,
"total_tracks": result.Album.TotalTracks,
"album_type": result.Album.AlbumType,
@@ -3620,7 +3519,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"header_image": result.Artist.HeaderImage,
"header_video": result.Artist.HeaderVideo,
"listeners": result.Artist.Listeners,
"provider_id": result.Artist.ProviderID,
}
@@ -3680,7 +3578,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3916,29 +3813,13 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
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) {
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
@@ -3947,12 +3828,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
ext, err := store.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
if err != nil {
return "", err
}
@@ -4017,12 +3893,9 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
if (typeof %s === 'function') {
return %s();
}
return null;
})()
`, functionName, functionName, functionName, functionName)
`, functionName, functionName)
jsStartedAt := time.Now()
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
+2 -55
View File
@@ -31,44 +31,6 @@ 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) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -428,25 +390,10 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
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 {
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
+17 -86
View File
@@ -15,9 +15,7 @@ import (
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
extensionHealthDefaultCache = 60 * time.Second
)
type ExtensionHealthResult struct {
@@ -60,7 +58,6 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
@@ -88,31 +85,16 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Unlock()
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)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
}
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: time.Now().Add(ttl),
expiresAt: now.Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
@@ -167,9 +149,6 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
@@ -247,11 +226,7 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Status = "offline"
result.Error = err.Error()
return result
}
@@ -287,10 +262,6 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
return result
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
@@ -316,9 +287,6 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
if isTransientHealthStatusMessage(string(body)) {
return "unknown", rawStatus
}
return "offline", rawStatus
default:
return "online", rawStatus
@@ -359,53 +327,42 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
joinedMessage := strings.Join(messageParts, ": ")
transient := isTransientHealthStatusMessage(detail) ||
isTransientHealthStatusMessage(errText) ||
isTransientHealthStatusMessage(label)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", joinedMessage, true
return "online", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", joinedMessage, true
return "degraded", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", joinedMessage, true
return "online", strings.Join(messageParts, ": "), true
}
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
return "offline", strings.Join(messageParts, ": "), true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
return "degraded", strings.Join(messageParts, ": "), true
}
if hasOK {
if okValue {
return "online", joinedMessage, true
return "online", strings.Join(messageParts, ": "), true
}
return "offline", joinedMessage, true
return "offline", strings.Join(messageParts, ": "), true
}
if !hasStatus {
return "unknown", joinedMessage, true
return "unknown", strings.Join(messageParts, ": "), true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", joinedMessage, true
return "online", strings.Join(messageParts, ": "), true
case "degraded", "partial", "warning", "warn":
return "degraded", joinedMessage, true
return "degraded", strings.Join(messageParts, ": "), true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", joinedMessage, true
return "offline", strings.Join(messageParts, ": "), true
default:
return "unknown", joinedMessage, true
return "unknown", strings.Join(messageParts, ": "), true
}
}
@@ -418,32 +375,6 @@ 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) {
switch v := value.(type) {
case float64:
@@ -1,10 +1,8 @@
package gobackend
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -29,12 +27,6 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if !isExtensionHealthAuthRequired(" unauthorized ") {
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" {
t.Fatalf("nil health = %#v", result)
+37 -64
View File
@@ -44,24 +44,18 @@ func compareVersions(v1, v2 string) int {
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
indexProgram *goja.Program
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
@@ -172,8 +166,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -312,7 +306,6 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -322,11 +315,6 @@ func initializeVMLocked(ext *loadedExtension) error {
if err != nil {
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)
ext.runtime = runtime
@@ -353,7 +341,7 @@ func initializeVMLocked(ext *loadedExtension) error {
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
_, err = vm.RunString(string(jsCode))
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -368,17 +356,10 @@ func initializeVMLocked(ext *loadedExtension) error {
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
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)
}
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
runtime := &extensionRuntime{
@@ -421,7 +402,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
if _, err := vm.RunString(string(jsCode)); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -692,7 +673,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
loaded = append(loaded, ext.ID)
}
}
} else if isExtensionPackagePath(entry.Name()) {
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -794,8 +775,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -943,8 +924,8 @@ type ExtensionUpgradeInfo struct {
}
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -1189,16 +1170,14 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
// 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.
actionNameLiteral := strconv.Quote(actionName)
script := fmt.Sprintf(`
(function() {
var actionName = %s;
function runAction(fn) {
try {
var result = fn();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
@@ -1213,19 +1192,13 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
} catch (e) {
return { success: false, error: e.toString() };
}
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
return runAction(function() { return extension[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)
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
+22 -63
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
@@ -114,49 +113,28 @@ type ExtensionHealthCheck struct {
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 {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
@@ -222,6 +200,7 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
@@ -259,26 +238,6 @@ 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
}
+90 -339
View File
@@ -29,7 +29,6 @@ type ExtTrackMetadata struct {
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
@@ -69,12 +68,9 @@ type ExtAlbumMetadata struct {
Artists string `json:"artists"`
ArtistID string `json:"artist_id,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"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
AudioTraits []string `json:"audio_traits,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
@@ -84,7 +80,6 @@ type ExtArtistMetadata struct {
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
@@ -478,18 +473,6 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
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 {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
@@ -500,15 +483,14 @@ type DownloadDecryptionInfo struct {
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
@@ -742,32 +724,6 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
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) {
if gojaValueIsEmpty(value) {
return 0, nil
@@ -798,7 +754,6 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
Images: gojaObjectString(obj, "images"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
@@ -865,147 +820,12 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
Artists: gojaObjectString(obj, "artists"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
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"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
Tracks: tracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}.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
}, nil
}
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
@@ -1071,7 +891,6 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
Name: gojaObjectString(obj, "name"),
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Listeners: gojaObjectInt(obj, "listeners"),
Albums: albums,
Releases: releases,
@@ -1123,36 +942,35 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
obj := value.ToObject(vm)
return ExtDownloadResult{
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
RequiresContainerConversion: gojaObjectBool(
obj,
"requires_container_conversion",
@@ -1164,11 +982,9 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
obj := value.ToObject(vm)
handleResult := ExtURLHandleResult{
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
@@ -2319,8 +2135,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
var lastErr error
var lastErrType string
var lastRetryAfterSeconds int
var stopProviderFallback bool
var sourceExtensionLocked bool
var sourceExtensionAvailability *ExtAvailabilityResult
@@ -2635,24 +2449,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if 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)
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 sourceExtensionLocked {
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
@@ -2660,11 +2461,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
RetryAfterSeconds: lastRetryAfterSeconds,
Service: req.Source,
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error",
Service: req.Source,
}, nil
}
} else {
@@ -2718,15 +2518,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil {
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 {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
@@ -2741,26 +2532,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
// Honor the requested quality when this provider recognizes it
// (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.
// Fallback provider: request its own highest quality, not the
// source provider's quality token.
fallbackQuality := req.Quality
if len(ext.Manifest.QualityOptions) > 0 {
requested := strings.TrimSpace(req.Quality)
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
}
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
fallbackQuality = best
}
}
@@ -2841,31 +2618,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if 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)
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 {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
@@ -2874,15 +2630,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if lastErr != nil {
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
errorType := classifyDownloadErrorType(lastErr.Error())
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
RetryAfterSeconds: lastRetryAfterSeconds,
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
}, nil
}
@@ -2899,22 +2654,21 @@ func buildOutputPath(req DownloadRequest) string {
}
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -2959,22 +2713,21 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
AddAllowedDownloadDir(tempDir)
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -3131,15 +2884,13 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
}
type ExtURLHandleResult struct {
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
}
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
-39
View File
@@ -8,35 +8,11 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"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
var (
@@ -327,12 +303,6 @@ func (e *RedirectBlockedError) Error() string {
}
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))
if hostLower == "" {
return false
@@ -495,15 +465,6 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
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.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
+1
View File
@@ -286,6 +286,7 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
}
switch parsedOptions.Mode {
case "cbc", "ctr":
// supported
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
+3
View File
@@ -370,6 +370,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
// Format: "bytes 0-1/12345"
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
@@ -456,6 +457,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
break // Success
}
// Non-success status
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
@@ -472,6 +474,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
})
}
// Read chunk body and write to file
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
-662
View File
@@ -1,662 +0,0 @@
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()})
}
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()})
}
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()
}
+7 -11
View File
@@ -330,26 +330,22 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return result, nil
}
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
registry, err := s.fetchRegistry(false)
if err != nil {
return nil, err
return err
}
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext := e
return &ext, nil
ext = &e
break
}
}
return nil, 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 ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
+1 -15
View File
@@ -13,7 +13,7 @@ import (
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
@@ -99,11 +99,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(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,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
@@ -125,9 +120,6 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
}
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])
if err != nil {
return ""
@@ -185,8 +177,6 @@ func getInt(m map[string]interface{}, key string) int {
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
case "playlist_position", "playlistPosition", "playlist position", "position":
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
}
for _, candidate := range candidateKeys {
@@ -210,10 +200,6 @@ func getInt(m map[string]interface{}, key string) int {
return 0
}
func getPlaylistPosition(metadata map[string]interface{}) int {
return getInt(metadata, "playlist_position")
}
func formatTrackNumber(n int) string {
if n <= 0 {
return ""
-17
View File
@@ -55,23 +55,6 @@ 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) {
metadata := map[string]interface{}{
"artist": "Artist Name",
+4 -4
View File
@@ -5,25 +5,25 @@ go 1.25.0
toolchain go1.25.9
require (
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/go-flac/flacpicture/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/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+8 -8
View File
@@ -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/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/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/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/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
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=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
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/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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+73 -117
View File
@@ -1,9 +1,7 @@
package gobackend
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
@@ -439,143 +437,101 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
// isTransientNetworkError reports retryable transport failures such as
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
func isTransientNetworkError(err error) bool {
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == 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"
return nil
}
var urlErr *url.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
}
}
}
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
return "DNS resolution failed - domain may be blocked by ISP"
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED:
return "Connection refused - port may be blocked by ISP/firewall"
case syscall.ECONNRESET:
return "Connection reset - ISP may be intercepting traffic"
case syscall.ETIMEDOUT:
return "Connection timed out - ISP may be blocking access"
case syscall.ENETUNREACH:
return "Network unreachable - ISP may be blocking route"
case syscall.EHOSTUNREACH:
return "Host unreachable - ISP may be blocking destination"
if opErr.Op == "dial" {
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
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
if errors.As(err, &tlsErr) {
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
}
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 &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
}
}
return false
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
blockingPatterns := []struct {
pattern string
reason string
}{
{"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)
if reason == "" {
return nil
}
return &ISPBlockingError{
Domain: extractDomain(requestURL),
Reason: reason,
OriginalErr: err,
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
}
return nil
}
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
+4 -15
View File
@@ -1,15 +1,13 @@
package gobackend
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"testing"
"time"
)
@@ -133,24 +131,15 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
t.Fatal("expected logged 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") {
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
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 {
t.Fatal("nil wrap should stay nil")
}
+7 -1
View File
@@ -144,7 +144,13 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
if isTLSHandshakeOrResetError(err) {
errStr := strings.ToLower(err.Error())
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)
reqCopy := req.Clone(req.Context())
+28 -196
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -93,18 +92,6 @@ type scannedCueFileInfo struct {
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 {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
@@ -163,129 +150,6 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
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) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
@@ -361,10 +225,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
}
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
audioTasks := make([]libraryScanTask, 0, totalFiles)
completedFiles := 0
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
@@ -373,6 +233,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
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))
if ext == ".cue" {
@@ -394,44 +260,26 @@ func ScanLibraryFolder(folderPath string) (string, error) {
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
continue
}
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
results = append(results, cueResults...)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
if cueReferencedAudioFiles[filePath] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
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]...)
results = append(results, *result)
}
libraryScanProgressMu.Lock()
@@ -1026,10 +874,6 @@ 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 {
select {
case <-cancelCh:
@@ -1037,6 +881,12 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
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))
if ext == ".cue" {
@@ -1058,42 +908,24 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
results = append(results, cueResults...)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
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]...)
results = append(results, *result)
}
libraryScanProgressMu.Lock()
+149 -502
View File
@@ -20,12 +20,6 @@ const (
durationToleranceSec = 10.0
)
const (
lyricsProviderUnavailableCooldown = 10 * time.Minute
lyricsProviderParallelism = 3
lyricsProviderPriorityGrace = 5000 * time.Millisecond
)
const (
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
@@ -52,33 +46,6 @@ var (
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) {
normalized := strings.TrimSpace(version)
@@ -132,7 +99,6 @@ func SetLyricsProviderOrder(providers []string) {
if len(providers) == 0 {
lyricsProviders = nil
clearLyricsProviderHealth()
return
}
@@ -159,131 +125,9 @@ func SetLyricsProviderOrder(providers []string) {
}
lyricsProviders = valid
clearLyricsProviderHealth()
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 {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -630,22 +474,15 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
if len(extensionProviders) > 0 {
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)
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
markLyricsProviderAvailable(providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if err != nil {
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
markLyricsProviderUnavailable(providerName, err)
}
}
}
@@ -659,338 +496,175 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
providerOrder := GetLyricsProviderOrder()
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)
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
if err == nil && isValidResult(lyrics) {
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
return nil, fmt.Errorf("lyrics not found from any source")
}
var lyrics *LyricsResponse
var err error
func fetchBuiltInLyricsProviders(
providerOrder []string,
request lyricsProviderSearchRequest,
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
) (*LyricsResponse, error) {
type providerCandidate struct {
index int
name string
}
switch providerName {
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
candidates := make([]providerCandidate, 0, len(providerOrder))
results := make(chan lyricsProviderSearchResult, len(providerOrder))
sem := make(chan struct{}, lyricsProviderParallelism)
var wg sync.WaitGroup
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err = neteaseClient.FetchLyrics(
trackName,
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,
)
}
for index, providerName := range providerOrder {
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
continue
}
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.MusixmatchLanguage,
)
if err != nil && primaryArtist != artistName {
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.MusixmatchLanguage,
)
}
knownProvider := isKnownBuiltInLyricsProvider(providerName)
if !knownProvider {
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
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)
continue
}
candidate := providerCandidate{index: index, name: providerName}
candidates = append(candidates, candidate)
wg.Add(1)
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 && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
select {
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 err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
}
}
if best != nil {
return best.lyrics, nil
}
if lastErr != nil {
return nil, lastErr
}
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) {
var lyrics *LyricsResponse
var err error
@@ -1000,9 +674,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
@@ -1010,9 +681,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
if simplifiedTrack != trackName {
@@ -1021,9 +689,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
query := primaryArtist + " " + trackName
@@ -1032,9 +697,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
@@ -1043,9 +705,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
return nil, fmt.Errorf("LRCLIB: no lyrics found")
@@ -1189,18 +848,6 @@ func detectLyricsErrorPayload(raw string) (string, bool) {
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
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
}
+3 -6
View File
@@ -2,7 +2,6 @@ package gobackend
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -14,8 +13,6 @@ import (
"time"
)
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
type AppleMusicClient struct {
httpClient *http.Client
}
@@ -191,7 +188,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody))
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
@@ -238,7 +235,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, errAppleMusicUnauthorized
return nil, fmt.Errorf("apple music catalog search unauthorized")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
@@ -284,7 +281,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
}
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if errors.Is(err, errAppleMusicUnauthorized) {
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
+5 -1
View File
@@ -24,8 +24,12 @@ import (
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
// Sourced from the upstream YouLy+ client server list.
var lyricsPlusServers = []string{
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus.prjktla.my.id",
"https://lyricsplus.atomix.one",
"https://lyricsplus.binimum.org",
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus-seven.vercel.app",
"https://lyrics-plus-backend.vercel.app",
}
type LyricsPlusClient struct {
+1 -14
View File
@@ -24,9 +24,7 @@ type neteaseSearchResponse struct {
} `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
Code int `json:"code"`
}
type neteaseLyricsResponse struct {
@@ -89,17 +87,6 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
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 {
return 0, fmt.Errorf("no songs found on netease")
}
+1 -1
View File
@@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "5")
params.Set("per_page", "10")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
+1 -131
View File
@@ -1,7 +1,6 @@
package gobackend
import (
"errors"
"io"
"net/http"
"path/filepath"
@@ -55,15 +54,6 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
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]" {
t.Fatal("unexpected LRC timestamp conversion")
}
@@ -140,120 +130,9 @@ 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) {
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}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -261,7 +140,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
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
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="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
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
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
@@ -357,12 +236,6 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if _, err := netease.SearchSong("", ""); err == nil {
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) {
if req.Method != http.MethodPost {
@@ -438,9 +311,6 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
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
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
-68
View File
@@ -1,68 +0,0 @@
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)
}
}
+42 -187
View File
@@ -6,7 +6,7 @@ import (
"fmt"
stdimage "image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
_ "image/png"
"io"
"math"
@@ -71,83 +71,11 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
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) {
if len(coverData) == 0 {
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)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
@@ -247,11 +175,10 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
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))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -303,11 +230,10 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
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))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -1197,7 +1123,9 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
return ilst, nil
}
}
@@ -1205,7 +1133,9 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return ilst, nil
}
}
@@ -1213,26 +1143,6 @@ 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)")
}
// 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) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
@@ -1370,7 +1280,9 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
@@ -1383,7 +1295,9 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
@@ -1518,51 +1432,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
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)
if err != nil {
return err
@@ -1576,13 +1445,6 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
path, err := findM4AMetadataPath(f, info.Size())
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
}
@@ -1594,6 +1456,13 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + 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; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
@@ -1611,7 +1480,7 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
@@ -1623,11 +1492,23 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
pos += header.size
}
for _, tag := range tags {
if strings.TrimSpace(tag.value) == "" {
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
@@ -1654,32 +1535,6 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
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) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
+1 -1
View File
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if err == nil {
return file, nil
}
if os.IsPermission(err) {
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
return os.OpenFile(path, os.O_WRONLY, 0)
}
return nil, err
+16
View File
@@ -41,6 +41,8 @@ const (
wavFormatExtensn = 0xFFFE
)
// ---------- low-level chunk size helpers ----------
func putUint32(dst []byte, le bool, v uint32) {
if le {
binary.LittleEndian.PutUint32(dst, v)
@@ -93,6 +95,8 @@ func parseExtendedFloat80(b []byte) float64 {
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
}
// ---------- WAV (RIFF) ----------
type wavProbe struct {
sampleRate int
bitDepth int
@@ -285,6 +289,8 @@ func ReadWAVTags(filePath string) (*AudioMetadata, error) {
return meta, nil
}
// ---------- AIFF / AIFC ----------
type aiffProbe struct {
sampleRate int
bitDepth int
@@ -437,6 +443,8 @@ func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
return meta, nil
}
// ---------- ID3v2 reading from a buffered chunk ----------
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
@@ -527,6 +535,8 @@ func extractAPICFromID3(tag []byte) ([]byte, string) {
return nil, ""
}
// ---------- ID3v2.4 building ----------
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
var frames bytes.Buffer
@@ -632,6 +642,8 @@ func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []b
return out.Bytes()
}
// ---------- tag writing (streaming chunk rewrite) ----------
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
// 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.
@@ -680,6 +692,7 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
pad := int64(size) & 1
if strings.EqualFold(id, chunkID) {
// Drop the existing tag chunk.
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
cleanup()
return err
@@ -698,6 +711,7 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
bodyLen += 8 + int64(size) + pad
}
// Append the new tag chunk.
newSize := len(id3)
chunkHdr := make([]byte, 8)
copy(chunkHdr[0:4], chunkID)
@@ -876,6 +890,8 @@ func WriteAIFFTags(filePath string, fields map[string]string) error {
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
}
// ---------- library scan integration ----------
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
+19 -48
View File
@@ -1,6 +1,6 @@
import Flutter
import UIKit
import Gobackend
import Gobackend // Import Go framework
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -17,8 +17,6 @@ import Gobackend
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
private var backendChannel: FlutterMethodChannel?
private var pendingSessionGrantEvents: [[String: Any]] = []
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
@@ -41,14 +39,6 @@ import Gobackend
name: CHANNEL,
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(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
@@ -93,25 +83,20 @@ import Gobackend
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let isSessionGrant = host == "session-grant"
let ok =
isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback")
host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ??
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
@@ -124,37 +109,16 @@ import Gobackend
}
streamQueue.async {
var err: NSError?
if isSessionGrant {
GobackendSetExtensionSessionGrantByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
} else {
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
}
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if let err = err {
NSLog(
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
} else if isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(extensionId: state)
}
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
}
}
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(
_ app: UIApplication,
open url: URL,
@@ -393,12 +357,6 @@ import Gobackend
let insecureTLS = args["insecure_tls"] as? Bool ?? false
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
return nil
case "setAllowPrivateNetwork":
let args = call.arguments as! [String: Any]
let allowed = args["allowed"] as? Bool ?? false
GobackendSetAllowPrivateNetwork(allowed)
return nil
case "checkDuplicate":
let args = call.arguments as! [String: Any]
@@ -632,6 +590,7 @@ import Gobackend
GobackendClearTrackCache()
return nil
// Log methods
case "getLogs":
let response = GobackendGetLogs()
return response
@@ -656,6 +615,7 @@ import Gobackend
GobackendSetLoggingEnabled(enabled)
return nil
// Extension System methods
case "initExtensionSystem":
let args = call.arguments as! [String: Any]
let extensionsDir = args["extensions_dir"] as! String
@@ -820,6 +780,7 @@ import Gobackend
GobackendCleanupExtensions()
return nil
// Extension Auth API
case "getExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -860,6 +821,7 @@ import Gobackend
if let error = error { throw error }
return response
// Extension FFmpeg API
case "getPendingFFmpegCommand":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
@@ -881,6 +843,7 @@ import Gobackend
if let error = error { throw error }
return response
// Extension Custom Search API
case "customSearchWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -902,6 +865,7 @@ import Gobackend
if let error = error { throw error }
return response
// Extension URL Handler API
case "handleURLWithExtension":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -920,6 +884,7 @@ import Gobackend
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
@@ -941,6 +906,7 @@ import Gobackend
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -998,6 +964,7 @@ import Gobackend
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -1013,6 +980,7 @@ import Gobackend
if let error = error { throw error }
return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -1049,6 +1017,7 @@ import Gobackend
if let error = error { throw error }
return response
// iOS Security-Scoped Bookmark for Local Library
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
@@ -1068,6 +1037,7 @@ import Gobackend
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
// Lyrics Provider Settings
case "setLyricsProviders":
let args = call.arguments as! [String: Any]
let providersJson = args["providers_json"] as? String ?? "[]"
@@ -1097,6 +1067,7 @@ import Gobackend
if let error = error { throw error }
return response
// CUE Sheet Parsing
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.7.0';
static const String buildNumber = '136';
static const String version = '4.6.0';
static const String buildNumber = '135';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
-634
View File
@@ -1202,24 +1202,6 @@ abstract class AppLocalizations {
/// **'Download'**
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
///
/// In en, this message translates to:
@@ -2972,12 +2954,6 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'**
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
///
/// In en, this message translates to:
@@ -5017,198 +4993,6 @@ abstract class AppLocalizations {
/// **'Buy the developer a coffee'**
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
///
/// In en, this message translates to:
@@ -5421,24 +5205,6 @@ abstract class AppLocalizations {
/// **'Using standard network settings'**
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
///
/// In en, this message translates to:
@@ -7350,406 +7116,6 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{service} link copied'**
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;
/// 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;
}
class _AppLocalizationsDelegate
-430
View File
@@ -603,15 +603,6 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsAr extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsAr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4456,252 +4274,4 @@ class AppLocalizationsAr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -612,15 +612,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogDownload => 'Herunterladen';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Verwerfen';
@@ -1624,10 +1615,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
@override
String get albumFolderStructureDescription =>
'Ordnerstruktur für Alben festlegen';
@override
String get downloadUseAlbumArtistForFolders =>
'Album-Künstler für Ordner verwenden';
@@ -2935,164 +2922,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Alle lieben';
@@ -3225,17 +3054,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4505,252 +4323,4 @@ class AppLocalizationsDe extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -603,15 +603,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4456,252 +4274,4 @@ class AppLocalizationsEn extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-434
View File
@@ -603,15 +603,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4450,254 +4268,6 @@ class AppLocalizationsEs extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -6272,10 +5842,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum';
@override
String get albumFolderStructureDescription =>
'Elige cómo se estructuran las carpetas de los álbumes';
@override
String get downloadUseAlbumArtistForFolders =>
'Usar álbum de artista cómo carpeta';
-430
View File
@@ -620,15 +620,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get dialogDiscard => 'Ignorer';
@@ -1646,10 +1637,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Structure du dossier de l\'album';
@override
String get albumFolderStructureDescription =>
'Choisir la structure des dossiers d\'album';
@override
String get downloadUseAlbumArtistForFolders =>
'Utilisez l\'artiste de l\'album pour les dossiers';
@@ -2975,164 +2962,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Tout aimer';
@@ -3270,17 +3099,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4570,252 +4388,4 @@ class AppLocalizationsFr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -603,15 +603,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4456,252 +4274,4 @@ class AppLocalizationsHi extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-407
View File
@@ -604,15 +604,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Buang';
@@ -1607,9 +1598,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get albumFolderStructureDescription => 'Pilih struktur folder album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Artis Album untuk folder';
@@ -2904,141 +2892,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3168,17 +3021,6 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4439,253 +4281,4 @@ class AppLocalizationsId extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-429
View File
@@ -600,15 +600,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '破棄';
@@ -1591,9 +1582,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get albumFolderStructureDescription => 'アルバムフォルダの構成を選択';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,164 +2873,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3172,17 +3002,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4443,252 +4262,4 @@ class AppLocalizationsJa extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -593,15 +593,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '취소';
@@ -1586,10 +1577,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2883,164 +2870,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3170,17 +2999,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4441,252 +4259,4 @@ class AppLocalizationsKo extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -603,15 +603,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4456,252 +4274,4 @@ class AppLocalizationsNl extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-434
View File
@@ -603,15 +603,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4450,254 +4268,6 @@ class AppLocalizationsPt extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -6267,10 +5837,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum';
@override
String get albumFolderStructureDescription =>
'Escolher a estrutura das pastas dos álbuns';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
-430
View File
@@ -609,15 +609,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogDownload => 'Скачать';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Отменить';
@@ -1622,10 +1613,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get albumFolderStructureDescription =>
'Выберите структуру папок альбомов';
@override
String get downloadUseAlbumArtistForFolders =>
'Использовать исполнителя альбома для папок';
@@ -2953,164 +2940,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3240,17 +3069,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4512,252 +4330,4 @@ class AppLocalizationsRu extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-429
View File
@@ -610,15 +610,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogDownload => 'İndir';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Vazgeç';
@@ -1618,9 +1609,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Albüm Klasör Yapısı';
@override
String get albumFolderStructureDescription => 'Albüm klasör yapısını seçin';
@override
String get downloadUseAlbumArtistForFolders =>
'Klasörler için Albüm Sanatçısı\'nı kullan';
@@ -2926,164 +2914,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3216,17 +3046,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4487,252 +4306,4 @@ class AppLocalizationsTr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-430
View File
@@ -612,15 +612,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get dialogDownload => 'Завантажити';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Відхилити';
@@ -1624,10 +1615,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбому';
@override
String get albumFolderStructureDescription =>
'Виберіть структуру папок альбомів';
@override
String get downloadUseAlbumArtistForFolders =>
'Використовувати виконавця альбому для папок';
@@ -2942,164 +2929,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Уподобати всіх';
@@ -3232,17 +3061,6 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4509,252 +4327,4 @@ class AppLocalizationsUk extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
-438
View File
@@ -603,15 +603,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1601,10 +1592,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2898,164 +2885,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get tooltipLoveAll => 'Love All';
@@ -3185,17 +3014,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'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
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4450,254 +4268,6 @@ class AppLocalizationsZh extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
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 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';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -6237,10 +5807,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -10472,10 +10038,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
+1140 -473
View File
File diff suppressed because it is too large Load Diff
+989 -322
View File
File diff suppressed because it is too large Load Diff
-529
View File
@@ -772,18 +772,6 @@
"@dialogDownload": {
"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": {
"description": "Dialog button - discard changes"
@@ -2107,10 +2095,6 @@
"@downloadAlbumFolderStructure": {
"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": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
@@ -3843,169 +3827,6 @@
"@settingsDonateSubtitle": {
"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": {
"description": "Tooltip for the Love All button on album/playlist screens"
@@ -4162,18 +3983,6 @@
"@downloadNetworkCompatibilityModeDisabled": {
"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": {
"description": "Subtitle when quality picker is disabled due to extension service"
@@ -5786,343 +5595,5 @@
"placeholders": {
"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"
},
"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"
}
}
-4
View File
@@ -1592,10 +1592,6 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+1067 -400
View File
File diff suppressed because it is too large Load Diff
+1006 -339
View File
File diff suppressed because it is too large Load Diff
+988 -321
View File
File diff suppressed because it is too large Load Diff
+1029 -793
View File
File diff suppressed because it is too large Load Diff
+1388 -721
View File
File diff suppressed because it is too large Load Diff
+1962 -1295
View File
File diff suppressed because it is too large Load Diff
+988 -321
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1592,10 +1592,6 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+1000 -333
View File
File diff suppressed because it is too large Load Diff
+1000 -333
View File
File diff suppressed because it is too large Load Diff
+1182 -519
View File
File diff suppressed because it is too large Load Diff
+989 -322
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1592,10 +1592,6 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+1089 -422
View File
File diff suppressed because it is too large Load Diff
+989 -322
View File
File diff suppressed because it is too large Load Diff
+4 -17
View File
@@ -12,14 +12,7 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
verificationRequired,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
@JsonSerializable()
class DownloadItem {
@@ -29,15 +22,14 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived;
final int bytesReceived; // Bytes downloaded so far
final int bytesTotal; // Total bytes when the server provides content length
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride;
final String? playlistName;
final int? playlistPosition; // 1-based position in the source playlist
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
const DownloadItem({
required this.id,
@@ -54,7 +46,6 @@ class DownloadItem {
required this.createdAt,
this.qualityOverride,
this.playlistName,
this.playlistPosition,
});
DownloadItem copyWith({
@@ -72,7 +63,6 @@ class DownloadItem {
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -89,7 +79,6 @@ class DownloadItem {
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
playlistPosition: playlistPosition ?? this.playlistPosition,
);
}
@@ -105,8 +94,6 @@ class DownloadItem {
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
case DownloadErrorType.verificationRequired:
return 'Verification required. Open the extension and complete the security check.';
default:
return error ?? 'An error occurred';
}
-3
View File
@@ -23,7 +23,6 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
playlistPosition: (json['playlistPosition'] as num?)?.toInt(),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -42,7 +41,6 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
'playlistPosition': instance.playlistPosition,
};
const _$DownloadStatusEnumMap = {
@@ -60,5 +58,4 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
DownloadErrorType.verificationRequired: 'verificationRequired',
};
+10 -20
View File
@@ -15,11 +15,11 @@ class AppSettings {
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedMetadata;
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics;
final bool embedReplayGain;
final bool embedReplayGain; // Calculate and embed ReplayGain tags
final bool maxQualityCover;
final bool isFirstLaunch;
final bool checkForUpdates;
@@ -50,32 +50,30 @@ class AppSettings {
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads;
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool
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
songLinkRegion; // SongLink userCountry region code used for platform lookup
final bool
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
final bool localLibraryEnabled;
final String localLibraryPath;
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates;
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial;
hasCompletedTutorial; // Track if user has completed the app tutorial
final List<String>
lyricsProviders;
lyricsProviders; // Ordered list of enabled lyrics provider IDs
final bool
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
final bool
@@ -91,10 +89,8 @@ class AppSettings {
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
final bool
deduplicateDownloads;
final bool saveDownloadHistory;
final String playerMode;
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
const AppSettings({
this.defaultService = '',
@@ -139,7 +135,6 @@ class AppSettings {
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.allowLocalNetwork = false,
this.songLinkRegion = 'US',
this.nativeDownloadWorkerEnabled = false,
this.localLibraryEnabled = false,
@@ -157,7 +152,6 @@ class AppSettings {
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
this.playerMode = 'external',
});
AppSettings copyWith({
@@ -206,7 +200,6 @@ class AppSettings {
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
bool? networkCompatibilityMode,
bool? allowLocalNetwork,
String? songLinkRegion,
bool? nativeDownloadWorkerEnabled,
bool? localLibraryEnabled,
@@ -224,7 +217,6 @@ class AppSettings {
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
String? playerMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -283,7 +275,6 @@ class AppSettings {
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
allowLocalNetwork: allowLocalNetwork ?? this.allowLocalNetwork,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
nativeDownloadWorkerEnabled:
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
@@ -309,7 +300,6 @@ class AppSettings {
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
playerMode: playerMode ?? this.playerMode,
);
}
-4
View File
@@ -56,7 +56,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
allowLocalNetwork: json['allowLocalNetwork'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
nativeDownloadWorkerEnabled:
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
@@ -83,7 +82,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'external',
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -132,7 +130,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
'networkCompatibilityMode': instance.networkCompatibilityMode,
'allowLocalNetwork': instance.allowLocalNetwork,
'songLinkRegion': instance.songLinkRegion,
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
'localLibraryEnabled': instance.localLibraryEnabled,
@@ -150,5 +147,4 @@ Map<String, dynamic> _$AppSettingsToJson(
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
};
-4
View File
@@ -13,7 +13,6 @@ class Track {
final String? albumId;
final String? coverUrl;
final String? isrc;
final String? previewUrl;
final int duration;
final int? trackNumber;
final int? discNumber;
@@ -39,7 +38,6 @@ class Track {
this.albumId,
this.coverUrl,
this.isrc,
this.previewUrl,
required this.duration,
this.trackNumber,
this.discNumber,
@@ -83,8 +81,6 @@ class Track {
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
}
@JsonSerializable()
-2
View File
@@ -16,7 +16,6 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
previewUrl: json['previewUrl'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
@@ -47,7 +46,6 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'albumId': instance.albumId,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'previewUrl': instance.previewUrl,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
+44 -414
View File
@@ -22,7 +22,6 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_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'
show HistoryLookupRequest, HistoryBatchLookupRequest;
@@ -481,8 +480,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _startupSafRepairCursorKey =
'history_startup_saf_repair_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';
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
@@ -1543,39 +1540,24 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
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) {
await _db.updateFilePath(replacement.key, replacement.value);
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
}
final deletedCount = confirmedOrphanIds.isEmpty
final deletedCount = result.orphanedIds.isEmpty
? 0
: await _db.deleteByIds(confirmedOrphanIds);
: await _db.deleteByIds(result.orphanedIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: confirmedOrphanIds,
deletedIds: result.orphanedIds,
replacementPaths: result.replacementPaths,
);
if (entries.length < maxItems) {
await prefs.remove(_startupOrphanCursorKey);
} else {
final nextCursor = result.orphanedIds.isNotEmpty
? safeCursor
: safeCursor + entries.length;
final nextCursor =
safeCursor + entries.length - result.orphanedIds.length;
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
}
@@ -1651,17 +1633,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<int> getDatabaseCount() async {
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 =
@@ -1934,8 +1905,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _lastNotifQueueCount = -1;
final Set<String> _locallyCancelledItemIds = {};
final Set<String> _pausePendingItemIds = {};
final Set<String> _verificationRetriedItemIds = {};
final Set<String> _rateLimitRetriedItemIds = {};
String? _activeNativeWorkerRunId;
// Album ReplayGain accumulator: keyed by album identifier.
@@ -1943,9 +1912,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// then computes and writes album gain/peak to every track in the album.
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
String _verificationRetryKey(String itemId, String service) =>
'$itemId::${service.trim().toLowerCase()}';
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
if (clamped <= 0) return 0;
@@ -2081,148 +2047,6 @@ 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 opened = await openPendingExtensionVerification(
normalizedExtensionId,
);
if (!opened) return false;
final event = await grantEventFuture;
return event.success;
}
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() {
_queuePersistDebounce?.cancel();
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
@@ -3367,29 +3191,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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;
}
@@ -3805,7 +3606,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -3819,7 +3619,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition: playlistPosition,
);
state = state.copyWith(items: [...state.items, item]);
@@ -3837,23 +3636,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
List<int?>? playlistPositions,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
final takenIds = state.items.map((item) => item.id).toSet();
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 newItems = tracks.map((track) {
final id = _newQueueItemId(track, takenIds: takenIds);
takenIds.add(id);
return DownloadItem(
@@ -3863,9 +3651,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition:
explicitPosition ??
(shouldAssignPlaylistPositions ? index + 1 : null),
);
}).toList();
@@ -3877,45 +3662,6 @@ 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(
String id,
DownloadStatus status, {
@@ -4231,10 +3977,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Retrying item: ${item.track.name} (id: $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
// produce duplicate entries that bias album gain.
@@ -4568,13 +4310,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.where((item) => _albumRgKey(item.track) == key)
.toList();
// If any item is still in-flight, the album isn't complete yet.
final pending = albumItemsInQueue.where(
(item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
if (pending.isNotEmpty) return;
if (pending.isNotEmpty) return; // still in progress
// If any item is failed/skipped, the user might retry it later.
// Don't finalize album RG with partial data — wait until all album
@@ -4584,7 +4327,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped,
);
if (retryable.isNotEmpty) return;
if (retryable.isNotEmpty) return; // still retryable
// The accumulator entries represent successfully scanned tracks. Entries
// are only added after a successful ReplayGain scan, removed on retry or
@@ -4724,6 +4467,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
continue;
}
// If any representative item is available, use its track.
final representative = albumItems.first;
_checkAndWriteAlbumReplayGain(representative.track);
}
@@ -5116,10 +4860,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
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(
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
);
@@ -5134,48 +4874,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath
: 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;
if (isFlac) {
ffmpegResult = await FFmpegService.embedMetadata(
@@ -5817,21 +5515,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safFileName;
final safOutputExt = isSafMode ? outputExt : '';
final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track)
? state.singleFilenameFormat
: state.filenameFormat;
final effectiveFilenameFormat = _filenameFormatForItem(
item,
baseFilenameFormat,
);
if (isSafMode) {
final baseName = await PlatformBridge.buildFilename(
effectiveFilenameFormat,
_filenameMetadataForTrack(
item.track,
playlistPosition: _validPlaylistPosition(item),
),
);
final effectiveFormat = _shouldTreatAsSingleRelease(item.track)
? state.singleFilenameFormat
: state.filenameFormat;
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
'title': item.track.name,
'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);
}
@@ -5919,7 +5615,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumArtist: resolvedAlbumArtist ?? '',
coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '',
outputDir: outputDir,
filenameFormat: effectiveFilenameFormat,
filenameFormat: _shouldTreatAsSingleRelease(trackForPayload)
? state.singleFilenameFormat
: state.filenameFormat,
quality: quality,
embedMetadata: settings.embedMetadata,
artistTagMode: settings.artistTagMode,
@@ -5936,7 +5634,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
totalTracks: trackForPayload.totalTracks ?? 0,
totalDiscs: trackForPayload.totalDiscs ?? 0,
@@ -6894,8 +6591,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadErrorType.network;
case 'permission':
return DownloadErrorType.permission;
case 'verification_required':
return DownloadErrorType.verificationRequired;
default:
return DownloadErrorType.unknown;
}
@@ -6903,9 +6598,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (isExtensionVerificationRequired(errorMsg)) {
return DownloadErrorType.verificationRequired;
}
if (errorMsg.contains('429') ||
lowerMsg.contains('rate limit') ||
lowerMsg.contains('too many requests')) {
@@ -7230,11 +6922,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final remainingIds = state.items.map((item) => item.id).toSet();
_locallyCancelledItemIds.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 {
@@ -7446,21 +7133,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safFileName;
String? safBaseName;
String safOutputExt = _determineOutputExt(quality, item.service);
final baseFilenameFormat = _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat;
final effectiveFilenameFormat = _filenameFormatForItem(
item,
baseFilenameFormat,
);
if (isSafMode) {
final baseName = await PlatformBridge.buildFilename(
effectiveFilenameFormat,
_filenameMetadataForTrack(
trackToDownload,
playlistPosition: _validPlaylistPosition(item),
),
);
final effectiveFormat = _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat;
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
'title': trackToDownload.name,
'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);
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
}
@@ -7649,7 +7334,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? (trackToDownload.coverUrl ?? '')
: '',
outputDir: outputDir,
filenameFormat: effectiveFilenameFormat,
filenameFormat: _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat,
quality: quality,
embedMetadata: metadataEmbeddingEnabled,
artistTagMode: settings.artistTagMode,
@@ -7667,7 +7354,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
totalTracks: trackToDownload.totalTracks ?? 0,
totalDiscs: trackToDownload.totalDiscs ?? 0,
@@ -7875,17 +7561,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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 decryptedExt = dotIndex >= 0
? decryptedTempPath.substring(dotIndex).toLowerCase()
@@ -7938,11 +7613,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
final encryptedSource = filePath;
final decryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: encryptedSource,
inputPath: filePath,
descriptor: decryptionDescriptor,
deleteOriginal: false,
deleteOriginal: true,
);
if (decryptedPath == null) {
_log.e('FFmpeg decrypt failed for local file');
@@ -7953,23 +7627,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: DownloadErrorType.unknown,
);
try {
await deleteFile(encryptedSource);
await deleteFile(filePath);
} catch (_) {}
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;
_log.i('Local decryption completed');
}
@@ -9107,14 +8768,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
var errorMsg = result['error'] as String? ?? 'Download failed';
final errorMsg = result['error'] as String? ?? 'Download failed';
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 (_isPausePending(item.id)) {
pausedDuringThisRun = true;
@@ -9143,26 +8798,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'permission':
errorType = DownloadErrorType.permission;
break;
case 'verification_required':
errorType = DownloadErrorType.verificationRequired;
break;
default:
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)');
updateItemStatus(
item.id,
@@ -9218,15 +8857,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
if (errorType == DownloadErrorType.verificationRequired) {
await _handleVerificationRequiredDownload(item, errorMsg, item.service);
return;
}
if (errorType == DownloadErrorType.rateLimit &&
await _handleRateLimitedDownload(item, errorMsg)) {
return;
}
updateItemStatus(
item.id,
DownloadStatus.failed,
+22 -279
View File
@@ -14,22 +14,6 @@ final _log = AppLogger('ExtensionProvider');
const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
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) {
if (identical(a, b)) return true;
@@ -808,15 +792,12 @@ class ExtensionInstallBatchResult {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
static const _extensionHealthCacheTtl = Duration(seconds: 60);
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
final Map<String, DateTime> _healthExpiresAt = {};
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
final Map<String, int> _healthRequestSerial = {};
@override
ExtensionState build() {
@@ -828,7 +809,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_appLifecycleListener = null;
_healthExpiresAt.clear();
_healthInFlight.clear();
_healthRequestSerial.clear();
});
return const ExtensionState();
}
@@ -958,46 +938,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void _scheduleExtensionHealthRefresh(
List<Extension> extensions, {
bool force = false,
}) {
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
for (final ext in extensions) {
if (!ext.enabled || !ext.hasServiceHealth) continue;
unawaited(checkExtensionHealth(ext.id, force: force));
unawaited(checkExtensionHealth(ext.id));
}
}
void refreshEnabledExtensionHealth({bool force = false}) {
_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;
void refreshEnabledExtensionHealth() {
_scheduleExtensionHealthRefresh(state.extensions);
}
Future<ExtensionHealthStatus?> checkExtensionHealth(
@@ -1025,22 +974,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return inFlight;
}
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
_healthRequestSerial[extensionId] = requestSerial;
final future = () async {
try {
final result = await PlatformBridge.checkExtensionHealth(extensionId);
final status = ExtensionHealthStatus.fromJson(result);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtlForStatus(ext, status.status),
);
state = state.copyWith(healthStatuses: updated);
}
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
return status;
} catch (e) {
_log.w('Failed to check extension health for $extensionId: $e');
@@ -1050,20 +994,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
checkedAt: DateTime.now(),
checks: const [],
);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthUnknownCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
}
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
return status;
} finally {
if (_healthRequestSerial[extensionId] == requestSerial) {
_healthInFlight.remove(extensionId);
}
_healthInFlight.remove(extensionId);
}
}();
@@ -1700,7 +1640,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
List<Extension> enabledExtensions() {
List<Extension> get enabledExtensions {
return state.extensions.where((ext) => ext.enabled).toList();
}
@@ -1777,208 +1717,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return result;
}
List<Extension> searchProviders() {
List<Extension> get searchProviders {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.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>(
@@ -953,90 +953,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
});
_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 =
-153
View File
@@ -1,153 +0,0 @@
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,
);
}
-167
View File
@@ -2,10 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_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/music_player_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -19,24 +16,6 @@ class PlaybackController extends Notifier<PlaybackState> {
@override
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({
required String path,
required String title,
@@ -48,143 +27,14 @@ class PlaybackController extends Notifier<PlaybackState> {
if (isCueVirtualPath(path)) {
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');
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 {
if (tracks.isEmpty) return;
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;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
@@ -248,23 +98,6 @@ class PlaybackController extends Notifier<PlaybackState> {
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 {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) {
-248
View File
@@ -1,248 +0,0 @@
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,
);
+12 -71
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -97,29 +96,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncLyricsSettingsToBackend() {
unawaited(syncLyricsSettingsToBackend());
}
Future<void> syncLyricsSettingsToBackend() async {
if (!PlatformBridge.supportsCoreBackend) return;
try {
await PlatformBridge.setLyricsProviders(state.lyricsProviders);
} catch (e) {
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
Object e,
) {
_log.w('Failed to sync lyrics providers to backend: $e');
}
});
try {
await PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
});
} catch (e) {
PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
}
});
}
void _syncNetworkCompatibilitySettingsToBackend() {
@@ -132,12 +125,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
).catchError((Object 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() {
@@ -207,40 +194,6 @@ 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 {
if (!Platform.isIOS) return;
@@ -588,12 +541,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncNetworkCompatibilitySettingsToBackend();
}
void setAllowLocalNetwork(bool enabled) {
state = state.copyWith(allowLocalNetwork: enabled);
_saveSettings();
_syncNetworkCompatibilitySettingsToBackend();
}
void setSongLinkRegion(String region) {
final normalized = _normalizeSongLinkRegion(region);
state = state.copyWith(songLinkRegion: normalized);
@@ -652,12 +599,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
void setPlayerMode(String mode) {
final normalized = mode == 'internal' ? 'internal' : 'external';
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+6 -85
View File
@@ -1,11 +1,8 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.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/extension_provider.dart';
@@ -23,7 +20,6 @@ class TrackState {
final String? artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
@@ -47,7 +43,6 @@ class TrackState {
this.artistName,
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
@@ -79,7 +74,6 @@ class TrackState {
String? artistName,
String? coverUrl,
String? headerImageUrl,
String? headerVideoUrl,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
@@ -105,7 +99,6 @@ class TrackState {
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
@@ -311,9 +304,6 @@ class TrackNotifier extends Notifier<TrackState> {
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
headerVideoUrl: normalizeRemoteHttpUrl(
result['header_video']?.toString(),
),
searchExtensionId: extensionId,
);
return;
@@ -324,8 +314,7 @@ class TrackNotifier extends Notifier<TrackState> {
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
@@ -346,9 +335,6 @@ class TrackNotifier extends Notifier<TrackState> {
headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
headerVideoUrl: normalizeRemoteHttpUrl(
artistData['header_video']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -373,7 +359,10 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(String query, {String? filterOverride}) async {
Future<void> search(
String query, {
String? filterOverride,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
@@ -571,7 +560,6 @@ class TrackNotifier extends Notifier<TrackState> {
String query, {
Map<String, dynamic>? options,
String? selectedFilter,
bool allowVerificationRetry = true,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
@@ -614,12 +602,6 @@ class TrackNotifier extends Notifier<TrackState> {
'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(
tracks: tracks,
searchArtists: [],
@@ -632,33 +614,6 @@ class TrackNotifier extends Notifier<TrackState> {
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_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(
isLoading: false,
error: e.toString(),
@@ -669,40 +624,6 @@ 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);
}
});
try {
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
);
if (!opened) return false;
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 {
await grantSub.cancel();
}
}
Future<void> checkAvailability(int index) async {
if (index < 0 || index >= state.tracks.length) return;
@@ -830,7 +751,6 @@ class TrackNotifier extends Notifier<TrackState> {
itemType: itemType,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -906,6 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+122 -341
View File
@@ -1,4 +1,3 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -24,8 +23,6 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.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/preview_button.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -56,9 +53,6 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final String? headerVideoUrl;
final String? headerImageUrl;
final List<String>? audioTraits;
final List<Track>? tracks;
final String? extensionId;
final String? artistId;
@@ -69,9 +63,6 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumId,
required this.albumName,
this.coverUrl,
this.headerVideoUrl,
this.headerImageUrl,
this.audioTraits,
this.tracks,
this.extensionId,
this.artistId,
@@ -90,10 +81,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _artistId;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
bool _tallHeader = false;
final ScrollController _scrollController = ScrollController();
String _legacyProviderIdFromResourceId(String value) {
@@ -152,9 +139,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
_headerVideoUrl = widget.headerVideoUrl;
_headerImageUrl = widget.headerImageUrl;
_audioTraits = widget.audioTraits ?? const [];
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
@@ -169,7 +153,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context, tall: _tallHeader);
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
@@ -177,12 +161,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
double _calculateExpandedHeight(BuildContext context, {bool tall = false}) {
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
if (tall) {
return (mediaSize.height * 0.68).clamp(440.0, 660.0);
}
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
String? _highResCoverUrl(String? url) {
@@ -233,11 +214,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumInfo?['album_type']?.toString(),
);
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
.map(
(t) => _parseTrack(
@@ -256,15 +232,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = artistId;
_albumType = albumType;
_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;
});
}
@@ -284,14 +251,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumInfo?['album_type']?.toString(),
);
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
.map(
(t) => _parseTrack(
@@ -310,15 +269,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = artistId;
_albumType = albumType;
_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;
});
}
@@ -343,101 +293,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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(
Map<String, dynamic> data, {
String? albumTypeFallback,
@@ -470,7 +325,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
composer: data['composer']?.toString(),
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -508,7 +362,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
],
@@ -521,6 +374,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
ColorScheme colorScheme,
Color pageBackgroundColor,
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName =
widget.artistName ??
@@ -529,16 +383,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
: 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(
expandedHeight: expandedHeight,
pinned: true,
@@ -566,46 +410,33 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
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(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (hasMotion)
MotionHeaderBanner(
videoUrl: motionUrl,
fallback: headerBgImage,
)
else if (showSquareCover)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: headerBgImage,
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
headerBgImage,
if (showSquareCover)
Container(color: Colors.black.withValues(alpha: 0.35)),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
Positioned(
left: 0,
right: 0,
@@ -635,75 +466,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
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(
widget.albumName,
style: TextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: _albumTitleFontSize(),
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -728,42 +495,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
_buildHeaderMeta(context, releaseDate),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: FilledButton.icon(
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,
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
disabledBackgroundColor: Colors.white
.withValues(alpha: 0.45),
disabledForegroundColor: Colors.black54,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.music_note,
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),
],
),
],
],
),
),
@@ -810,49 +641,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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(
BuildContext context,
ColorScheme colorScheme,
@@ -1284,7 +1072,6 @@ class _AlbumTrackItem extends ConsumerWidget {
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
extensionId: track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
@@ -1329,13 +1116,7 @@ class _AlbumTrackItem extends ConsumerWidget {
],
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PreviewButton(track: track),
TrackCollectionQuickActions(track: track),
],
),
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
+2 -64
View File
@@ -24,7 +24,6 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.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';
class _ArtistCache {
@@ -47,7 +46,6 @@ class _ArtistCache {
List<ArtistAlbum>? releases,
List<Track>? topTracks,
String? headerImageUrl,
String? headerVideoUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
@@ -55,7 +53,6 @@ class _ArtistCache {
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
);
@@ -67,7 +64,6 @@ class _CacheEntry {
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final DateTime expiresAt;
@@ -76,7 +72,6 @@ class _CacheEntry {
this.releases,
this.topTracks,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
required this.expiresAt,
});
@@ -87,7 +82,6 @@ class ArtistScreen extends ConsumerStatefulWidget {
final String artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
@@ -99,7 +93,6 @@ class ArtistScreen extends ConsumerStatefulWidget {
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
this.albums,
this.topTracks,
@@ -116,7 +109,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
int? _monthlyListeners;
String? _error;
@@ -225,7 +217,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
@@ -241,7 +232,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_monthlyListeners = widget.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -252,7 +242,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_releases = cached.releases;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_headerVideoUrl = cached.headerVideoUrl;
_monthlyListeners = cached.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -285,7 +274,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
String? headerVideo;
int? listeners;
if (_directMetadataProviderId() != null) {
@@ -322,9 +310,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
headerVideo =
artistInfo?['header_video'] as String? ??
artistData['header_video'] as String?;
listeners =
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
} else {
@@ -347,7 +332,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
headerImage = artistData['header_image'] as String?;
headerVideo = artistData['header_video'] as String?;
listeners = artistData['listeners'] as int?;
} else {
throw StateError('Failed to load artist metadata from extension');
@@ -356,8 +340,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final finalHeaderImage =
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalHeaderVideo =
headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl;
final finalListeners =
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
@@ -367,7 +349,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
headerVideoUrl: finalHeaderVideo,
monthlyListeners: finalListeners,
);
@@ -377,7 +358,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
@@ -430,7 +410,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId,
previewUrl: data['preview_url']?.toString(),
);
}
@@ -1148,15 +1127,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
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;
String? listenersText;
@@ -1204,37 +1174,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
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)
if (hasValidImage)
CachedCoverImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
@@ -1967,9 +1907,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
album.albumType == 'ep'
? context.l10n.releaseTypeEp
: context.l10n.releaseTypeSingle,
album.albumType == 'ep' ? 'EP' : 'Single',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
+94 -302
View File
@@ -1,6 +1,4 @@
import 'dart:io';
import 'dart:math';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -22,7 +20,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.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/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
@@ -100,7 +97,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
String? _highResCoverUrl(String? url) {
@@ -272,16 +269,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
Future<void> _openFile(
DownloadHistoryItem track, {
List<DownloadHistoryItem> queueItems = const [],
}) async {
Future<void> _openFile(DownloadHistoryItem track) async {
try {
await ref
.read(playbackProvider.notifier)
.playHistoryQueue(
queueItems.isNotEmpty ? queueItems : [track],
startItem: track,
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
} catch (e) {
if (mounted) {
@@ -505,32 +502,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
fit: StackFit.expand,
children: [
if (embeddedCoverPath != null)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
),
Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else if (widget.coverUrl != null)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(
@@ -541,8 +532,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
if (embeddedCoverPath != null || widget.coverUrl != null)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
@@ -572,43 +561,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
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(
widget.albumName,
style: TextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: _albumTitleFontSize(),
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -630,49 +587,62 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
_buildDownloadedHeaderMeta(
context,
tracks,
commonQuality,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Flexible(
child: FilledButton.icon(
onPressed: () => _playAll(tracks),
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(
context.l10n.tooltipPlay,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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.download_done,
size: 14,
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)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: context.l10n.actionShuffle,
onPressed: () => _shuffleAll(tracks),
icon: const Icon(
Icons.shuffle,
color: Colors.white,
),
),
),
],
),
],
@@ -701,132 +671,6 @@ 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(
BuildContext context,
ColorScheme colorScheme,
@@ -1044,8 +888,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () =>
_openFile(track, queueItems: navigationItems),
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
@@ -1104,8 +947,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) {
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
final sourceBitDepths = <int?>[];
final sourceSampleRates = <int?>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
@@ -1115,8 +956,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
fileName: item.safFileName,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
sourceBitDepths.add(item.bitDepth);
sourceSampleRates.add(item.sampleRate);
}
final formats = audioConversionTargetFormats
@@ -1147,15 +986,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality) {
onConvert: (format, bitrate) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
);
},
),
@@ -1166,8 +1002,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
required List<DownloadHistoryItem> allTracks,
required String targetFormat,
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <DownloadHistoryItem>[];
@@ -1199,23 +1033,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
final isLossless = isLosslessConversionTarget(targetFormat);
final losslessLabels = context.l10n.losslessConversionLabels;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
isLossless && losslessQuality.hasCaps
? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped(
selected.length,
targetFormat,
losslessQualityLabel(
losslessQuality,
originalLabel: losslessLabels.original,
originalQualityLabel: losslessLabels.originalQuality,
),
)
: isLossless
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
@@ -1244,6 +1067,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1320,8 +1147,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
);
if (coverPath != null) {
@@ -1339,39 +1164,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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) {
final treeUri = item.downloadTreeUri;
final relativeDir = item.safRelativeDir ?? '';
@@ -1421,9 +1213,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
),
newBitDepth: convertedBitDepth,
newSampleRate: convertedSampleRate,
clearAudioSpecs: !isLosslessOutput,
clearAudioSpecs: true,
);
}
try {
@@ -1444,9 +1234,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
),
newBitDepth: convertedBitDepth,
newSampleRate: convertedSampleRate,
clearAudioSpecs: !isLosslessOutput,
clearAudioSpecs: true,
);
}
@@ -1491,7 +1279,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -1541,7 +1331,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
content: Text(
context.l10n.replayGainBatchSuccess(successCount, total),
),
),
);
}
+1 -3
View File
@@ -32,7 +32,6 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.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';
part 'home_tab_helpers.dart';
@@ -178,6 +177,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
},
);
// Watch for new homeFeed extension being installed/enabled after init
_homeFeedExtSub = ref.listenManual<bool>(
extensionProvider.select(
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
@@ -821,8 +821,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistId: trackState.artistId!,
artistName: trackState.artistName!,
coverUrl: trackState.coverUrl,
headerImageUrl: trackState.headerImageUrl,
headerVideoUrl: trackState.headerVideoUrl,
albums: trackState.artistAlbums!,
extensionId: extensionId,
),
-38
View File
@@ -376,7 +376,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
],
),
),
PreviewButton(track: track),
TrackCollectionQuickActions(track: track),
],
),
@@ -993,9 +992,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _artistName;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
@override
void initState() {
@@ -1040,11 +1036,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
_albumType;
final totalTracks =
albumInfo['total_tracks'] as int? ?? _albumTotalTracks;
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
.map(
(t) => _parseTrack(
@@ -1061,15 +1052,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
_artistName = artistName;
_albumType = albumType;
_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;
});
} catch (e) {
@@ -1125,7 +1107,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
source: widget.extensionId,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -1172,9 +1153,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
albumId: widget.albumId,
albumName: widget.albumName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
headerImageUrl: _headerImageUrl,
audioTraits: _audioTraits,
tracks: _tracks,
extensionId: widget.extensionId,
artistId: _artistId,
@@ -1208,7 +1186,6 @@ class _ExtensionPlaylistScreenState
List<Track>? _tracks;
bool _isLoading = true;
String? _error;
String? _headerVideoUrl;
@override
void initState() {
@@ -1245,14 +1222,8 @@ class _ExtensionPlaylistScreenState
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final headerVideo = playlistInfo?['header_video']?.toString();
setState(() {
_tracks = tracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_isLoading = false;
});
} catch (e) {
@@ -1295,7 +1266,6 @@ class _ExtensionPlaylistScreenState
source: widget.extensionId,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -1338,7 +1308,6 @@ class _ExtensionPlaylistScreenState
return PlaylistScreen(
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
);
@@ -1368,7 +1337,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
List<ArtistAlbum>? _albums;
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
int? _monthlyListeners;
bool _isLoading = true;
String? _error;
@@ -1415,9 +1383,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artistInfo['header_image'] as String? ??
artistInfo['cover_url'] as String? ??
result['header_image'] as String?;
final headerVideo =
artistInfo['header_video'] as String? ??
result['header_video'] as String?;
final listeners =
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
@@ -1425,7 +1390,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = headerImage;
_headerVideoUrl = headerVideo;
_monthlyListeners = listeners;
_isLoading = false;
});
@@ -1482,7 +1446,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: (data['provider_id'] ?? widget.extensionId).toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -1522,7 +1485,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artistName: widget.artistName,
coverUrl: widget.coverUrl,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
monthlyListeners: _monthlyListeners,
albums: _albums,
topTracks: _topTracks,
+39 -223
View File
@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
@@ -69,14 +68,7 @@ class _LibraryTracksFolderScreenState
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
double _folderTitleFontSize(String title) {
final length = title.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
return (mediaSize.height * 0.45).clamp(300.0, 420.0);
}
IconData _modeIcon() {
@@ -647,27 +639,7 @@ class _LibraryTracksFolderScreenState
),
),
actions: [
if (isPlaylistMode && !_isSelectionMode) ...[
IconButton(
tooltip: context.l10n.collectionRenamePlaylist,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(
Icons.edit_outlined,
color: Colors.white,
size: 20,
),
),
onPressed: () {
final id = widget.playlistId ?? playlist?.id;
if (id == null || id.isEmpty) return;
_showRenamePlaylistDialog(context, id, playlist?.name ?? title);
},
),
if (isPlaylistMode && !_isSelectionMode)
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
@@ -683,7 +655,6 @@ class _LibraryTracksFolderScreenState
),
onPressed: () => _showCoverOptionsSheet(context, hasCustomCover),
),
],
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
@@ -710,67 +681,48 @@ class _LibraryTracksFolderScreenState
fit: StackFit.expand,
children: [
if (hasCustomCover)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(customCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return coverFallback;
},
errorBuilder: (_, _, _) => coverFallback,
),
Image.file(
File(customCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return coverFallback;
},
errorBuilder: (_, _, _) => coverFallback,
)
else if (hasCoverUrl)
_isCoverLocalPath(coverUrl)
? ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 32,
sigmaY: 32,
),
child: Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder:
(_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
),
? Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder:
(_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 32,
sigmaY: 32,
),
child: CachedNetworkImage(
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
: CachedNetworkImage(
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
coverFallback,
if (hasCustomCover || hasCoverUrl)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
@@ -800,82 +752,11 @@ class _LibraryTracksFolderScreenState
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
Widget squarePlaceholder() => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 48,
color: colorScheme.onSurfaceVariant,
),
);
Widget coverChild;
if (hasCustomCover) {
coverChild = Image.file(
File(customCoverPath),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
} else if (hasCoverUrl &&
_isCoverLocalPath(coverUrl)) {
coverChild = Image.file(
File(coverUrl),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => squarePlaceholder(),
);
} else if (hasCoverUrl) {
coverChild = CachedNetworkImage(
imageUrl:
_highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => squarePlaceholder(),
errorWidget: (_, _, _) => squarePlaceholder(),
);
} else {
coverChild = squarePlaceholder();
}
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: coverChild,
),
);
},
),
const SizedBox(height: 20),
Text(
title,
style: TextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: _folderTitleFontSize(title),
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -1192,71 +1073,6 @@ class _LibraryTracksFolderScreenState
),
);
}
Future<void> _showRenamePlaylistDialog(
BuildContext context,
String playlistId,
String currentName,
) async {
final controller = TextEditingController(text: currentName);
final formKey = GlobalKey<FormState>();
final nextName = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.collectionRenamePlaylist),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: dialogContext.l10n.collectionPlaylistNameHint,
),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return dialogContext.l10n.collectionPlaylistNameRequired;
}
return null;
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogContext.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
child: Text(dialogContext.l10n.dialogSave),
),
],
);
},
);
if (nextName == null || nextName.trim().isEmpty || !context.mounted) {
return;
}
await ref
.read(libraryCollectionsProvider.notifier)
.renamePlaylist(playlistId, nextName.trim());
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)),
);
}
}
class _CollectionTrackTile extends ConsumerWidget {
+106 -242
View File
@@ -1,6 +1,4 @@
import 'dart:io';
import 'dart:math';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -25,7 +23,6 @@ import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
@@ -98,7 +95,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
List<LocalLibraryItem> _buildSortedTracks() {
@@ -234,7 +231,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try {
await ref
.read(playbackProvider.notifier)
.playLocalLibraryQueue(_sortedTracksCache, startItem: track);
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverPath ?? '',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -312,6 +315,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final expandedHeight = _calculateExpandedHeight(context);
final commonQuality = _commonQualityCache;
return SliverAppBar(
expandedHeight: expandedHeight,
@@ -347,17 +351,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
fit: StackFit.expand,
children: [
if (widget.coverPath != null)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
),
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(
@@ -368,8 +369,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
if (widget.coverPath != null)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
@@ -399,63 +398,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
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: widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => 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(
widget.albumName,
style: TextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: _albumTitleFontSize(),
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -476,45 +423,90 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
_buildLocalHeaderMeta(context),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Flexible(
child: FilledButton.icon(
onPressed: _playAll,
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(
context.l10n.tooltipPlay,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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.folder,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.librarySourceLocal,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
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.music_note,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.queueTrackCount(
_sortedTracksCache.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: context.l10n.actionShuffle,
onPressed: _shuffleAll,
icon: const Icon(
Icons.shuffle,
color: Colors.white,
),
),
),
],
),
],
@@ -550,85 +542,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
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 _buildLocalHeaderMeta(BuildContext context) {
final tracks = _sortedTracksCache;
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.queueTrackCount(tracks.length)));
if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min'));
final quality = _commonQualityCache;
if (quality != null && quality.isNotEmpty) {
add(_metaWhiteItem(Icons.graphic_eq, quality));
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
);
}
Future<void> _playAll() async {
final tracks = _sortedTracksCache;
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(false);
await _openFile(tracks.first);
}
Future<void> _shuffleAll() async {
final tracks = _sortedTracksCache
.where((t) => !isCueVirtualPath(t.filePath))
.toList();
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(true);
await _openFile(tracks[Random().nextInt(tracks.length)]);
}
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
@@ -987,7 +900,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final durationMs = (item.duration ?? 0) * 1000;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
@@ -1284,8 +1196,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
) {
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
final sourceBitDepths = <int?>[];
final sourceSampleRates = <int?>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
@@ -1294,8 +1204,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
filePath: item.filePath,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
sourceBitDepths.add(item.bitDepth);
sourceSampleRates.add(item.sampleRate);
}
final formats = audioConversionTargetFormats
@@ -1326,15 +1234,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality) {
onConvert: (format, bitrate) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
);
},
),
@@ -1345,8 +1250,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
required List<LocalLibraryItem> allTracks,
required String targetFormat,
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1382,19 +1285,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
isLossless && losslessQuality.hasCaps
? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped(
selected.length,
targetFormat,
losslessQualityLabel(
losslessQuality,
originalLabel:
context.l10n.losslessConversionLabels.original,
originalQualityLabel:
context.l10n.losslessConversionLabels.originalQuality,
),
)
: isLossless
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
@@ -1498,8 +1389,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
);
if (coverPath != null) {
@@ -1517,31 +1406,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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,
);
}
if (isSaf) {
final uri = Uri.parse(item.filePath);
final pathSegments = uri.pathSegments;
@@ -1624,8 +1488,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
newFilePath: safUri,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
);
}
@@ -1643,8 +1505,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
);
}
@@ -1689,7 +1549,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -1739,7 +1601,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
content: Text(
context.l10n.replayGainBatchSuccess(successCount, total),
),
),
);
}

Some files were not shown because too many files have changed in this diff Show More