fix provider fallbacks and public branding

This commit is contained in:
zarzet
2026-05-04 00:51:52 +07:00
parent 1b4a6cd042
commit e187ac461d
45 changed files with 615 additions and 238 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="SpotiFLAC"
android:label="SpotiFLAC Mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
+3 -3
View File
@@ -1,16 +1,16 @@
{
"name": "SpotiFLAC Source",
"name": "SpotiFLAC Mobile Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC",
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34773644
}
+22 -4
View File
@@ -17,6 +17,24 @@ type HTTPResponse struct {
Headers map[string]string `json:"headers"`
}
const maxExtensionHTTPResponseBytes = 16 << 20
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
)
if err != nil {
return nil, err
}
if len(body) > maxExtensionHTTPResponseBytes {
return nil, fmt.Errorf(
"response body exceeds %d byte limit; use file.download for large media",
maxExtensionHTTPResponseBytes,
)
}
return body, nil
}
func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -99,7 +117,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -197,7 +215,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -307,7 +325,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
@@ -433,7 +451,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
+3 -3
View File
@@ -22,7 +22,7 @@
<string>zh-Hant</string>
</array>
<key>CFBundleDisplayName</key>
<string>SpotiFLAC</string>
<string>SpotiFLAC Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -30,7 +30,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>SpotiFLAC</string>
<string>SpotiFLAC Mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -80,7 +80,7 @@
<!-- Photo Library (for cover art if needed) -->
<key>NSPhotoLibraryUsageDescription</key>
<string>SpotiFLAC needs access to save album artwork</string>
<string>SpotiFLAC Mobile needs access to save album artwork</string>
<!-- URL Schemes for deep linking -->
<key>CFBundleURLTypes</key>
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
@@ -105,7 +106,7 @@ class SpotiFLACApp extends ConsumerWidget {
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
return MaterialApp.router(
title: 'SpotiFLAC',
title: AppInfo.appName,
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
+2 -2
View File
@@ -5912,7 +5912,7 @@ abstract class AppLocalizations {
/// Notification title while downloading an app update
///
/// In en, this message translates to:
/// **'Downloading SpotiFLAC v{version}'**
/// **'Downloading SpotiFLAC Mobile v{version}'**
String notifDownloadingUpdate(String version);
/// Notification body showing update download progress
@@ -5930,7 +5930,7 @@ abstract class AppLocalizations {
/// Notification body when app update is ready to install
///
/// In en, this message translates to:
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
/// **'SpotiFLAC Mobile v{version} downloaded. Tap to install.'**
String notifUpdateReadyBody(String version);
/// Notification title when app update download fails
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Startseite';
@@ -3508,7 +3508,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3521,7 +3521,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3473,7 +3473,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3486,7 +3486,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+6 -6
View File
@@ -9,7 +9,7 @@ class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3473,7 +3473,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3486,7 +3486,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -3788,7 +3788,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
AppLocalizationsEsEs() : super('es_ES');
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Inicio';
@@ -7211,7 +7211,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -7224,7 +7224,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Accueil';
@@ -3477,7 +3477,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3490,7 +3490,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale);
@override
String get appName => 'SpotiFlac';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'होम';
@@ -3474,7 +3474,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3487,7 +3487,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsId extends AppLocalizations {
AppLocalizationsId([String locale = 'id']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Beranda';
@@ -3483,7 +3483,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3496,7 +3496,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'ホーム';
@@ -3461,7 +3461,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3474,7 +3474,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsKo extends AppLocalizations {
AppLocalizationsKo([String locale = 'ko']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3454,7 +3454,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3467,7 +3467,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsNl extends AppLocalizations {
AppLocalizationsNl([String locale = 'nl']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3474,7 +3474,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3487,7 +3487,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+6 -6
View File
@@ -9,7 +9,7 @@ class AppLocalizationsPt extends AppLocalizations {
AppLocalizationsPt([String locale = 'pt']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3473,7 +3473,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3486,7 +3486,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -3788,7 +3788,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
AppLocalizationsPtPt() : super('pt_PT');
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Início';
@@ -7204,7 +7204,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -7217,7 +7217,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Главная';
@@ -3533,7 +3533,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3546,7 +3546,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsTr extends AppLocalizations {
AppLocalizationsTr([String locale = 'tr']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Ana sayfa';
@@ -3500,7 +3500,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3513,7 +3513,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsUk extends AppLocalizations {
AppLocalizationsUk([String locale = 'uk']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Головна';
@@ -3533,7 +3533,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Завантаження SpotiFLAC v$version';
return 'Завантаження SpotiFLAC Mobile v$version';
}
@override
@@ -3546,7 +3546,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version завантажений. Натисніть щоб установити.';
return 'SpotiFLAC Mobile v$version завантажений. Натисніть щоб установити.';
}
@override
+9 -9
View File
@@ -9,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -3473,7 +3473,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3486,7 +3486,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -3788,7 +3788,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
AppLocalizationsZhCn() : super('zh_CN');
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => '主页';
@@ -7170,7 +7170,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -7183,7 +7183,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -7266,7 +7266,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
AppLocalizationsZhTw() : super('zh_TW');
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFLAC Mobile';
@override
String get navHome => 'Home';
@@ -10661,7 +10661,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -10674,7 +10674,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "de",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "en",
"@@last_modified": "2026-04-28",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4564,7 +4564,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4592,7 +4592,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "es",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "es_ES",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "fr",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "hi",
"@@last_modified": "2026-01-16",
"appName": "SpotiFlac",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "id",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4481,7 +4481,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4509,7 +4509,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "ja",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "ko",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "nl",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "pt",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "pt_PT",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "ru",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "tr",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4477,7 +4477,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4505,7 +4505,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "uk",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Завантаження SpotiFLAC v{version}",
"notifDownloadingUpdate": "Завантаження SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} завантажений. Натисніть щоб установити.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} завантажений. Натисніть щоб установити.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "zh",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "zh_CN",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"@@locale": "zh_TW",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -4473,7 +4473,7 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
@@ -4501,7 +4501,7 @@
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"notifUpdateReadyBody": "SpotiFLAC Mobile v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
+109 -34
View File
@@ -110,6 +110,7 @@ class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final String? providerId;
final List<ExploreSection> sections;
final DateTime? lastFetched;
@@ -117,6 +118,7 @@ class ExploreState {
this.isLoading = false,
this.error,
this.greeting,
this.providerId,
this.sections = const [],
this.lastFetched,
});
@@ -127,6 +129,8 @@ class ExploreState {
bool? isLoading,
String? error,
String? greeting,
String? providerId,
bool clearProviderId = false,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
@@ -134,6 +138,7 @@ class ExploreState {
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
providerId: clearProviderId ? null : (providerId ?? this.providerId),
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
@@ -189,14 +194,54 @@ List<Map<String, Object?>> _normalizeExploreSectionsPayload(
return sections;
}
List<Map<String, Object?>> _decodeExploreCacheSections(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) return const [];
return _normalizeExploreSectionsPayload(decoded['sections']);
List<Map<String, Object?>> _withDefaultExploreProviderId(
List<Map<String, Object?>> normalizedSections,
String providerId,
) {
final normalizedProviderId = providerId.trim();
if (normalizedProviderId.isEmpty) return normalizedSections;
return normalizedSections
.map((section) {
final rawItems = section['items'];
if (rawItems is! List) return section;
return <String, Object?>{
...section,
'items': rawItems
.map((rawItem) {
if (rawItem is! Map) return rawItem;
final item = Map<String, Object?>.from(rawItem);
final itemProviderId =
item['provider_id']?.toString().trim() ?? '';
if (itemProviderId.isEmpty) {
item['provider_id'] = normalizedProviderId;
}
return item;
})
.toList(growable: false),
};
})
.toList(growable: false);
}
String _encodeExploreCacheSections(List<Map<String, Object?>> sections) {
return jsonEncode({'sections': sections});
Map<String, Object?> _decodeExploreCache(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) {
return const {'provider_id': null, 'sections': <Map<String, Object?>>[]};
}
final providerId = decoded['provider_id']?.toString().trim();
var sections = _normalizeExploreSectionsPayload(decoded['sections']);
if (providerId != null && providerId.isNotEmpty) {
sections = _withDefaultExploreProviderId(sections, providerId);
}
return {'provider_id': providerId, 'sections': sections};
}
String _encodeExploreCache(Map<String, Object?> cachePayload) {
return jsonEncode(cachePayload);
}
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
@@ -234,10 +279,24 @@ class ExploreNotifier extends Notifier<ExploreState> {
final cachedTs = prefs.getInt(_cacheTsKey);
if (cached == null || cached.isEmpty) return;
final normalizedSections = await compute(
_decodeExploreCacheSections,
cached,
);
final cachePayload = await compute(_decodeExploreCache, cached);
final providerId = cachePayload['provider_id']?.toString().trim();
final rawSections = cachePayload['sections'];
var normalizedSections = rawSections is List
? rawSections
.whereType<Map<Object?, Object?>>()
.map((section) => Map<String, Object?>.from(section))
.toList(growable: false)
: const <Map<String, Object?>>[];
final resolvedProviderId = providerId?.isNotEmpty == true
? providerId
: _resolveHomeFeedExtension()?.id;
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
normalizedSections = _withDefaultExploreProviderId(
normalizedSections,
resolvedProviderId,
);
}
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
@@ -251,23 +310,51 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.i('Restored ${sections.length} cached explore sections');
state = ExploreState(
greeting: _getLocalGreeting(),
providerId: resolvedProviderId,
sections: sections,
lastFetched: lastFetched,
);
} catch (e) {
_log.w('Failed to restore explore cache: $e');
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTsKey);
_log.d('Removed invalid explore cache');
} catch (clearError) {
_log.w('Failed to remove invalid explore cache: $clearError');
}
}
}
Extension? _resolveHomeFeedExtension() {
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
final enabledHomeFeedExtensions = ref
.read(extensionProvider)
.extensions
.where((extension) => extension.enabled && extension.hasHomeFeed)
.toList(growable: false);
if (preferredId != null && preferredId.isNotEmpty) {
return enabledHomeFeedExtensions
.where((extension) => extension.id == preferredId)
.firstOrNull;
}
return enabledHomeFeedExtensions.firstOrNull;
}
Future<void> _saveToCache(
List<Map<String, Object?>> normalizedSections,
String providerId,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = await compute(
_encodeExploreCacheSections,
normalizedSections,
);
final encoded = await compute(_encodeExploreCache, {
'provider_id': providerId,
'sections': normalizedSections,
});
await prefs.setString(_cacheKey, encoded);
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${normalizedSections.length} explore sections to cache');
@@ -313,24 +400,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (preferredId == null && extension.id == 'spotify-web') {
break;
}
}
}
final targetExt = _resolveHomeFeedExtension();
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
@@ -367,10 +437,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final normalizedSections = await compute(
final normalizedSectionsWithoutProvider = await compute(
_normalizeExploreSectionsPayload,
sectionsData,
);
final normalizedSections = _withDefaultExploreProviderId(
normalizedSectionsWithoutProvider,
targetExt.id,
);
if (requestId != _homeFeedRequestId) return;
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
@@ -391,11 +465,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
state = ExploreState(
isLoading: false,
greeting: localGreeting,
providerId: targetExt.id,
sections: sections,
lastFetched: DateTime.now(),
);
_saveToCache(normalizedSections);
_saveToCache(normalizedSections, targetExt.id);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
if (requestId != _homeFeedRequestId) return;
+68 -16
View File
@@ -24,6 +24,30 @@ bool _stringListEquals(List<String> a, List<String> b) {
return true;
}
List<String>? _tryDecodeStringListPreference(String rawJson, String key) {
try {
final decoded = jsonDecode(rawJson);
if (decoded is! List) {
throw const FormatException('expected a JSON list');
}
final values = <String>[];
for (final item in decoded) {
if (item is! String) {
throw const FormatException('expected string entries');
}
final trimmed = item.trim();
if (trimmed.isNotEmpty) {
values.add(trimmed);
}
}
return values;
} catch (e) {
_log.w('Ignoring invalid $key preference: $e');
return null;
}
}
class BuiltInProviderSpec {
final String id;
final String displayName;
@@ -1630,15 +1654,27 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await PlatformBridge.setProviderPriority(priority);
final saved = _tryDecodeStringListPreference(
savedJson,
_providerPriorityKey,
);
if (saved != null) {
priority = _sanitizeDownloadProviderPriority(saved);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await PlatformBridge.setProviderPriority(priority);
} else {
await prefs.remove(_providerPriorityKey);
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await PlatformBridge.setProviderPriority(priority);
_log.d('Recovered provider priority from defaults: $priority');
}
} else {
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await PlatformBridge.setProviderPriority(priority);
_log.d('Using default provider priority: $priority');
}
@@ -1691,18 +1727,34 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = _sanitizeMetadataProviderPriority(
_replaceRetiredBuiltInMetadataProviders(
saved.map((e) => e as String).toList(),
),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
await prefs.setString(
final saved = _tryDecodeStringListPreference(
savedJson,
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
if (saved != null) {
priority = _sanitizeMetadataProviderPriority(
_replaceRetiredBuiltInMetadataProviders(saved),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
await prefs.remove(_metadataProviderPriorityKey);
final backendPriority =
await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(backendPriority);
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
_log.d(
'Recovered metadata provider priority from defaults: $priority',
);
}
} else {
final backendPriority =
await PlatformBridge.getMetadataProviderPriority();
+50 -33
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _settingsCorruptBackupKey = 'app_settings_corrupt_backup';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 11;
const _spotifyClientSecretKey = 'spotify_client_secret';
@@ -41,40 +42,56 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _loadSettings() async {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
loaded.defaultService,
);
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
loaded.searchProvider,
);
state = loaded.copyWith(
useExtensionProviders: true,
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: sanitizedDefaultService ?? '',
searchProvider: sanitizedSearchProvider,
clearSearchProvider:
loaded.searchProvider != null && sanitizedSearchProvider == null,
);
final rawSettings = prefs.getString(_settingsKey);
if (rawSettings != null) {
AppSettings? loaded;
try {
final decoded = jsonDecode(rawSettings);
if (decoded is! Map) {
throw const FormatException('settings root must be a JSON object');
}
loaded = AppSettings.fromJson(Map<String, dynamic>.from(decoded));
} catch (e, stack) {
_log.e('Failed to load settings, resetting to defaults: $e', e, stack);
try {
await prefs.setString(_settingsCorruptBackupKey, rawSettings);
await prefs.remove(_settingsKey);
} catch (backupError) {
_log.w('Failed to backup corrupt settings: $backupError');
}
}
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
if (loaded != null) {
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
loaded.defaultService,
);
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
loaded.searchProvider,
);
state = loaded.copyWith(
useExtensionProviders: true,
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: sanitizedDefaultService ?? '',
searchProvider: sanitizedSearchProvider,
clearSearchProvider:
loaded.searchProvider != null && sanitizedSearchProvider == null,
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
}
await _cleanupRetiredSpotifySettings();
+8 -4
View File
@@ -449,7 +449,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
_preWarmCacheForTracks(tracks, service: providerId);
return;
case 'playlist':
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -469,7 +469,7 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
_preWarmCacheForTracks(tracks, service: providerId);
return;
case 'artist':
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -1054,7 +1054,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
void _preWarmCacheForTracks(List<Track> tracks) {
void _preWarmCacheForTracks(List<Track> tracks, {String? service}) {
if (tracks.isEmpty) return;
final cacheRequests = <Map<String, String>>[];
for (final track in tracks) {
@@ -1062,12 +1062,16 @@ class TrackNotifier extends Notifier<TrackState> {
if (isrc == null || isrc.isEmpty) {
continue;
}
final effectiveService =
(track.source?.trim().isNotEmpty == true ? track.source : service)
?.trim();
cacheRequests.add({
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id,
'service': 'tidal',
if (effectiveService != null && effectiveService.isNotEmpty)
'service': effectiveService,
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
break;
+41 -3
View File
@@ -1896,14 +1896,38 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
String? _providerIdForExploreItem(ExploreItem item) {
final itemProviderId = item.providerId?.trim();
if (itemProviderId != null && itemProviderId.isNotEmpty) {
return itemProviderId;
}
final feedProviderId = ref.read(exploreProvider).providerId?.trim();
if (feedProviderId != null && feedProviderId.isNotEmpty) {
return feedProviderId;
}
return null;
}
void _showMissingExploreProviderMessage() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.extensionsNoHomeFeedExtensions)),
);
}
void _navigateToExploreItem(ExploreItem item) async {
final extensionId = item.providerId ?? 'spotify-web';
final extensionId = _providerIdForExploreItem(item);
switch (item.type) {
case 'track':
_showTrackBottomSheet(item);
return;
case 'album':
if (extensionId == null) {
_showMissingExploreProviderMessage();
return;
}
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -1917,6 +1941,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return;
case 'playlist':
if (extensionId == null) {
_showMissingExploreProviderMessage();
return;
}
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -1930,6 +1958,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return;
case 'artist':
if (extensionId == null) {
_showMissingExploreProviderMessage();
return;
}
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2064,7 +2096,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
isrc: null,
releaseDate: item.releaseDate,
coverUrl: item.coverUrl,
source: item.providerId ?? 'spotify-web',
source: _providerIdForExploreItem(item),
);
if (settings.askQualityBeforeDownload) {
@@ -2105,11 +2137,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _navigateToTrackAlbum(ExploreItem item) async {
if (item.albumId != null && item.albumId!.isNotEmpty) {
final extensionId = _providerIdForExploreItem(item);
if (extensionId == null) {
_showMissingExploreProviderMessage();
return;
}
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId ?? 'spotify-web',
extensionId: extensionId,
albumId: item.albumId!,
albumName: item.albumName ?? 'Album',
coverUrl: item.coverUrl,
+3 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
class NotificationService {
@@ -547,7 +548,7 @@ class NotificationService {
id: updateDownloadId,
title:
_l10n?.notifDownloadingUpdate(version) ??
'Downloading SpotiFLAC v$version',
'Downloading ${AppInfo.appName} v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
@@ -585,7 +586,7 @@ class NotificationService {
title: _l10n?.notifUpdateReady ?? 'Update Ready',
body:
_l10n?.notifUpdateReadyBody(version) ??
'SpotiFLAC v$version downloaded. Tap to install.',
'${AppInfo.appName} v$version downloaded. Tap to install.',
details: details,
);
}
+195 -24
View File
@@ -1,8 +1,10 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
@@ -12,20 +14,151 @@ import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ClickableMetadata');
const _deezerExtensionId = 'deezer';
Future<List<Map<String, dynamic>>> _searchDeezerExtension(
class _MetadataSearchResult {
final String providerId;
final List<Map<String, dynamic>> items;
const _MetadataSearchResult({required this.providerId, required this.items});
}
Future<_MetadataSearchResult?> _searchMetadataProviders(
BuildContext context,
String query, {
required String filter,
int limit = 5,
}) {
String? sourceProviderId,
}) async {
final providerIds = _metadataSearchProviderCandidates(
context,
sourceProviderId: sourceProviderId,
);
for (final providerId in providerIds) {
try {
final items = await _searchMetadataProvider(
providerId,
query,
filter: filter,
limit: limit,
);
if (items.isNotEmpty) {
return _MetadataSearchResult(providerId: providerId, items: items);
}
} catch (e) {
_log.w(
'Metadata lookup failed for provider "$providerId", filter=$filter: $e',
);
}
}
return null;
}
Future<List<Map<String, dynamic>>> _searchMetadataProvider(
String providerId,
String query, {
required String filter,
required int limit,
}) async {
if (isBuiltInSearchProvider(providerId)) {
final result = await PlatformBridge.searchProviderAll(
providerId,
query,
trackLimit: 0,
artistLimit: filter == 'artist' ? limit : 0,
filter: filter,
);
return _extractSearchItems(result, filter);
}
return PlatformBridge.customSearchWithExtension(
_deezerExtensionId,
providerId,
query,
options: {'filter': filter, 'limit': limit},
);
}
List<Map<String, dynamic>> _extractSearchItems(
Map<String, dynamic> result,
String filter,
) {
final key = switch (filter) {
'artist' => 'artists',
'album' => 'albums',
_ => '${filter}s',
};
final items = result[key];
if (items is! List) return const [];
return items
.whereType<Map<Object?, Object?>>()
.map((item) => Map<String, dynamic>.from(item))
.toList(growable: false);
}
List<String> _metadataSearchProviderCandidates(
BuildContext context, {
String? sourceProviderId,
}) {
final container = ProviderScope.containerOf(context, listen: false);
final extensionState = container.read(extensionProvider);
final settings = container.read(settingsProvider);
final extensionNotifier = container.read(extensionProvider.notifier);
final candidates = <String>[];
void addProvider(String? providerId) {
final normalized = providerId?.trim();
if (normalized == null ||
normalized.isEmpty ||
candidates.contains(normalized) ||
!_canSearchMetadataProvider(normalized, extensionState)) {
return;
}
candidates.add(normalized);
}
addProvider(sourceProviderId);
addProvider(settings.searchProvider);
for (final providerId in extensionState.metadataProviderPriority) {
addProvider(providerId);
}
for (final providerId in extensionNotifier.getAllMetadataProviders()) {
addProvider(providerId);
}
final searchExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(growable: false);
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary == true,
)) {
addProvider(extension.id);
}
for (final extension in searchExtensions.where(
(ext) => ext.searchBehavior?.primary != true,
)) {
addProvider(extension.id);
}
for (final providerId in builtInSearchProviderIds) {
addProvider(providerId);
}
return candidates;
}
bool _canSearchMetadataProvider(
String providerId,
ExtensionState extensionState,
) {
if (isBuiltInSearchProvider(providerId)) return true;
return extensionState.extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
);
}
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
@@ -54,14 +187,17 @@ Future<void> navigateToArtist(
_showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
try {
final artistList = await _searchDeezerExtension(
final searchResult = await _searchMetadataProviders(
context,
artistName,
filter: 'artist',
limit: 3,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final artistList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (artistList.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
return;
@@ -81,6 +217,10 @@ Future<void> navigateToArtist(
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? artistName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, context.l10n.trackArtist);
@@ -93,7 +233,7 @@ Future<void> navigateToArtist(
artistId: resolvedId,
artistName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up artist "$artistName": $e', e);
@@ -113,10 +253,7 @@ Future<void> navigateToAlbum(
}) async {
if (albumName.isEmpty) return;
if (albumId != null &&
albumId.isNotEmpty &&
albumId != 'unknown' &&
albumId != 'deezer:unknown') {
if (albumId != null && albumId.isNotEmpty && !_isUnknownResourceId(albumId)) {
_pushAlbumScreen(
context,
albumId: albumId,
@@ -127,25 +264,23 @@ Future<void> navigateToAlbum(
return;
}
if (extensionId != null) {
_showUnavailable(context, 'Album');
return;
}
_showLoadingSnackBar(context, 'Looking up album...');
try {
final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName'
: albumName;
final albumList = await _searchDeezerExtension(
final searchResult = await _searchMetadataProviders(
context,
query,
filter: 'album',
limit: 5,
sourceProviderId: extensionId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final albumList = searchResult?.items ?? const <Map<String, dynamic>>[];
if (albumList.isEmpty) {
_showUnavailable(context, 'Album');
return;
@@ -165,6 +300,10 @@ Future<void> navigateToAlbum(
final resolvedId = bestMatch['id'] as String? ?? '';
final resolvedName = bestMatch['name'] as String? ?? albumName;
final resolvedImage = bestMatch['images'] as String?;
final resolvedProviderId = _resolveResultProviderId(
bestMatch,
searchResult?.providerId,
);
if (resolvedId.isEmpty) {
_showUnavailable(context, 'Album');
@@ -177,7 +316,7 @@ Future<void> navigateToAlbum(
albumId: resolvedId,
albumName: resolvedName,
coverUrl: resolvedImage ?? coverUrl,
extensionId: _deezerExtensionId,
extensionId: resolvedProviderId,
);
} catch (e) {
_log.e('Failed to look up album "$albumName": $e', e);
@@ -194,11 +333,15 @@ void _pushArtistScreen(
String? coverUrl,
String? extensionId,
}) {
final isExtension =
extensionId != null && !isBuiltInMetadataProvider(extensionId);
final resolvedProviderId = extensionId;
_pushViaPreferredNavigator(
context,
(context) => extensionId != null
(context) => isExtension && resolvedProviderId != null
? ExtensionArtistScreen(
extensionId: extensionId,
extensionId: resolvedProviderId,
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
@@ -207,6 +350,7 @@ void _pushArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: coverUrl,
extensionId: resolvedProviderId,
),
);
}
@@ -235,6 +379,7 @@ void _pushAlbumScreen(
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
extensionId: resolvedExtensionId,
tracks: const [],
),
);
@@ -289,9 +434,7 @@ void _showLoadingSnackBar(BuildContext context, String message) {
}
void _showUnavailable(BuildContext context, String type) {
ScaffoldMessenger.of(
context,
).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
);
}
@@ -504,21 +647,49 @@ List<String> _parseArtistIds(String? rawArtistIds) {
String? _normalizeArtistId(String? artistId) {
final id = artistId?.trim();
if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') {
if (id == null || _isUnknownResourceId(id)) {
return null;
}
return id;
}
bool _isUnknownResourceId(String id) {
final normalized = id.trim().toLowerCase();
return normalized.isEmpty ||
normalized == 'unknown' ||
normalized.endsWith(':unknown');
}
String? _resolveResultProviderId(
Map<String, dynamic> result,
String? fallbackProviderId,
) {
final providerId = result['provider_id']?.toString().trim();
if (providerId != null && providerId.isNotEmpty) return providerId;
final source = result['source']?.toString().trim();
if (source != null && source.isNotEmpty) return source;
final fallback = fallbackProviderId?.trim();
return fallback != null && fallback.isNotEmpty ? fallback : null;
}
bool _canNavigateArtistDirectly({
required String artistId,
required String? extensionId,
}) {
if (extensionId != null) return true;
if (artistId.startsWith('deezer:')) return true;
final providerPrefix = _resourceProviderPrefix(artistId);
if (providerPrefix != null && isBuiltInMetadataProvider(providerPrefix)) {
return true;
}
return _spotifyArtistIdPattern.hasMatch(artistId);
}
String? _resourceProviderPrefix(String resourceId) {
final colonIndex = resourceId.indexOf(':');
if (colonIndex <= 0) return null;
return resourceId.substring(0, colonIndex).trim();
}
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
class ClickableAlbumName extends StatelessWidget {