Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
251 lines
8.1 KiB
Dart
251 lines
8.1 KiB
Dart
part of '../../video_player_screen.dart';
|
|
|
|
extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
|
void _onVideoCompleted(bool completed, {bool skipAutoPlayCountdown = false}) async {
|
|
// Live TV streams are continuous — ignore transient EOF events while an
|
|
// HLS playlist refreshes or crosses a discontinuity.
|
|
if (widget.isLive) return;
|
|
if (!completed) return;
|
|
// Ignore spurious EOF from the old file during an in-place media-source
|
|
// transition (episode swap, transcode restart, channel switch).
|
|
if (_playbackTransition != _PlaybackTransition.idle) return;
|
|
if (_isResolvingCompletionAdjacency) return;
|
|
|
|
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
|
|
// never fires false. Normalize all playback-dependent state.
|
|
unawaited(_wakelockController.setEnabled(false));
|
|
final duration = player?.state.duration;
|
|
unawaited(
|
|
duration != null && duration.inMilliseconds > 0
|
|
? _sendStoppedProgressOnce(positionOverride: duration)
|
|
: _sendStoppedProgressOnce(),
|
|
);
|
|
_updateMediaControlsPlaybackState();
|
|
unawaited(DiscordRPCService.instance.pausePlayback());
|
|
// The item finished, so real-time trackers get a terminal report now rather
|
|
// than whenever the screen happens to tear down: a completion prompt or
|
|
// end-of-video sleep timer can leave it open for minutes, and until then
|
|
// the service would still show the item as playing. Seed the known duration
|
|
// first — the position stream can stop a beat short of it on EOF. A later
|
|
// dispose or in-place reload finds no context and does nothing.
|
|
if (duration != null && duration.inMilliseconds > 0) {
|
|
TrackerCoordinator.instance.updatePosition(duration);
|
|
}
|
|
unawaited(TrackerCoordinator.instance.stopPlayback());
|
|
if (_autoPipEnabled) {
|
|
unawaited(_updateAutoPipState(isPlaying: false));
|
|
}
|
|
|
|
// End-of-video sleep timer takes precedence over autoplay / next-episode
|
|
// dialogs: the user explicitly asked to stop after this item.
|
|
final sleepTimerService = SleepTimerService();
|
|
if (sleepTimerService.isEndOfVideoMode && !_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
sleepTimerService.notifyVideoCompleted();
|
|
return;
|
|
}
|
|
if (!_canNavigateMediaItems()) {
|
|
if (!_completionLatch.triggered) _completionLatch.latch();
|
|
return;
|
|
}
|
|
|
|
var navigationAction = completionNavigationAction(
|
|
hasNext: _nextEpisode != null,
|
|
adjacentStatus: _nextEpisodeStatus,
|
|
);
|
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
|
_isResolvingCompletionAdjacency = true;
|
|
try {
|
|
await _loadAdjacentEpisodes();
|
|
} finally {
|
|
_isResolvingCompletionAdjacency = false;
|
|
}
|
|
if (!mounted) return;
|
|
navigationAction = completionNavigationAction(hasNext: _nextEpisode != null, adjacentStatus: _nextEpisodeStatus);
|
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
|
_completionLatch.latch();
|
|
showGlobalErrorSnackBar(t.messages.errorLoadingSeries);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (navigationAction == CompletionNavigationAction.presentNext &&
|
|
!_showPlayNextDialog &&
|
|
!_showStillWatchingPrompt &&
|
|
!_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
|
|
// PiP: skip dialog (user can't interact), auto-play immediately
|
|
if (PipService().isPipActive.value) {
|
|
unawaited(_playNext());
|
|
return;
|
|
}
|
|
|
|
// Capture keyboard mode before async gap
|
|
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context);
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
if (!mounted) return;
|
|
final autoPlayEnabled = settings.read(SettingsService.autoPlayNextEpisode);
|
|
|
|
if (skipAutoPlayCountdown && autoPlayEnabled) {
|
|
unawaited(_playNext());
|
|
return;
|
|
}
|
|
|
|
_setPlayerState(() {
|
|
_showPlayNextDialog = true;
|
|
_autoPlayCountdown = autoPlayEnabled ? 5 : -1;
|
|
});
|
|
|
|
// Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode)
|
|
if (isKeyboardMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_playNextConfirmFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (autoPlayEnabled) {
|
|
_startAutoPlayTimer();
|
|
}
|
|
} else if (navigationAction == CompletionNavigationAction.exit && !_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
unawaited(_handleBackButton());
|
|
}
|
|
}
|
|
|
|
void _startAutoPlayTimer() {
|
|
if (!_canNavigateMediaItems()) return;
|
|
_autoPlayTimer?.cancel();
|
|
_autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
_setPlayerState(() {
|
|
_autoPlayCountdown--;
|
|
});
|
|
if (_autoPlayCountdown <= 0) {
|
|
timer.cancel();
|
|
_playNext();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _cancelAutoPlay() {
|
|
_autoPlayTimer?.cancel();
|
|
_unfocusPlayNextPrompt();
|
|
_progressTracker?.resumeAfterStoppedReport();
|
|
// Keep the latch set while playback is still parked at EOF, so duplicate
|
|
// completed signals cannot re-open this prompt. It is re-armed once playback
|
|
// seeks back clear of the end region (see the position listener) or new media loads.
|
|
_setPlayerState(() {
|
|
_showPlayNextDialog = false;
|
|
});
|
|
}
|
|
|
|
void _dismissPlaybackPromptForBack() {
|
|
if (_showPlayNextDialog) {
|
|
_cancelAutoPlay();
|
|
return;
|
|
}
|
|
if (_showStillWatchingPrompt) {
|
|
_dismissStillWatching();
|
|
}
|
|
}
|
|
|
|
/// Re-arm the end-of-video latch so Play Next can fire again. Callers
|
|
/// decide *when* it is safe to re-arm (media reloaded, or playback moved
|
|
/// back out of the end region); the latch itself refuses while a prompt
|
|
/// or countdown is active.
|
|
void _rearmCompletionLatch() {
|
|
_completionLatch.rearmIfClear(
|
|
promptVisible: _showPlayNextDialog,
|
|
countdownActive: _autoPlayTimer?.isActive == true,
|
|
);
|
|
}
|
|
|
|
void _showStillWatchingDialog() {
|
|
// Don't show if auto-play dialog is already visible
|
|
if (_showPlayNextDialog) return;
|
|
|
|
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context);
|
|
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = true;
|
|
_stillWatchingCountdown = 30;
|
|
});
|
|
|
|
if (isKeyboardMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _stillWatchingContinueFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
_stillWatchingTimer?.cancel();
|
|
_stillWatchingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
_setPlayerState(() {
|
|
_stillWatchingCountdown--;
|
|
});
|
|
if (_stillWatchingCountdown <= 0) {
|
|
timer.cancel();
|
|
_onStillWatchingTimeout();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingTimeout() {
|
|
_unfocusStillWatchingPrompt();
|
|
final currentPlayer = player;
|
|
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingContinue() {
|
|
_stillWatchingTimer?.cancel();
|
|
_unfocusStillWatchingPrompt();
|
|
SleepTimerService().restartTimer();
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingPause() {
|
|
_stillWatchingTimer?.cancel();
|
|
_unfocusStillWatchingPrompt();
|
|
final currentPlayer = player;
|
|
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _dismissStillWatching() {
|
|
_stillWatchingTimer?.cancel();
|
|
if (_showStillWatchingPrompt) {
|
|
_unfocusStillWatchingPrompt();
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _unfocusPlayNextPrompt() {
|
|
_playNextCancelFocusNode.unfocus();
|
|
_playNextConfirmFocusNode.unfocus();
|
|
}
|
|
|
|
void _unfocusStillWatchingPrompt() {
|
|
_stillWatchingPauseFocusNode.unfocus();
|
|
_stillWatchingContinueFocusNode.unfocus();
|
|
}
|
|
}
|