feat(library): pause and resume library scans

The scan progress tile offers Pause and Resume. A paused scan releases its foreground service and wake lock, shows a paused notice, and resumes from the next unscanned file.
This commit is contained in:
zarzet committed 2026-10-01 21:00:42 +07:00
1 parent fe36f08918
commit 50f21d9c82
7 files changed
+356 -18

No files matched your search

+17
View File
@@ -2846,6 +2846,10 @@
"@libraryScanFinalizing": {
"description": "Status shown after file scanning finishes but library persistence is still running"
},
"libraryScanPaused": "Scan paused",
"@libraryScanPaused": {
"description": "Status shown while a library scan is paused by the user"
},
"libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": {
"description": "Scan progress display",
@@ -5396,6 +5400,19 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifLibraryScanPaused": "Library scan paused",
"@notifLibraryScanPaused": {
"description": "Notification title when the user pauses a library scan"
},
"notifLibraryScanPausedBody": "{count} files scanned. Resume in Local Library settings to continue from here.",
"@notifLibraryScanPausedBody": {
"description": "Notification body for a paused library scan - files scanned so far",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
+17
View File
@@ -2803,6 +2803,10 @@
"@libraryScanFinalizing": {
"description": "Status shown after file scanning finishes but library persistence is still running"
},
"libraryScanPaused": "Pindai dijeda",
"@libraryScanPaused": {
"description": "Status shown while a library scan is paused by the user"
},
"libraryScanProgress": "{progress}% dari {total} file",
"@libraryScanProgress": {
"description": "Scan progress display",
@@ -5302,6 +5306,19 @@
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifLibraryScanPaused": "Pindai koleksi dijeda",
"@notifLibraryScanPaused": {
"description": "Notification title when the user pauses a library scan"
},
"notifLibraryScanPausedBody": "{count} file sudah dipindai. Ketuk Lanjutkan di pengaturan Koleksi Lokal untuk meneruskan dari sini.",
"@notifLibraryScanPausedBody": {
"description": "Notification body for a paused library scan - files scanned so far",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifDownloadingUpdate": "Mendownload SpotiFLAC Mobile v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
+111 -7
View File
@@ -21,6 +21,7 @@ final _prefs = SharedPreferences.getInstance();
class LocalLibraryState {
final bool isScanning;
final bool scanIsFinalizing;
final bool scanIsPaused;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
@@ -39,6 +40,7 @@ class LocalLibraryState {
LocalLibraryState({
this.isScanning = false,
this.scanIsFinalizing = false,
this.scanIsPaused = false,
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
@@ -76,6 +78,7 @@ class LocalLibraryState {
LocalLibraryState copyWith({
bool? isScanning,
bool? scanIsFinalizing,
bool? scanIsPaused,
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
@@ -96,6 +99,7 @@ class LocalLibraryState {
return LocalLibraryState(
isScanning: isScanning ?? this.isScanning,
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
scanIsPaused: scanIsPaused ?? this.scanIsPaused,
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
@@ -144,6 +148,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
bool _hasLoadedFromDatabase = false;
Future<void>? _loadFuture;
bool _scanCancelRequested = false;
bool _scanPauseRequested = false;
bool _scanInProgress = false;
StreamSubscription<void>? _storageEventsSubscription;
Timer? _storageEventDebounce;
@@ -539,6 +544,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
var ingested = false;
try {
if (_scanCancelRequested) return null;
await _endPauseForFinalization();
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
@@ -613,6 +619,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_scanInProgress = true;
_scanCancelRequested = false;
_scanPauseRequested = false;
// A pause left behind by an interrupted Dart session would hold the new
// native scan forever; clear it before the pause control becomes visible.
await _releaseNativeScanPause();
try {
final prefs = await _prefs;
await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId);
@@ -625,6 +635,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(
isScanning: true,
scanIsFinalizing: false,
scanIsPaused: false,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
@@ -821,6 +832,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
await _endPauseForFinalization();
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
@@ -955,6 +967,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Stopped iOS security-scoped access');
}
_stopProgressPolling();
if (_scanPauseRequested) {
_scanPauseRequested = false;
await _releaseNativeScanPause();
}
_scanInProgress = false;
try {
final prefs = await _prefs;
@@ -965,7 +981,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} catch (e) {
_log.w('Failed to clear active library scan marker: $e');
}
state = state.copyWith(clearScanningSourceId: true);
state = state.copyWith(clearScanningSourceId: true, scanIsPaused: false);
}
}
@@ -1021,11 +1037,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
if (_shouldShowScanProgressNotification(
progress: normalizedProgress,
totalFiles: totalFiles,
isComplete: false,
)) {
// Files already in flight still finish after a pause; count them, but
// keep the paused notice instead of reposting progress.
if (!state.scanIsPaused &&
_shouldShowScanProgressNotification(
progress: normalizedProgress,
totalFiles: totalFiles,
isComplete: false,
)) {
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
@@ -1073,12 +1092,97 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Cancelling library scan');
_scanCancelRequested = true;
// Native cancel also releases a paused scan.
_scanPauseRequested = false;
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(scanIsFinalizing: false, scanWasCancelled: true);
state = state.copyWith(
scanIsFinalizing: false,
scanIsPaused: false,
scanWasCancelled: true,
);
_stopProgressPolling();
await _showScanCancelledNotification();
}
/// Holds the running scan in place. Files already processed stay in the
/// native scan's memory, so [resumeScan] continues with the next file.
Future<void> pauseScan() async {
if (!state.isScanning ||
state.scanIsFinalizing ||
state.scanIsPaused ||
_scanCancelRequested) {
return;
}
_log.i('Pausing library scan at ${state.scannedFiles} files');
_scanPauseRequested = true;
state = state.copyWith(scanIsPaused: true);
try {
await PlatformBridge.pauseLibraryScan();
} catch (e) {
_log.w('Failed to pause library scan: $e');
_scanPauseRequested = false;
state = state.copyWith(scanIsPaused: false);
return;
}
// A paused scan does no work; release the foreground service and wake
// lock (iOS background task) until the user resumes.
await _notificationService.endBackgroundWork(
NotificationService.libraryScanWorkKind,
);
await _showScanPausedNotification(state.scannedFiles);
}
Future<void> resumeScan() async {
if (!state.isScanning || !state.scanIsPaused || !_scanPauseRequested) {
return;
}
_log.i('Resuming library scan from ${state.scannedFiles} files');
// Reacquire background work first so resumed reads survive screen-off.
await _notificationService.beginLibraryScanWork();
await _cancelScanPausedNotification();
_scanPauseRequested = false;
_resetScanNotificationTracking();
state = state.copyWith(scanIsPaused: false);
await _releaseNativeScanPause();
}
Future<void> _releaseNativeScanPause() async {
try {
await PlatformBridge.resumeLibraryScan();
} catch (e) {
_log.w('Failed to release library scan pause: $e');
}
}
/// The native scan can finish while paused when the pause lands after its
/// last file. Ingestion then runs normally, so restore background work.
Future<void> _endPauseForFinalization() async {
if (!_scanPauseRequested) return;
_scanPauseRequested = false;
state = state.copyWith(scanIsPaused: false);
await _notificationService.beginLibraryScanWork();
await _cancelScanPausedNotification();
await _releaseNativeScanPause();
}
Future<void> _showScanPausedNotification(int scannedFiles) async {
try {
await _notificationService.showLibraryScanPaused(
scannedFiles: scannedFiles,
);
} catch (e) {
_log.w('Failed to show scan paused notification: $e');
}
}
Future<void> _cancelScanPausedNotification() async {
try {
await _notificationService.cancelLibraryScanNotification();
} catch (e) {
_log.w('Failed to clear scan paused notification: $e');
}
}
Future<void> _showScanProgressNotification({
required double progress,
required int scannedFiles,
+73 -11
View File
@@ -204,6 +204,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
await ref.read(localLibraryProvider.notifier).cancelScan();
}
Future<void> _pauseScan() async {
await ref.read(localLibraryProvider.notifier).pauseScan();
}
Future<void> _resumeScan() async {
await ref.read(localLibraryProvider.notifier).resumeScan();
}
Future<void> _clearLibrary() async {
final confirmed = await showAppDialog<bool>(
context: context,
@@ -557,6 +565,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing,
scanIsPaused: libraryState.scanIsPaused,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
@@ -627,6 +636,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return (
active: active,
finalizing: active && state.scanIsFinalizing,
paused: active && state.scanIsPaused,
scanned: active ? state.scannedFiles : 0,
total: active ? state.scanTotalFiles : 0,
progress: active ? state.scanProgress : 0.0,
@@ -637,6 +647,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
source: source,
isScanning: scan.active,
isFinalizing: scan.finalizing,
isPaused: scan.paused,
scannedFiles: scan.scanned,
totalFiles: scan.total,
progress: scan.progress,
@@ -774,10 +785,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
if (libraryState.isScanning)
_ScanProgressTile(
isFinalizing: libraryState.scanIsFinalizing,
isPaused: libraryState.scanIsPaused,
progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile,
scannedFiles: libraryState.scannedFiles,
totalFiles: libraryState.scanTotalFiles,
onPause: _pauseScan,
onResume: _resumeScan,
onCancel: _cancelScan,
)
else ...[
@@ -858,6 +872,7 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
final LocalLibrarySource source;
final bool isScanning;
final bool isFinalizing;
final bool isPaused;
final int scannedFiles;
final int totalFiles;
final double progress;
@@ -871,6 +886,7 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
required this.source,
required this.isScanning,
required this.isFinalizing,
required this.isPaused,
required this.scannedFiles,
required this.totalFiles,
required this.progress,
@@ -960,6 +976,8 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
: isScanning
? isFinalizing
? context.l10n.libraryScanFinalizing
: isPaused
? context.l10n.libraryScanPaused
: totalFiles > 0
? context.l10n.librarySourceScanCount(
scannedFiles,
@@ -1066,6 +1084,7 @@ class _LibraryHeroCard extends StatelessWidget {
final int excludedDownloadedCount;
final bool isScanning;
final bool scanIsFinalizing;
final bool scanIsPaused;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
@@ -1077,6 +1096,7 @@ class _LibraryHeroCard extends StatelessWidget {
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanIsFinalizing,
required this.scanIsPaused,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
@@ -1103,6 +1123,7 @@ class _LibraryHeroCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark;
final showIndeterminateProgress =
isScanning &&
!scanIsPaused &&
(scanIsFinalizing ||
scanTotalFiles <= 0 ||
(scannedFiles <= 0 && scanProgress <= 0));
@@ -1168,7 +1189,11 @@ class _LibraryHeroCard extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
child: Icon(
isScanning ? Icons.sync : Icons.music_note,
isScanning
? scanIsPaused
? Icons.pause_rounded
: Icons.sync
: Icons.music_note,
color: colorScheme.onPrimaryContainer,
size: 32,
),
@@ -1190,14 +1215,22 @@ class _LibraryHeroCard extends StatelessWidget {
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
child: scanIsPaused
? Icon(
Icons.pause_rounded,
size: 12,
color: colorScheme.onPrimary,
)
: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
Text(
context.l10n.libraryScanning,
scanIsPaused
? context.l10n.libraryScanPaused
: context.l10n.libraryScanning,
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: 12,
@@ -1256,7 +1289,9 @@ class _LibraryHeroCard extends StatelessWidget {
LinearProgressIndicator(
value: showIndeterminateProgress
? null
: scanProgress / 100,
: scanTotalFiles > 0
? scanProgress / 100
: 0,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
@@ -1270,6 +1305,8 @@ class _LibraryHeroCard extends StatelessWidget {
scanProgress.toStringAsFixed(0),
scanTotalFiles,
)
: scanIsPaused
? context.l10n.libraryScanPaused
: context.l10n.libraryScanning,
style: TextStyle(
fontSize: 12,
@@ -1331,26 +1368,37 @@ class _LibraryHeroCard extends StatelessWidget {
class _ScanProgressTile extends StatelessWidget {
final bool isFinalizing;
final bool isPaused;
final double progress;
final String? currentFile;
final int scannedFiles;
final int totalFiles;
final VoidCallback onPause;
final VoidCallback onResume;
final VoidCallback onCancel;
const _ScanProgressTile({
required this.isFinalizing,
required this.isPaused,
required this.progress,
this.currentFile,
required this.scannedFiles,
required this.totalFiles,
required this.onPause,
required this.onResume,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// A paused bar stays still at its last position instead of animating.
final showIndeterminateProgress =
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
!isPaused &&
(isFinalizing ||
totalFiles <= 0 ||
(scannedFiles <= 0 && progress <= 0));
final progressValue = totalFiles > 0 ? progress / 100 : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -1359,14 +1407,19 @@ class _ScanProgressTile extends StatelessWidget {
children: [
Row(
children: [
Icon(Icons.scanner, color: colorScheme.primary),
Icon(
isPaused ? Icons.pause_circle_outline : Icons.scanner,
color: colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryScanning,
isPaused
? context.l10n.libraryScanPaused
: context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -1387,6 +1440,15 @@ class _ScanProgressTile extends StatelessWidget {
],
),
),
if (!isFinalizing)
TextButton(
onPressed: isPaused ? onResume : onPause,
child: Text(
isPaused
? context.l10n.actionResume
: context.l10n.actionPause,
),
),
TextButton(
onPressed: onCancel,
child: Text(context.l10n.actionCancel),
@@ -1395,7 +1457,7 @@ class _ScanProgressTile extends StatelessWidget {
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: showIndeterminateProgress ? null : progress / 100,
value: showIndeterminateProgress ? null : progressValue,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
+19
View File
@@ -539,6 +539,25 @@ class NotificationService {
);
}
Future<void> showLibraryScanPaused({required int scannedFiles}) async {
if (!_isInitialized) await initialize();
await _showSafely(
id: libraryScanId,
title: _l10n?.notifLibraryScanPaused ?? 'Library scan paused',
body:
_l10n?.notifLibraryScanPausedBody(scannedFiles) ??
'$scannedFiles files scanned. Resume in Local Library settings to '
'continue from here.',
details: _details(library: true),
);
}
/// Clears a paused-scan notice once the foreground service shows progress.
Future<void> cancelLibraryScanNotification() async {
await _notifications.cancel(id: libraryScanId);
}
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
+9
View File
@@ -2346,6 +2346,15 @@ class PlatformBridge {
await _channel.invokeMethod('cancelLibraryScan');
}
/// Holds the running native scan at its next file; it keeps its position.
static Future<void> pauseLibraryScan() async {
await _channel.invokeMethod('pauseLibraryScan');
}
static Future<void> resumeLibraryScan() async {
await _channel.invokeMethod('resumeLibraryScan');
}
static Object? _decodeJsonResult(dynamic result) {
if (result is String) {
if (result.isEmpty) return null;
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
class _ScanningLibrary extends LocalLibraryNotifier {
_ScanningLibrary({this.finalizing = false});
final bool finalizing;
@override
LocalLibraryState build() => LocalLibraryState(
isScanning: true,
scanIsFinalizing: finalizing,
scannedFiles: 100,
scanTotalFiles: 400,
scanProgress: 25,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backend = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late List<String> calls;
setUp(() {
calls = <String>[];
messenger.setMockMethodCallHandler(backend, (call) async {
calls.add(call.method);
return null;
});
});
tearDown(() => messenger.setMockMethodCallHandler(backend, null));
ProviderContainer containerFor(_ScanningLibrary library) {
final container = ProviderContainer(
overrides: [localLibraryProvider.overrideWith(() => library)],
);
addTearDown(container.dispose);
return container;
}
test('pause holds the scan in place and resume continues it', () async {
final container = containerFor(_ScanningLibrary());
final notifier = container.read(localLibraryProvider.notifier);
await notifier.pauseScan();
var state = container.read(localLibraryProvider);
expect(state.isScanning, isTrue);
expect(state.scanIsPaused, isTrue);
expect(state.scannedFiles, 100);
// Repeated pauses do not reach native code again.
await notifier.pauseScan();
expect(calls, ['pauseLibraryScan']);
await notifier.resumeScan();
state = container.read(localLibraryProvider);
expect(state.isScanning, isTrue);
expect(state.scanIsPaused, isFalse);
expect(state.scannedFiles, 100);
expect(calls, ['pauseLibraryScan', 'resumeLibraryScan']);
await notifier.resumeScan();
expect(calls, ['pauseLibraryScan', 'resumeLibraryScan']);
});
test('cancel releases a paused scan without resuming it first', () async {
final container = containerFor(_ScanningLibrary());
final notifier = container.read(localLibraryProvider.notifier);
await notifier.pauseScan();
await notifier.cancelScan();
final state = container.read(localLibraryProvider);
expect(state.scanIsPaused, isFalse);
expect(state.scanWasCancelled, isTrue);
expect(calls, ['pauseLibraryScan', 'cancelLibraryScan']);
await notifier.resumeScan();
expect(calls, ['pauseLibraryScan', 'cancelLibraryScan']);
});
test('finalizing scans cannot be paused', () async {
final container = containerFor(_ScanningLibrary(finalizing: true));
final notifier = container.read(localLibraryProvider.notifier);
await notifier.pauseScan();
expect(container.read(localLibraryProvider).scanIsPaused, isFalse);
expect(calls, isEmpty);
});
test('a failed native pause leaves the scan running', () async {
messenger.setMockMethodCallHandler(backend, (call) async {
calls.add(call.method);
throw PlatformException(code: 'pause-unavailable');
});
final container = containerFor(_ScanningLibrary());
final notifier = container.read(localLibraryProvider.notifier);
await notifier.pauseScan();
expect(container.read(localLibraryProvider).scanIsPaused, isFalse);
await notifier.resumeScan();
expect(calls, ['pauseLibraryScan']);
});
}