mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
refactor(queue): extract shared payload, history, and polling helpers
- _buildDownloadRequestPayload/_historyItemFromResult shared by the inline and native-worker paths; _buildOutputDir now derives from _buildRelativeOutputDir - shouldAbortWork extended (deleteFileOnAbort, null-item check) and used at all ten guard sites; _purgeAlbumRgEntry replaces six copies - ProgressStreamPoller shared by download queue and local library; openVerificationAndAwaitGrant shared by queue and track provider
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
@@ -117,17 +118,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
static const _progressPollingInterval = Duration(milliseconds: 350);
|
||||
static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
late final ProgressStreamPoller<Map<String, dynamic>> _progressPoller =
|
||||
ProgressStreamPoller<Map<String, dynamic>>(
|
||||
streamProvider: PlatformBridge.libraryScanProgressStream,
|
||||
pollProvider: PlatformBridge.getLibraryScanProgress,
|
||||
onProgress: _handleLibraryScanProgress,
|
||||
pollingInterval: _progressPollingInterval,
|
||||
bootstrapTimeout: _progressStreamBootstrapTimeout,
|
||||
onStreamProcessingError: (e) =>
|
||||
_log.w('Library scan progress stream processing failed: $e'),
|
||||
onStreamFailed: (error) => _log.w(
|
||||
'Library scan progress stream failed, fallback to polling: $error',
|
||||
),
|
||||
onStreamTimeout: () =>
|
||||
_log.w('Library scan progress stream timeout, fallback to polling'),
|
||||
onPollError: (e) => _log.w('Library scan progress polling failed: $e'),
|
||||
);
|
||||
bool _isLoaded = false;
|
||||
bool _hasLoadedFromDatabase = false;
|
||||
Future<void>? _loadFuture;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
bool _hasReceivedProgressStreamEvent = false;
|
||||
bool _usingProgressStream = false;
|
||||
static const _scanNotificationHeartbeat = Duration(seconds: 4);
|
||||
int _lastScanNotificationPercent = -1;
|
||||
int _lastScanNotificationTotalFiles = -1;
|
||||
@@ -135,11 +145,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
});
|
||||
ref.onDispose(_progressPoller.stop);
|
||||
|
||||
Future.microtask(_ensureLoadedFromDatabase);
|
||||
return LocalLibraryState();
|
||||
@@ -584,106 +590,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
_progressStreamSub = PlatformBridge.libraryScanProgressStream().listen(
|
||||
(progress) async {
|
||||
_hasReceivedProgressStreamEvent = true;
|
||||
_usingProgressStream = true;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress stream processing failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (_usingProgressStream) {
|
||||
_log.w(
|
||||
'Library scan progress stream failed, fallback to polling: $error',
|
||||
);
|
||||
}
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_startProgressPollingTimer();
|
||||
},
|
||||
cancelOnError: false,
|
||||
final useStream = Platform.isAndroid || Platform.isIOS;
|
||||
_progressPoller.start(useStream: useStream);
|
||||
if (useStream) {
|
||||
Future<void>.microtask(
|
||||
() => _progressPoller.pollOnce(
|
||||
(e) => _log.w('Initial library scan progress fetch failed: $e'),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void>.microtask(_requestProgressSnapshot);
|
||||
|
||||
_progressStreamBootstrapTimer = Timer(
|
||||
_progressStreamBootstrapTimeout,
|
||||
() {
|
||||
if (_hasReceivedProgressStreamEvent) {
|
||||
return;
|
||||
}
|
||||
_log.w('Library scan progress stream timeout, fallback to polling');
|
||||
_progressStreamSub?.cancel();
|
||||
_progressStreamSub = null;
|
||||
_usingProgressStream = false;
|
||||
_startProgressPollingTimer();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_startProgressPollingTimer();
|
||||
}
|
||||
|
||||
void _startProgressPollingTimer() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestProgressSnapshot() async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
await _handleLibraryScanProgress(progress);
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Initial library scan progress fetch failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,16 +654,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressStreamBootstrapTimer?.cancel();
|
||||
_progressStreamSub?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressStreamBootstrapTimer = null;
|
||||
_progressStreamSub = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_hasReceivedProgressStreamEvent = false;
|
||||
_usingProgressStream = false;
|
||||
_progressPoller.stop();
|
||||
_resetScanNotificationTracking();
|
||||
}
|
||||
|
||||
|
||||
@@ -643,7 +643,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchExtensionId: extensionId,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
final verified = await _openVerificationAndWait(extensionId);
|
||||
final verified = await openVerificationAndAwaitGrant(
|
||||
extensionId,
|
||||
browserMode: ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
if (verified) {
|
||||
_log.i(
|
||||
@@ -669,55 +674,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _openVerificationAndWait(String extensionId) async {
|
||||
final normalizedExtensionId = extensionId.trim();
|
||||
if (normalizedExtensionId.isEmpty) return false;
|
||||
|
||||
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
|
||||
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
|
||||
grantSub = PlatformBridge.extensionSessionGrantEvents()
|
||||
.where((event) => event.extensionId.trim() == normalizedExtensionId)
|
||||
.listen((event) {
|
||||
if (!grantCompleter.isCompleted) {
|
||||
grantCompleter.complete(event);
|
||||
}
|
||||
});
|
||||
|
||||
final browserMode = ref
|
||||
.read(settingsProvider)
|
||||
.extensionVerificationBrowserMode;
|
||||
Uri? authUri;
|
||||
Timer? helpDialogTimer;
|
||||
|
||||
try {
|
||||
final opened = await openPendingExtensionVerification(
|
||||
normalizedExtensionId,
|
||||
browserMode: browserMode,
|
||||
onAuthUri: (uri) => authUri = uri,
|
||||
);
|
||||
if (!opened) return false;
|
||||
|
||||
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
|
||||
normalizedExtensionId,
|
||||
authUri,
|
||||
browserMode: browserMode,
|
||||
);
|
||||
|
||||
final event = await grantCompleter.future.timeout(
|
||||
const Duration(minutes: 5),
|
||||
);
|
||||
return event.success;
|
||||
} on TimeoutException {
|
||||
_log.w(
|
||||
'Timed out waiting for verification grant: $normalizedExtensionId',
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
helpDialogTimer?.cancel();
|
||||
await grantSub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkAvailability(int index) async {
|
||||
if (index < 0 || index >= state.tracks.length) return;
|
||||
|
||||
|
||||
@@ -97,6 +97,65 @@ Timer? scheduleExtensionVerificationHelpDialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens a pending extension verification challenge and waits (up to 5
|
||||
/// minutes) for its grant result, returning whether it succeeded.
|
||||
///
|
||||
/// [awaitForeground], if given, is awaited first — passed the trimmed
|
||||
/// [extensionId] — before opening the challenge; used by callers that must
|
||||
/// defer verification until the app is foregrounded (launching a browser
|
||||
/// from the background is blocked by the OS).
|
||||
Future<bool> openVerificationAndAwaitGrant(
|
||||
String extensionId, {
|
||||
required String browserMode,
|
||||
Future<void> Function(String extensionId)? awaitForeground,
|
||||
}) async {
|
||||
final normalizedExtensionId = extensionId.trim();
|
||||
if (normalizedExtensionId.isEmpty) return false;
|
||||
|
||||
if (awaitForeground != null) {
|
||||
await awaitForeground(normalizedExtensionId);
|
||||
}
|
||||
|
||||
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
|
||||
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
|
||||
grantSub = PlatformBridge.extensionSessionGrantEvents()
|
||||
.where((event) => event.extensionId.trim() == normalizedExtensionId)
|
||||
.listen((event) {
|
||||
if (!grantCompleter.isCompleted) {
|
||||
grantCompleter.complete(event);
|
||||
}
|
||||
});
|
||||
|
||||
Uri? authUri;
|
||||
Timer? helpDialogTimer;
|
||||
|
||||
try {
|
||||
final opened = await openPendingExtensionVerification(
|
||||
normalizedExtensionId,
|
||||
browserMode: browserMode,
|
||||
onAuthUri: (uri) => authUri = uri,
|
||||
);
|
||||
if (!opened) return false;
|
||||
|
||||
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
|
||||
normalizedExtensionId,
|
||||
authUri,
|
||||
browserMode: browserMode,
|
||||
);
|
||||
|
||||
final event = await grantCompleter.future.timeout(
|
||||
const Duration(minutes: 5),
|
||||
);
|
||||
return event.success;
|
||||
} on TimeoutException {
|
||||
_log.w('Timed out waiting for verification grant: $normalizedExtensionId');
|
||||
return false;
|
||||
} finally {
|
||||
helpDialogTimer?.cancel();
|
||||
await grantSub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> showExtensionVerificationHelpDialog(
|
||||
String extensionId,
|
||||
Uri authUri, {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Shared scaffold for consuming a native progress stream (EventChannel-backed)
|
||||
/// with a bootstrap-timeout fallback to polling, an in-flight guard against
|
||||
/// re-entrant processing, and an error-count throttle that suppresses log
|
||||
/// spam after repeated consecutive failures.
|
||||
///
|
||||
/// Callers own all logging text (via the `on*Error`/`onStreamTimeout`/
|
||||
/// `onStreamFailed` callbacks) and platform gating (via [start]'s [useStream]
|
||||
/// argument), so behavior stays identical to each call site's prior bespoke
|
||||
/// implementation.
|
||||
class ProgressStreamPoller<T> {
|
||||
ProgressStreamPoller({
|
||||
required this.streamProvider,
|
||||
required this.pollProvider,
|
||||
required this.onProgress,
|
||||
required this.pollingInterval,
|
||||
required this.bootstrapTimeout,
|
||||
required this.onStreamProcessingError,
|
||||
required this.onStreamFailed,
|
||||
required this.onStreamTimeout,
|
||||
required this.onPollError,
|
||||
this.shouldPollTick,
|
||||
});
|
||||
|
||||
final Stream<T> Function() streamProvider;
|
||||
final Future<T> Function() pollProvider;
|
||||
final Future<void> Function(T progress) onProgress;
|
||||
final Duration pollingInterval;
|
||||
final Duration bootstrapTimeout;
|
||||
|
||||
/// Optional gate checked on every periodic poll tick (stream events are
|
||||
/// never gated), after the in-flight guard; returning false skips that
|
||||
/// tick without fetching or affecting the error counter. Used for
|
||||
/// idle-cadence throttling.
|
||||
final bool Function()? shouldPollTick;
|
||||
|
||||
/// Called when a stream event's [onProgress] throws, only while the error
|
||||
/// count is within the log-spam threshold.
|
||||
final void Function(Object error) onStreamProcessingError;
|
||||
|
||||
/// Called when the stream itself errors, before falling back to polling.
|
||||
final void Function(Object error) onStreamFailed;
|
||||
|
||||
/// Called when no stream event arrived before [bootstrapTimeout].
|
||||
final void Function() onStreamTimeout;
|
||||
|
||||
/// Called when a poll tick's [onProgress] throws, only while the error
|
||||
/// count is within the log-spam threshold.
|
||||
final void Function(Object error) onPollError;
|
||||
|
||||
static const _errorLogThreshold = 3;
|
||||
|
||||
Timer? _timer;
|
||||
Timer? _bootstrapTimer;
|
||||
StreamSubscription<T>? _sub;
|
||||
bool _hasReceivedStreamEvent = false;
|
||||
bool _usingStream = false;
|
||||
bool _inFlight = false;
|
||||
int _errorCount = 0;
|
||||
|
||||
bool get usingStream => _usingStream;
|
||||
|
||||
/// (Re)starts progress consumption. When [useStream] is true, attaches the
|
||||
/// stream (falling back to polling on timeout/error); otherwise starts
|
||||
/// polling immediately.
|
||||
void start({required bool useStream}) {
|
||||
_timer?.cancel();
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_hasReceivedStreamEvent = false;
|
||||
_usingStream = false;
|
||||
|
||||
if (useStream) {
|
||||
_attachStream();
|
||||
return;
|
||||
}
|
||||
startPollingTimer();
|
||||
}
|
||||
|
||||
void _attachStream() {
|
||||
_sub = streamProvider().listen(
|
||||
(progress) async {
|
||||
_hasReceivedStreamEvent = true;
|
||||
_usingStream = true;
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
await _runGuarded(() async => progress, onStreamProcessingError);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (_usingStream) {
|
||||
onStreamFailed(error);
|
||||
}
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_usingStream = false;
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
startPollingTimer();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_bootstrapTimer = Timer(bootstrapTimeout, () {
|
||||
if (_hasReceivedStreamEvent) return;
|
||||
onStreamTimeout();
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_usingStream = false;
|
||||
startPollingTimer();
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)starts the fallback polling timer.
|
||||
void startPollingTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(pollingInterval, (_) async {
|
||||
await _runGuarded(pollProvider, onPollError, gate: shouldPollTick);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot immediate fetch reusing the same in-flight guard and error
|
||||
/// counter as the periodic poll, with its own error callback.
|
||||
Future<void> pollOnce(void Function(Object error) onError) =>
|
||||
_runGuarded(pollProvider, onError);
|
||||
|
||||
Future<void> _runGuarded(
|
||||
Future<T> Function() fetch,
|
||||
void Function(Object error) onError, {
|
||||
bool Function()? gate,
|
||||
}) async {
|
||||
if (_inFlight) return;
|
||||
_inFlight = true;
|
||||
try {
|
||||
if (gate != null && !gate()) return;
|
||||
final progress = await fetch();
|
||||
await onProgress(progress);
|
||||
_errorCount = 0;
|
||||
} catch (e) {
|
||||
_errorCount++;
|
||||
if (_errorCount <= _errorLogThreshold) {
|
||||
onError(e);
|
||||
}
|
||||
} finally {
|
||||
_inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels timers/subscription and resets guard/counter state, without
|
||||
/// releasing the poller for reuse (matches each caller's `_stop*` reset).
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_bootstrapTimer?.cancel();
|
||||
_sub?.cancel();
|
||||
_timer = null;
|
||||
_bootstrapTimer = null;
|
||||
_sub = null;
|
||||
_errorCount = 0;
|
||||
_inFlight = false;
|
||||
_hasReceivedStreamEvent = false;
|
||||
_usingStream = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user