Files
plezy/lib/screens/video_player/parts/playback_prompts.dart
T
2026-06-02 15:52:14 +02:00

200 lines
6.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 spurious EOF events caused by
// inter-segment gaps in the chunked MKV transcode stream.
if (widget.isLive) return;
if (!completed) return;
// Ignore spurious EOF from the old file during in-place episode swap
if (_isSwappingEpisode) return;
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
// never fires false. Normalize all playback-dependent state.
unawaited(_setWakelock(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 && !_completionTriggered) {
_completionTriggered = true;
sleepTimerService.notifyVideoCompleted();
return;
}
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) {
_completionTriggered = true;
// 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 && !_completionTriggered) {
_completionTriggered = true;
unawaited(_handleBackButton());
}
}
void _startAutoPlayTimer() {
_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 _completionTriggered set: playback is still parked inside the
// end-of-video window, so clearing it here would let the position listener
// re-fire this prompt on the next tick. It is re-armed once playback seeks
// back clear of the end region (see the position listener) or new media loads.
_setPlayerState(() {
_showPlayNextDialog = false;
});
}
/// Re-arm the end-of-video latch so Play Next can fire again — but only when no
/// prompt is visible and no auto-play countdown is running, so we never clobber
/// an active dialog. Callers decide *when* it is safe to re-arm (media reloaded,
/// or playback moved back out of the end region).
void _rearmCompletionLatch() {
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
_completionTriggered = false;
}
}
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();
player?.pause();
_setPlayerState(() {
_showStillWatchingPrompt = false;
});
}
void _onStillWatchingContinue() {
_stillWatchingTimer?.cancel();
_unfocusStillWatchingPrompt();
SleepTimerService().restartTimer();
_setPlayerState(() {
_showStillWatchingPrompt = false;
});
}
void _onStillWatchingPause() {
_stillWatchingTimer?.cancel();
_unfocusStillWatchingPrompt();
player?.pause();
_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();
}
}