mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-17 22:04:47 +02:00
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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user