From 5fa00c00511713dc64ffde0d43bc739d7a625962 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Feb 2026 21:22:00 +0700 Subject: [PATCH] feat: v3.5.0 - instant home feed, SAF display path, per-app language - Cache home feed to SharedPreferences for instant restore on app launch - Resolve SAF tree URIs to human-readable paths (e.g. /storage/emulated/0/Music) - Add Android 13+ per-app language support (locale_config.xml) - Bump version to 3.5.0+73 --- CHANGELOG.md | 3 +- android/app/src/main/AndroidManifest.xml | 3 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 28 +++++++ .../app/src/main/res/xml/locale_config.xml | 16 ++++ lib/constants/app_info.dart | 4 +- lib/providers/explore_provider.dart | 82 ++++++++++++++++++- lib/screens/home_tab.dart | 2 +- pubspec.yaml | 2 +- 8 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/res/xml/locale_config.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d7b97e..01623245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ## [3.5.0] - 2026-02-06 ### Highlights - - **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs) - Select download folder via SAF tree picker - Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths @@ -11,6 +10,8 @@ ### Added +- Home feed disk caching via SharedPreferences for instant restore on app startup +- SAF display path resolver in native Android layer (converts tree URIs to readable paths) - New settings fields for storage mode + SAF tree URI - SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF - SAF library scan mode (DocumentFile traversal + metadata read) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0de21e00..9043277b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,7 +21,8 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="false" - android:enableOnBackInvokedCallback="true"> + android:enableOnBackInvokedCallback="true" + android:localeConfig="@xml/locale_config"> "/storage/emulated/0/Music" + * "content://...tree/1234-5678%3AMusic" -> "SD Card/Music" + */ + private fun resolveSafDisplayPath(treeUri: Uri): String { + try { + val docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri) + if (docId.isNullOrEmpty()) return treeUri.toString() + + val parts = docId.split(":", limit = 2) + val storageId = parts.getOrNull(0) ?: return docId + val subPath = parts.getOrNull(1) ?: "" + + val prefix = if (storageId == "primary") { + "/storage/emulated/0" + } else { + "SD Card" + } + + return if (subPath.isEmpty()) prefix else "$prefix/$subPath" + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to resolve SAF display path: ${e.message}") + return treeUri.toString() + } + } + data class SafScanProgress( var totalFiles: Int = 0, var scannedFiles: Int = 0, diff --git a/android/app/src/main/res/xml/locale_config.xml b/android/app/src/main/res/xml/locale_config.xml new file mode 100644 index 00000000..95de83ad --- /dev/null +++ b/android/app/src/main/res/xml/locale_config.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index f33f4ecc..55345df9 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.4.0'; - static const String buildNumber = '72'; + static const String version = '3.5.0'; + static const String buildNumber = '73'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 9af13ec2..cd2b01a7 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -47,6 +49,20 @@ class ExploreItem { durationMs: json['duration_ms'] as int? ?? 0, ); } + + Map toJson() => { + 'id': id, + 'uri': uri, + 'type': type, + 'name': name, + 'artists': artists, + 'description': description, + 'cover_url': coverUrl, + 'provider_id': providerId, + 'album_id': albumId, + 'album_name': albumName, + 'duration_ms': durationMs, + }; } class ExploreSection { @@ -75,6 +91,12 @@ class ExploreSection { isYTMusicQuickPicks: isQuickPicks, ); } + + Map toJson() => { + 'uri': uri, + 'title': title, + 'items': items.map((i) => i.toJson()).toList(), + }; } class ExploreState { @@ -136,20 +158,71 @@ bool _isYTMusicQuickPicksItems(List items) { } class ExploreNotifier extends Notifier { + static const _cacheKey = 'explore_home_feed_cache'; + static const _cacheTsKey = 'explore_home_feed_ts'; + @override ExploreState build() { + _restoreFromCache(); return const ExploreState(); } + /// Restore cached home feed from SharedPreferences immediately on startup + Future _restoreFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cached = prefs.getString(_cacheKey); + final cachedTs = prefs.getInt(_cacheTsKey); + if (cached == null || cached.isEmpty) return; + + final data = jsonDecode(cached) as Map; + final sectionsData = data['sections'] as List? ?? []; + final sections = sectionsData + .map((s) => ExploreSection.fromJson(s as Map)) + .toList(); + + if (sections.isEmpty) return; + + final lastFetched = cachedTs != null + ? DateTime.fromMillisecondsSinceEpoch(cachedTs) + : null; + + _log.i('Restored ${sections.length} cached explore sections'); + state = ExploreState( + greeting: _getLocalGreeting(), + sections: sections, + lastFetched: lastFetched, + ); + } catch (e) { + _log.w('Failed to restore explore cache: $e'); + } + } + + /// Save home feed to SharedPreferences for instant restore on next launch + Future _saveToCache(List sections) async { + try { + final prefs = await SharedPreferences.getInstance(); + final data = { + 'sections': sections.map((s) => s.toJson()).toList(), + }; + await prefs.setString(_cacheKey, jsonEncode(data)); + await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); + _log.d('Saved ${sections.length} explore sections to cache'); + } catch (e) { + _log.w('Failed to save explore cache: $e'); + } + } + /// Fetch home feed from spotify-web extension Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); + // If we have cached content and it's fresh enough, skip network fetch if (!forceRefresh && state.hasContent && state.lastFetched != null && DateTime.now().difference(state.lastFetched!).inMinutes < 5) { - _log.d('Using cached home feed'); + _log.d('Using cached home feed (fresh enough)'); return; } @@ -158,7 +231,9 @@ class ExploreNotifier extends Notifier { return; } - state = state.copyWith(isLoading: true, error: null); + // Only show loading spinner if we have no cached content to display + final showLoading = !state.hasContent; + state = state.copyWith(isLoading: showLoading, error: null); try { final extState = ref.read(extensionProvider); @@ -231,6 +306,9 @@ class ExploreNotifier extends Notifier { sections: sections, lastFetched: DateTime.now(), ); + + // Save to disk cache for instant restore on next app launch + _saveToCache(sections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); state = state.copyWith( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 8e9bb468..3bf15914 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -495,7 +495,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient : null; final hasExploreContent = exploreSections.isNotEmpty; - final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent; + final showExplore = !hasActualResults && !isLoading && !showRecentAccess && (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; // Get current search extension and its filters final settings = ref.watch(settingsProvider); diff --git a/pubspec.yaml b/pubspec.yaml index ce9f2810..bda18402 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.4.0+72 +version: 3.5.0+73 environment: sdk: ^3.10.0