From 50f21d9c82dad478bedbad92ea3f5e9cc619287d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:00:42 +0700 Subject: [PATCH] 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. --- lib/l10n/arb/app_en.arb | 17 +++ lib/l10n/arb/app_id.arb | 17 +++ lib/providers/local_library_provider.dart | 118 ++++++++++++++++-- .../settings/library_settings_page.dart | 84 +++++++++++-- lib/services/notification_service.dart | 19 +++ lib/services/platform_bridge.dart | 9 ++ test/library_scan_pause_test.dart | 110 ++++++++++++++++ 7 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 test/library_scan_pause_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 410ccf8f..27fa040b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 42d874c4..5d84bc69 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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", diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index fbfb63e4..faa3a191 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -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 { bool _hasLoadedFromDatabase = false; Future? _loadFuture; bool _scanCancelRequested = false; + bool _scanPauseRequested = false; bool _scanInProgress = false; StreamSubscription? _storageEventsSubscription; Timer? _storageEventDebounce; @@ -539,6 +544,7 @@ class LocalLibraryNotifier extends Notifier { 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 { _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 { state = state.copyWith( isScanning: true, scanIsFinalizing: false, + scanIsPaused: false, scanProgress: 0, scanCurrentFile: null, scanTotalFiles: 0, @@ -821,6 +832,7 @@ class LocalLibraryNotifier extends Notifier { return; } + await _endPauseForFinalization(); state = state.copyWith( scanIsFinalizing: true, scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, @@ -955,6 +967,10 @@ class LocalLibraryNotifier extends Notifier { _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 { } 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 { ); } - 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 { _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 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 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 _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 _endPauseForFinalization() async { + if (!_scanPauseRequested) return; + _scanPauseRequested = false; + state = state.copyWith(scanIsPaused: false); + await _notificationService.beginLibraryScanWork(); + await _cancelScanPausedNotification(); + await _releaseNativeScanPause(); + } + + Future _showScanPausedNotification(int scannedFiles) async { + try { + await _notificationService.showLibraryScanPaused( + scannedFiles: scannedFiles, + ); + } catch (e) { + _log.w('Failed to show scan paused notification: $e'); + } + } + + Future _cancelScanPausedNotification() async { + try { + await _notificationService.cancelLibraryScanNotification(); + } catch (e) { + _log.w('Failed to clear scan paused notification: $e'); + } + } + Future _showScanProgressNotification({ required double progress, required int scannedFiles, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 817be414..a17e119e 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -204,6 +204,14 @@ class _LibrarySettingsPageState extends ConsumerState { await ref.read(localLibraryProvider.notifier).cancelScan(); } + Future _pauseScan() async { + await ref.read(localLibraryProvider.notifier).pauseScan(); + } + + Future _resumeScan() async { + await ref.read(localLibraryProvider.notifier).resumeScan(); + } + Future _clearLibrary() async { final confirmed = await showAppDialog( context: context, @@ -557,6 +565,7 @@ class _LibrarySettingsPageState extends ConsumerState { 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 { 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 { 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 { 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), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index c3c69bf3..b13bb6ba 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -539,6 +539,25 @@ class NotificationService { ); } + Future 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 cancelLibraryScanNotification() async { + await _notifications.cancel(id: libraryScanId); + } + Future showUpdateDownloadProgress({ required String version, required int received, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 732b3f34..cd03648f 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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 pauseLibraryScan() async { + await _channel.invokeMethod('pauseLibraryScan'); + } + + static Future resumeLibraryScan() async { + await _channel.invokeMethod('resumeLibraryScan'); + } + static Object? _decodeJsonResult(dynamic result) { if (result is String) { if (result.isEmpty) return null; diff --git a/test/library_scan_pause_test.dart b/test/library_scan_pause_test.dart new file mode 100644 index 00000000..3c74d70f --- /dev/null +++ b/test/library_scan_pause_test.dart @@ -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 calls; + + setUp(() { + calls = []; + 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']); + }); +}