Files
plezy/lib/screens/video_player/parts/playback_prompts.dart
T

218 lines
6.7 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;
// 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());
unawaited(TraktScrobbleService.instance.pausePlayback());
if (_autoPipEnabled) {
unawaited(_videoPIPManager?.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;
}
if (_nextEpisode != null && !_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 (_nextEpisode == null && !_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();
}
}