mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2043370b6c | |||
| 39ddb7a14f |
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.1] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **Background Download Service**: Downloads now continue running when app is in background
|
||||
- Foreground service with wake lock prevents Android from killing downloads
|
||||
- Persistent notification shows download progress
|
||||
- No more "connection abort" errors when switching apps
|
||||
|
||||
### 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
|
||||
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||
|
||||
### Changed
|
||||
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||
|
||||
## [1.6.0] - 2026-01-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -39,7 +39,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **📱 iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
@@ -23,8 +24,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
|
||||
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground service to keep downloads running when app is in background.
|
||||
* This prevents Android from killing the download process or throttling network.
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
||||
private const val CHANNEL_ID = "download_channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||
|
||||
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||
|
||||
const val EXTRA_TRACK_NAME = "track_name"
|
||||
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||
const val EXTRA_PROGRESS = "progress"
|
||||
const val EXTRA_TOTAL = "total"
|
||||
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||
|
||||
private var isRunning = false
|
||||
|
||||
fun isServiceRunning(): Boolean = isRunning
|
||||
|
||||
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_UPDATE_PROGRESS
|
||||
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||
putExtra(EXTRA_PROGRESS, progress)
|
||||
putExtra(EXTRA_TOTAL, total)
|
||||
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var currentTrackName = ""
|
||||
private var currentArtistName = ""
|
||||
private var queueCount = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startForegroundService()
|
||||
ACTION_STOP -> stopSelf()
|
||||
ACTION_START -> {
|
||||
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||
startForegroundService()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForegroundService()
|
||||
}
|
||||
ACTION_UPDATE_PROGRESS -> {
|
||||
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
updateNotification(progress, total)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Download Progress",
|
||||
"Download Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows download progress for SpotiFLAC"
|
||||
description = "Shows download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun startForegroundService() {
|
||||
val notification = createNotification("Downloading...", 0)
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun updateProgress(trackName: String, progress: Int) {
|
||||
val notification = createNotification(trackName, progress)
|
||||
|
||||
private fun stopForegroundService() {
|
||||
isRunning = false
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification(progress: Long, total: Long) {
|
||||
if (!isRunning) return
|
||||
|
||||
val notification = buildNotification(progress, total)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, progress: Int): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
|
||||
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
|
||||
val title = if (queueCount > 1) {
|
||||
"Downloading $queueCount tracks"
|
||||
} else if (currentTrackName.isNotEmpty()) {
|
||||
currentTrackName
|
||||
} else {
|
||||
"Downloading..."
|
||||
}
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this, 0, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("SpotiFLAC")
|
||||
.setContentText(title)
|
||||
|
||||
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||
currentArtistName
|
||||
} else if (total > 0) {
|
||||
val progressPercent = (progress * 100 / total).toInt()
|
||||
val progressMB = progress / (1024.0 * 1024.0)
|
||||
val totalMB = total / (1024.0 * 1024.0)
|
||||
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||
} else {
|
||||
"Preparing download..."
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
||||
.build()
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
|
||||
if (total > 0) {
|
||||
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||
} else {
|
||||
builder.setProgress(0, 0, true)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
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) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
@@ -160,6 +167,29 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"startDownloadService" -> {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
|
||||
result.success(null)
|
||||
}
|
||||
"stopDownloadService" -> {
|
||||
DownloadService.stop(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"updateDownloadServiceProgress" -> {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val progress = call.argument<Long>("progress") ?: 0L
|
||||
val total = call.argument<Long>("total") ?: 0L
|
||||
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||
result.success(null)
|
||||
}
|
||||
"isDownloadServiceRunning" -> {
|
||||
result.success(DownloadService.isServiceRunning())
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -20,7 +20,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -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 = '1.6.0';
|
||||
static const String buildNumber = '25';
|
||||
static const String version = '1.6.1';
|
||||
static const String buildNumber = '26';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
|
||||
@@ -242,18 +242,90 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
// Initialize output directory asynchronously
|
||||
// Initialize output directory and load persisted queue asynchronously
|
||||
Future.microtask(() async {
|
||||
await _initOutputDir();
|
||||
await _loadQueueFromStorage();
|
||||
});
|
||||
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) {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
@@ -276,6 +348,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
);
|
||||
|
||||
// Update foreground service notification (Android)
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Log progress
|
||||
@@ -331,6 +414,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
);
|
||||
|
||||
// Update foreground service notification (Android)
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: downloadingItems.first.track.name,
|
||||
artistName: downloadingItems.first.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -461,6 +555,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
state = state.copyWith(items: [...state.items, item]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
@@ -487,6 +582,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(items: [...state.items, ...newItems]);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
if (!state.isProcessing) {
|
||||
// Run in microtask to not block UI
|
||||
@@ -508,6 +604,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}).toList();
|
||||
|
||||
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) {
|
||||
@@ -526,10 +629,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
).toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = state.copyWith(items: [], isPaused: false);
|
||||
_saveQueueToStorage(); // Clear persisted queue
|
||||
}
|
||||
|
||||
/// Pause the download queue
|
||||
@@ -571,6 +676,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return item;
|
||||
}).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
// Start processing if not already
|
||||
if (!state.isProcessing) {
|
||||
@@ -582,6 +688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
void removeItem(String id) {
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
@@ -657,6 +764,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Track total items at start for notification
|
||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
||||
|
||||
// Start foreground service to keep downloads running in background (Android only)
|
||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||
final firstItem = state.items.firstWhere(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
orElse: () => state.items.first,
|
||||
);
|
||||
try {
|
||||
await PlatformBridge.startDownloadService(
|
||||
trackName: firstItem.track.name,
|
||||
artistName: firstItem.track.artistName,
|
||||
queueCount: _totalQueuedAtStart,
|
||||
);
|
||||
_log.d('Foreground service started');
|
||||
} catch (e) {
|
||||
_log.e('Failed to start foreground service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure output directory is initialized before processing
|
||||
if (state.outputDir.isEmpty) {
|
||||
_log.d('Output dir empty, initializing...');
|
||||
@@ -686,6 +811,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
// Stop foreground service (Android only)
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await PlatformBridge.stopDownloadService();
|
||||
_log.d('Foreground service stopped');
|
||||
} catch (e) {
|
||||
_log.e('Failed to stop foreground service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleanup after queue finishes
|
||||
if (_downloadCount > 0) {
|
||||
_log.d('Final connection cleanup...');
|
||||
|
||||
@@ -150,8 +150,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If on Search tab and has text in search bar or has content, clear it
|
||||
if (_currentIndex == 0 && (trackState.hasSearchText || trackState.hasContent || trackState.isLoading)) {
|
||||
// If on Search tab and has text in search bar or has content (but not loading), clear it
|
||||
// Don't clear while loading - this prevents clearing during share intent processing
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
@@ -162,6 +163,11 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If loading, ignore back press
|
||||
if (trackState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already at root, show exit dialog
|
||||
final shouldPop = await _showExitDialog();
|
||||
if (shouldPop && context.mounted) {
|
||||
|
||||
@@ -234,4 +234,45 @@ class PlatformBridge {
|
||||
static Future<void> cleanupConnections() async {
|
||||
await _channel.invokeMethod('cleanupConnections');
|
||||
}
|
||||
|
||||
/// Start foreground download service to keep downloads running in background
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
int queueCount = 0,
|
||||
}) async {
|
||||
await _channel.invokeMethod('startDownloadService', {
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'queue_count': queueCount,
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop foreground download service
|
||||
static Future<void> stopDownloadService() async {
|
||||
await _channel.invokeMethod('stopDownloadService');
|
||||
}
|
||||
|
||||
/// Update download service notification progress
|
||||
static Future<void> updateDownloadServiceProgress({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required int progress,
|
||||
required int total,
|
||||
required int queueCount,
|
||||
}) async {
|
||||
await _channel.invokeMethod('updateDownloadServiceProgress', {
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'progress': progress,
|
||||
'total': total,
|
||||
'queue_count': queueCount,
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if download service is running
|
||||
static Future<bool> isDownloadServiceRunning() async {
|
||||
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||
return result as bool;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 1.6.0+25
|
||||
version: 1.6.1+26
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user