mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-20 18:07:44 +02:00
fix: persist download queue to survive app restart (v1.6.1)
- Download queue now persisted to SharedPreferences - Auto-restore pending downloads on app restart - Interrupted downloads reset to queued and auto-resumed - singleTask launch mode to prevent app restart on share intent - onNewIntent handler for proper intent handling - Reverted share_plus to 10.1.4 (12.0.1 has Kotlin build issues)
This commit is contained in:
+9
-1
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
||||||
|
- Download queue is now persisted to storage and automatically restored on app restart
|
||||||
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
|
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
|
||||||
|
- Added `onNewIntent` handler to properly receive new share intents
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-02
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -33,7 +42,6 @@
|
|||||||
### Improved
|
### Improved
|
||||||
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
- **Dependencies Updated**:
|
- **Dependencies Updated**:
|
||||||
- `share_plus`: 10.1.4 → 12.0.1
|
|
||||||
- `flutter_local_notifications`: 18.0.1 → 19.0.0
|
- `flutter_local_notifications`: 18.0.1 → 19.0.0
|
||||||
- `build_runner`: 2.4.15 → 2.10.4
|
- `build_runner`: 2.4.15 → 2.10.4
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '1.6.0';
|
static const String version = '1.6.1';
|
||||||
static const String buildNumber = '25';
|
static const String buildNumber = '26';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC';
|
static const String appName = 'SpotiFLAC';
|
||||||
|
|||||||
@@ -242,18 +242,90 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
int _downloadCount = 0; // Counter for connection cleanup
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||||
|
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
// Initialize output directory asynchronously
|
// Initialize output directory and load persisted queue asynchronously
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
|
await _loadQueueFromStorage();
|
||||||
});
|
});
|
||||||
return const DownloadQueueState();
|
return const DownloadQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load persisted queue from storage (for app restart recovery)
|
||||||
|
Future<void> _loadQueueFromStorage() async {
|
||||||
|
if (_isLoaded) return;
|
||||||
|
_isLoaded = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonStr = prefs.getString(_queueStorageKey);
|
||||||
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
|
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Reset downloading items to queued (they were interrupted)
|
||||||
|
final restoredItems = items.map((item) {
|
||||||
|
if (item.status == DownloadStatus.downloading) {
|
||||||
|
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Only restore queued/downloading items (not completed/failed/skipped)
|
||||||
|
final pendingItems = restoredItems.where((item) =>
|
||||||
|
item.status == DownloadStatus.queued
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
if (pendingItems.isNotEmpty) {
|
||||||
|
state = state.copyWith(items: pendingItems);
|
||||||
|
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||||
|
|
||||||
|
// Auto-resume queue processing
|
||||||
|
Future.microtask(() => _processQueue());
|
||||||
|
} else {
|
||||||
|
_log.d('No pending items to restore');
|
||||||
|
// Clear storage since nothing to restore
|
||||||
|
await prefs.remove(_queueStorageKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.d('No queue found in storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to load queue from storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save current queue to storage (only pending items)
|
||||||
|
Future<void> _saveQueueToStorage() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Only persist queued and downloading items
|
||||||
|
final pendingItems = state.items.where((item) =>
|
||||||
|
item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
if (pendingItems.isEmpty) {
|
||||||
|
// Clear storage if no pending items
|
||||||
|
await prefs.remove(_queueStorageKey);
|
||||||
|
_log.d('Cleared queue storage (no pending items)');
|
||||||
|
} else {
|
||||||
|
final jsonList = pendingItems.map((e) => e.toJson()).toList();
|
||||||
|
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
|
||||||
|
_log.d('Saved ${pendingItems.length} pending items to storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to save queue to storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startProgressPolling(String itemId) {
|
void _startProgressPolling(String itemId) {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
@@ -461,6 +533,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, item]);
|
state = state.copyWith(items: [...state.items, item]);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
// Run in microtask to not block UI
|
||||||
@@ -487,6 +560,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, ...newItems]);
|
state = state.copyWith(items: [...state.items, ...newItems]);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
// Run in microtask to not block UI
|
||||||
@@ -508,6 +582,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
|
||||||
|
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
|
||||||
|
if (status == DownloadStatus.completed ||
|
||||||
|
status == DownloadStatus.failed ||
|
||||||
|
status == DownloadStatus.skipped) {
|
||||||
|
_saveQueueToStorage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress) {
|
void updateProgress(String id, double progress) {
|
||||||
@@ -526,10 +607,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
state = state.copyWith(items: [], isPaused: false);
|
state = state.copyWith(items: [], isPaused: false);
|
||||||
|
_saveQueueToStorage(); // Clear persisted queue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause the download queue
|
/// Pause the download queue
|
||||||
@@ -571,6 +654,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return item;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
// Start processing if not already
|
// Start processing if not already
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
@@ -582,6 +666,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
void removeItem(String id) {
|
void removeItem(String id) {
|
||||||
final items = state.items.where((item) => item.id != id).toList();
|
final items = state.items.where((item) => item.id != id).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||||
|
|||||||
@@ -938,11 +938,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await SharePlus.instance.share(
|
await Share.shareXFiles(
|
||||||
ShareParams(
|
[XFile(item.filePath)],
|
||||||
files: [XFile(item.filePath)],
|
text: '${item.trackName} - ${item.artistName}',
|
||||||
text: '${item.trackName} - ${item.artistName}',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -876,18 +876,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.1"
|
version: "10.1.4"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "5.0.2"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.6.0+25
|
version: 1.6.1+26
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -46,7 +46,7 @@ dependencies:
|
|||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^10.1.4
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user