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
This commit is contained in:
zarzet
2026-02-06 21:22:00 +07:00
parent 239e073a8c
commit 5fa00c0051
8 changed files with 132 additions and 8 deletions
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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">
<activity
android:name=".MainActivity"
@@ -62,9 +62,37 @@ class MainActivity: FlutterFragmentActivity() {
val payload = JSONObject()
payload.put("tree_uri", uri.toString())
payload.put("display_name", resolveSafDisplayPath(uri))
result.success(payload.toString())
}
/**
* Resolve a SAF tree URI to a human-readable path.
* e.g. "content://...tree/primary%3AMusic" -> "/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,
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
<locale android:name="es-ES" />
<locale android:name="id" />
<locale android:name="pt-PT" />
<locale android:name="ja" />
<locale android:name="tr" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="hi" />
<locale android:name="ko" />
<locale android:name="nl" />
<locale android:name="zh" />
</locale-config>
+2 -2
View File
@@ -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';
+80 -2
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'uri': uri,
'title': title,
'items': items.map((i) => i.toJson()).toList(),
};
}
class ExploreState {
@@ -136,20 +158,71 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
}
class ExploreNotifier extends Notifier<ExploreState> {
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<void> _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<String, dynamic>;
final sectionsData = data['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.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<void> _saveToCache(List<ExploreSection> 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<void> 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<ExploreState> {
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<ExploreState> {
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(
+1 -1
View File
@@ -495,7 +495,7 @@ class _HomeTabState extends ConsumerState<HomeTab> 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);
+1 -1
View File
@@ -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