feat: still watching prompt on sleep timer expiry

This commit is contained in:
edde746
2026-02-24 10:08:50 +01:00
parent 3fc1540f41
commit 4083837c3b
21 changed files with 372 additions and 44 deletions
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Schlaftimer aktiv",
"playbackWillPauseIn": "Wiedergabe wird in ${duration} pausiert",
"sleepTimerCompleted": "Schlaftimer abgelaufen Wiedergabe pausiert",
"stillWatching": "Schaust du noch?",
"pausingIn": "Pause in ${seconds}s",
"continueWatching": "Weiter",
"autoPlayNext": "Nächstes automatisch abspielen",
"playNext": "Nächstes abspielen",
"playButton": "Wiedergeben",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Timer Active",
"playbackWillPauseIn": "Playback will pause in ${duration}",
"sleepTimerCompleted": "Sleep timer completed - playback paused",
"stillWatching": "Still watching?",
"pausingIn": "Pausing in ${seconds}s",
"continueWatching": "Continue",
"autoPlayNext": "Auto-Play Next",
"playNext": "Play Next",
"playButton": "Play",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Temporizador Activo",
"playbackWillPauseIn": "La reproducción se pausará en ${duration}",
"sleepTimerCompleted": "Temporizador completado - reproducción pausada",
"stillWatching": "¿Sigues viendo?",
"pausingIn": "Pausa en ${seconds}s",
"continueWatching": "Continuar",
"autoPlayNext": "Reproducir siguiente automáticamente",
"playNext": "Reproducir siguiente",
"playButton": "Reproducir",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Minuterie active",
"playbackWillPauseIn": "La lecture sera mise en pause dans ${duration}",
"sleepTimerCompleted": "Minuterie de mise en veille terminée - lecture en pause",
"stillWatching": "Toujours en train de regarder ?",
"pausingIn": "Pause dans ${seconds}s",
"continueWatching": "Continuer",
"autoPlayNext": "Lecture automatique suivante",
"playNext": "Lire l'épisode suivant",
"playButton": "Lire",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Timer attivo",
"playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}",
"sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa",
"stillWatching": "Stai ancora guardando?",
"pausingIn": "Pausa tra ${seconds}s",
"continueWatching": "Continua",
"autoPlayNext": "Riproduzione automatica successivo",
"playNext": "Riproduci successivo",
"playButton": "Riproduci",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "타이머 활성화됨",
"playbackWillPauseIn": "재생이 ${duration} 후에 일시 중지 됩니다",
"sleepTimerCompleted": "수면 타이머 완료됨 - 재생이 일시 중지되었습니다",
"stillWatching": "아직 시청 중이신가요?",
"pausingIn": "${seconds}초 후 일시 정지",
"continueWatching": "계속",
"autoPlayNext": "다음 자동 재생",
"playNext": "다음 재생",
"playButton": "재생",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Timer actief",
"playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}",
"sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd",
"stillWatching": "Kijk je nog?",
"pausingIn": "Pauze over ${seconds}s",
"continueWatching": "Doorgaan",
"autoPlayNext": "Automatisch volgende afspelen",
"playNext": "Volgende afspelen",
"playButton": "Afspelen",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
/// Strings: 7254 (806 per locale)
/// Strings: 7281 (809 per locale)
///
/// Built on 2026-02-23 at 04:30 UTC
/// Built on 2026-02-24 at 03:45 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get timerActive => 'Schlaftimer aktiv';
@override String playbackWillPauseIn({required Object duration}) => 'Wiedergabe wird in ${duration} pausiert';
@override String get sleepTimerCompleted => 'Schlaftimer abgelaufen Wiedergabe pausiert';
@override String get stillWatching => 'Schaust du noch?';
@override String pausingIn({required Object seconds}) => 'Pause in ${seconds}s';
@override String get continueWatching => 'Weiter';
@override String get autoPlayNext => 'Nächstes automatisch abspielen';
@override String get playNext => 'Nächstes abspielen';
@override String get playButton => 'Wiedergeben';
@@ -1580,6 +1583,9 @@ extension on TranslationsDe {
'videoControls.timerActive' => 'Schlaftimer aktiv',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'Wiedergabe wird in ${duration} pausiert',
'videoControls.sleepTimerCompleted' => 'Schlaftimer abgelaufen Wiedergabe pausiert',
'videoControls.stillWatching' => 'Schaust du noch?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pause in ${seconds}s',
'videoControls.continueWatching' => 'Weiter',
'videoControls.autoPlayNext' => 'Nächstes automatisch abspielen',
'videoControls.playNext' => 'Nächstes abspielen',
'videoControls.playButton' => 'Wiedergeben',
@@ -1812,11 +1818,11 @@ extension on TranslationsDe {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NEU',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Programmführer neu laden',
'liveTv.guideReloaded' => 'Programmdaten neu geladen',
'liveTv.allChannels' => 'Alle Kanäle',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Jetzt',
'liveTv.today' => 'Heute',
'liveTv.midnight' => 'Mitternacht',
+14 -2
View File
@@ -981,6 +981,15 @@ class TranslationsVideoControlsEn {
/// en: 'Sleep timer completed - playback paused'
String get sleepTimerCompleted => 'Sleep timer completed - playback paused';
/// en: 'Still watching?'
String get stillWatching => 'Still watching?';
/// en: 'Pausing in ${seconds}s'
String pausingIn({required Object seconds}) => 'Pausing in ${seconds}s';
/// en: 'Continue'
String get continueWatching => 'Continue';
/// en: 'Auto-Play Next'
String get autoPlayNext => 'Auto-Play Next';
@@ -3199,6 +3208,9 @@ extension on Translations {
'videoControls.timerActive' => 'Timer Active',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'Playback will pause in ${duration}',
'videoControls.sleepTimerCompleted' => 'Sleep timer completed - playback paused',
'videoControls.stillWatching' => 'Still watching?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pausing in ${seconds}s',
'videoControls.continueWatching' => 'Continue',
'videoControls.autoPlayNext' => 'Auto-Play Next',
'videoControls.playNext' => 'Play Next',
'videoControls.playButton' => 'Play',
@@ -3431,11 +3443,11 @@ extension on Translations {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NEW',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Reload Guide',
'liveTv.guideReloaded' => 'Guide data reloaded',
'liveTv.allChannels' => 'All Channels',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Now',
'liveTv.today' => 'Today',
'liveTv.midnight' => 'Midnight',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get timerActive => 'Temporizador Activo';
@override String playbackWillPauseIn({required Object duration}) => 'La reproducción se pausará en ${duration}';
@override String get sleepTimerCompleted => 'Temporizador completado - reproducción pausada';
@override String get stillWatching => '¿Sigues viendo?';
@override String pausingIn({required Object seconds}) => 'Pausa en ${seconds}s';
@override String get continueWatching => 'Continuar';
@override String get autoPlayNext => 'Reproducir siguiente automáticamente';
@override String get playNext => 'Reproducir siguiente';
@override String get playButton => 'Reproducir';
@@ -1580,6 +1583,9 @@ extension on TranslationsEs {
'videoControls.timerActive' => 'Temporizador Activo',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'La reproducción se pausará en ${duration}',
'videoControls.sleepTimerCompleted' => 'Temporizador completado - reproducción pausada',
'videoControls.stillWatching' => '¿Sigues viendo?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pausa en ${seconds}s',
'videoControls.continueWatching' => 'Continuar',
'videoControls.autoPlayNext' => 'Reproducir siguiente automáticamente',
'videoControls.playNext' => 'Reproducir siguiente',
'videoControls.playButton' => 'Reproducir',
@@ -1812,11 +1818,11 @@ extension on TranslationsEs {
'liveTv.live' => 'EN VIVO',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NUEVO',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Recargar guía',
'liveTv.guideReloaded' => 'Datos de la guía recargados',
'liveTv.allChannels' => 'Todos los canales',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Ahora',
'liveTv.today' => 'Hoy',
'liveTv.midnight' => 'Medianoche',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get timerActive => 'Minuterie active';
@override String playbackWillPauseIn({required Object duration}) => 'La lecture sera mise en pause dans ${duration}';
@override String get sleepTimerCompleted => 'Minuterie de mise en veille terminée - lecture en pause';
@override String get stillWatching => 'Toujours en train de regarder ?';
@override String pausingIn({required Object seconds}) => 'Pause dans ${seconds}s';
@override String get continueWatching => 'Continuer';
@override String get autoPlayNext => 'Lecture automatique suivante';
@override String get playNext => 'Lire l\'épisode suivant';
@override String get playButton => 'Lire';
@@ -1580,6 +1583,9 @@ extension on TranslationsFr {
'videoControls.timerActive' => 'Minuterie active',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'La lecture sera mise en pause dans ${duration}',
'videoControls.sleepTimerCompleted' => 'Minuterie de mise en veille terminée - lecture en pause',
'videoControls.stillWatching' => 'Toujours en train de regarder ?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pause dans ${seconds}s',
'videoControls.continueWatching' => 'Continuer',
'videoControls.autoPlayNext' => 'Lecture automatique suivante',
'videoControls.playNext' => 'Lire l\'épisode suivant',
'videoControls.playButton' => 'Lire',
@@ -1812,11 +1818,11 @@ extension on TranslationsFr {
'liveTv.live' => 'EN DIRECT',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NOUVEAU',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Recharger le guide',
'liveTv.guideReloaded' => 'Données du guide rechargées',
'liveTv.allChannels' => 'Toutes les chaînes',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Maintenant',
'liveTv.today' => 'Aujourd\'hui',
'liveTv.midnight' => 'Minuit',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get timerActive => 'Timer attivo';
@override String playbackWillPauseIn({required Object duration}) => 'La riproduzione si interromperà tra ${duration}';
@override String get sleepTimerCompleted => 'Timer di spegnimento completato - riproduzione in pausa';
@override String get stillWatching => 'Stai ancora guardando?';
@override String pausingIn({required Object seconds}) => 'Pausa tra ${seconds}s';
@override String get continueWatching => 'Continua';
@override String get autoPlayNext => 'Riproduzione automatica successivo';
@override String get playNext => 'Riproduci successivo';
@override String get playButton => 'Riproduci';
@@ -1580,6 +1583,9 @@ extension on TranslationsIt {
'videoControls.timerActive' => 'Timer attivo',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'La riproduzione si interromperà tra ${duration}',
'videoControls.sleepTimerCompleted' => 'Timer di spegnimento completato - riproduzione in pausa',
'videoControls.stillWatching' => 'Stai ancora guardando?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pausa tra ${seconds}s',
'videoControls.continueWatching' => 'Continua',
'videoControls.autoPlayNext' => 'Riproduzione automatica successivo',
'videoControls.playNext' => 'Riproduci successivo',
'videoControls.playButton' => 'Riproduci',
@@ -1812,11 +1818,11 @@ extension on TranslationsIt {
'liveTv.live' => 'IN DIRETTA',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NUOVO',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Ricarica guida',
'liveTv.guideReloaded' => 'Dati della guida ricaricati',
'liveTv.allChannels' => 'Tutti i canali',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Ora',
'liveTv.today' => 'Oggi',
'liveTv.midnight' => 'Mezzanotte',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get timerActive => '타이머 활성화됨';
@override String playbackWillPauseIn({required Object duration}) => '재생이 ${duration} 후에 일시 중지 됩니다';
@override String get sleepTimerCompleted => '수면 타이머 완료됨 - 재생이 일시 중지되었습니다';
@override String get stillWatching => '아직 시청 중이신가요?';
@override String pausingIn({required Object seconds}) => '${seconds}초 후 일시 정지';
@override String get continueWatching => '계속';
@override String get autoPlayNext => '다음 자동 재생';
@override String get playNext => '다음 재생';
@override String get playButton => '재생';
@@ -1580,6 +1583,9 @@ extension on TranslationsKo {
'videoControls.timerActive' => '타이머 활성화됨',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => '재생이 ${duration} 후에 일시 중지 됩니다',
'videoControls.sleepTimerCompleted' => '수면 타이머 완료됨 - 재생이 일시 중지되었습니다',
'videoControls.stillWatching' => '아직 시청 중이신가요?',
'videoControls.pausingIn' => ({required Object seconds}) => '${seconds}초 후 일시 정지',
'videoControls.continueWatching' => '계속',
'videoControls.autoPlayNext' => '다음 자동 재생',
'videoControls.playNext' => '다음 재생',
'videoControls.playButton' => '재생',
@@ -1812,11 +1818,11 @@ extension on TranslationsKo {
'liveTv.live' => '실시간',
'liveTv.hd' => 'HD',
'liveTv.premiere' => '신규',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => '편성표 새로고침',
'liveTv.guideReloaded' => '편성표 데이터가 새로고침되었습니다',
'liveTv.allChannels' => '전체 채널',
_ => null,
} ?? switch (path) {
'liveTv.now' => '지금',
'liveTv.today' => '오늘',
'liveTv.midnight' => '자정',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get timerActive => 'Timer actief';
@override String playbackWillPauseIn({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}';
@override String get sleepTimerCompleted => 'Slaaptimer voltooid - afspelen gepauzeerd';
@override String get stillWatching => 'Kijk je nog?';
@override String pausingIn({required Object seconds}) => 'Pauze over ${seconds}s';
@override String get continueWatching => 'Doorgaan';
@override String get autoPlayNext => 'Automatisch volgende afspelen';
@override String get playNext => 'Volgende afspelen';
@override String get playButton => 'Afspelen';
@@ -1580,6 +1583,9 @@ extension on TranslationsNl {
'videoControls.timerActive' => 'Timer actief',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}',
'videoControls.sleepTimerCompleted' => 'Slaaptimer voltooid - afspelen gepauzeerd',
'videoControls.stillWatching' => 'Kijk je nog?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pauze over ${seconds}s',
'videoControls.continueWatching' => 'Doorgaan',
'videoControls.autoPlayNext' => 'Automatisch volgende afspelen',
'videoControls.playNext' => 'Volgende afspelen',
'videoControls.playButton' => 'Afspelen',
@@ -1812,11 +1818,11 @@ extension on TranslationsNl {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NIEUW',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Gids herladen',
'liveTv.guideReloaded' => 'Gidsgegevens herladen',
'liveTv.allChannels' => 'Alle zenders',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Nu',
'liveTv.today' => 'Vandaag',
'liveTv.midnight' => 'Middernacht',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get timerActive => 'Timer aktiv';
@override String playbackWillPauseIn({required Object duration}) => 'Uppspelningen pausas om ${duration}';
@override String get sleepTimerCompleted => 'Sovtimer slutförd - uppspelning pausad';
@override String get stillWatching => 'Tittar du fortfarande?';
@override String pausingIn({required Object seconds}) => 'Pausar om ${seconds}s';
@override String get continueWatching => 'Fortsätt';
@override String get autoPlayNext => 'Spela nästa automatiskt';
@override String get playNext => 'Spela nästa';
@override String get playButton => 'Spela';
@@ -1580,6 +1583,9 @@ extension on TranslationsSv {
'videoControls.timerActive' => 'Timer aktiv',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => 'Uppspelningen pausas om ${duration}',
'videoControls.sleepTimerCompleted' => 'Sovtimer slutförd - uppspelning pausad',
'videoControls.stillWatching' => 'Tittar du fortfarande?',
'videoControls.pausingIn' => ({required Object seconds}) => 'Pausar om ${seconds}s',
'videoControls.continueWatching' => 'Fortsätt',
'videoControls.autoPlayNext' => 'Spela nästa automatiskt',
'videoControls.playNext' => 'Spela nästa',
'videoControls.playButton' => 'Spela',
@@ -1812,11 +1818,11 @@ extension on TranslationsSv {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NY',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => 'Ladda om programguide',
'liveTv.guideReloaded' => 'Programdata omladdad',
'liveTv.allChannels' => 'Alla kanaler',
_ => null,
} ?? switch (path) {
'liveTv.now' => 'Nu',
'liveTv.today' => 'Idag',
'liveTv.midnight' => 'Midnatt',
+8 -2
View File
@@ -460,6 +460,9 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get timerActive => '定时器已激活';
@override String playbackWillPauseIn({required Object duration}) => '播放将在 ${duration} 后暂停';
@override String get sleepTimerCompleted => '睡眠定时器已完成 - 播放已暂停';
@override String get stillWatching => '还在看吗?';
@override String pausingIn({required Object seconds}) => '${seconds}秒后暂停';
@override String get continueWatching => '继续';
@override String get autoPlayNext => '自动播放下一集';
@override String get playNext => '播放下一集';
@override String get playButton => '播放';
@@ -1580,6 +1583,9 @@ extension on TranslationsZh {
'videoControls.timerActive' => '定时器已激活',
'videoControls.playbackWillPauseIn' => ({required Object duration}) => '播放将在 ${duration} 后暂停',
'videoControls.sleepTimerCompleted' => '睡眠定时器已完成 - 播放已暂停',
'videoControls.stillWatching' => '还在看吗?',
'videoControls.pausingIn' => ({required Object seconds}) => '${seconds}秒后暂停',
'videoControls.continueWatching' => '继续',
'videoControls.autoPlayNext' => '自动播放下一集',
'videoControls.playNext' => '播放下一集',
'videoControls.playButton' => '播放',
@@ -1812,11 +1818,11 @@ extension on TranslationsZh {
'liveTv.live' => '直播',
'liveTv.hd' => '高清',
'liveTv.premiere' => '',
_ => null,
} ?? switch (path) {
'liveTv.reloadGuide' => '重新加载节目指南',
'liveTv.guideReloaded' => '节目指南已重新加载',
'liveTv.allChannels' => '所有频道',
_ => null,
} ?? switch (path) {
'liveTv.now' => '现在',
'liveTv.today' => '今天',
'liveTv.midnight' => '午夜',
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "Timer aktiv",
"playbackWillPauseIn": "Uppspelningen pausas om ${duration}",
"sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad",
"stillWatching": "Tittar du fortfarande?",
"pausingIn": "Pausar om ${seconds}s",
"continueWatching": "Fortsätt",
"autoPlayNext": "Spela nästa automatiskt",
"playNext": "Spela nästa",
"playButton": "Spela",
+3
View File
@@ -305,6 +305,9 @@
"timerActive": "定时器已激活",
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停",
"stillWatching": "还在看吗?",
"pausingIn": "${seconds}秒后暂停",
"continueWatching": "继续",
"autoPlayNext": "自动播放下一集",
"playNext": "播放下一集",
"playButton": "播放",
+207 -19
View File
@@ -160,6 +160,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
late final FocusNode _playNextCancelFocusNode;
late final FocusNode _playNextConfirmFocusNode;
// "Still watching?" prompt (sleep timer)
bool _showStillWatchingPrompt = false;
int _stillWatchingCountdown = 30;
Timer? _stillWatchingTimer;
late final FocusNode _stillWatchingPauseFocusNode;
late final FocusNode _stillWatchingContinueFocusNode;
// Screen-level focus node: persists across loading/initialized phases so
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
@@ -221,6 +228,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
// Initialize "Still watching?" dialog focus nodes
_stillWatchingPauseFocusNode = FocusNode(debugLabel: 'StillWatchingPause');
_stillWatchingContinueFocusNode = FocusNode(debugLabel: 'StillWatchingContinue');
// Screen-level focus node that wraps the entire build output.
// Ensures a single stable focus target across loading → initialized phases.
_screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
@@ -265,9 +276,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Wire companion remote playback callbacks
_setupCompanionRemoteCallbacks();
// Exit the player when the sleep timer completes so the device can auto-lock
_sleepTimerSubscription = SleepTimerService().onCompleted.listen((_) {
if (mounted) _handleBackButton();
// Show "Still watching?" prompt when sleep timer fires
_sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) {
if (mounted) _showStillWatchingDialog();
});
// Initialize player asynchronously with buffer size from settings
@@ -463,6 +474,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_isPlayerInitialized = true;
});
// Restart sleep timer if we're starting a new playback session
final p = player;
if (p != null) {
SleepTimerService().restartIfNeeded(() => p.pause());
}
// Enable wakelock to prevent screen from turning off during playback
WakelockPlus.enable();
appLogger.d('Wakelock enabled for video playback');
@@ -1570,6 +1587,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoPIPManager?.onBeforeEnterPip = null;
_videoFilterManager?.dispose();
// Mark sleep timer for restart if truly exiting (not episode transition)
if (!_isReplacingWithVideo) {
SleepTimerService().markNeedsRestart();
}
// Cancel stream subscriptions
_playingSubscription?.cancel();
_completedSubscription?.cancel();
@@ -1585,10 +1607,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Cancel auto-play timer
_autoPlayTimer?.cancel();
// Cancel still watching timer
_stillWatchingTimer?.cancel();
// Dispose Play Next dialog focus nodes
_playNextCancelFocusNode.dispose();
_playNextConfirmFocusNode.dispose();
// Dispose "Still watching?" dialog focus nodes
_stillWatchingPauseFocusNode.dispose();
_stillWatchingContinueFocusNode.dispose();
// Dispose screen-level focus node
_screenFocusNode.removeListener(_onScreenFocusChanged);
_screenFocusNode.dispose();
@@ -1678,7 +1707,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
void _onVideoCompleted(bool completed) async {
if (completed && _nextEpisode != null && !_showPlayNextDialog && !_completionTriggered) {
if (completed &&
_nextEpisode != null &&
!_showPlayNextDialog &&
!_showStillWatchingPrompt &&
!_completionTriggered) {
_completionTriggered = true;
// Capture keyboard mode before async gap
@@ -1743,6 +1776,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Cancel auto-play timer if running
_autoPlayTimer?.cancel();
_dismissStillWatching();
// Notify Watch Together of episode change before navigating
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
@@ -1934,6 +1968,73 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
});
}
// -- "Still watching?" prompt --
void _showStillWatchingDialog() {
// Don't show if auto-play dialog is already visible
if (_showPlayNextDialog) return;
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context);
setState(() {
_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;
}
setState(() {
_stillWatchingCountdown--;
});
if (_stillWatchingCountdown <= 0) {
timer.cancel();
_onStillWatchingTimeout();
}
});
}
void _onStillWatchingTimeout() {
player?.pause();
setState(() {
_showStillWatchingPrompt = false;
});
}
void _onStillWatchingContinue() {
_stillWatchingTimer?.cancel();
SleepTimerService().restartTimer();
setState(() {
_showStillWatchingPrompt = false;
});
}
void _onStillWatchingPause() {
_stillWatchingTimer?.cancel();
player?.pause();
setState(() {
_showStillWatchingPrompt = false;
});
}
void _dismissStillWatching() {
_stillWatchingTimer?.cancel();
if (_showStillWatchingPrompt) {
setState(() {
_showStillWatchingPrompt = false;
});
}
}
/// Apply track selection using the TrackSelectionService
Future<void> _applyTrackSelection() async {
if (!mounted || player == null) return;
@@ -2069,12 +2170,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
await _saveTrackPreferences(
partId: partId,
trackType: 'audio',
languageCode: track.language,
streamID: streamID,
);
await _saveTrackPreferences(partId: partId, trackType: 'audio', languageCode: track.language, streamID: streamID);
}
/// Handle subtitle track changes from the user - save both stream selection and language preference
@@ -2115,12 +2211,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
await _saveTrackPreferences(
partId: partId,
trackType: 'subtitle',
languageCode: languageCode,
streamID: streamID,
);
await _saveTrackPreferences(partId: partId, trackType: 'subtitle', languageCode: languageCode, streamID: streamID);
}
/// Set flag to skip orientation restoration when replacing with another video
@@ -2251,9 +2342,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
},
child: OverlaySheetHost(
child: Builder(
builder: (sheetContext) => _isPlayerInitialized && player != null
? _buildVideoPlayer(sheetContext)
: _buildLoadingSpinner(),
builder: (sheetContext) =>
_isPlayerInitialized && player != null ? _buildVideoPlayer(sheetContext) : _buildLoadingSpinner(),
),
),
);
@@ -2548,6 +2638,104 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
);
},
),
// "Still watching?" overlay (hidden in PiP mode)
ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip || !_showStillWatchingPrompt) {
return const SizedBox.shrink();
}
return ValueListenableBuilder<bool>(
valueListenable: _controlsVisible,
builder: (context, controlsShown, child) {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
right: 24,
bottom: controlsShown ? 100 : 24,
child: Container(
width: 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.videoControls.stillWatching,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
t.videoControls.pausingIn(seconds: '$_stillWatchingCountdown'),
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FocusableButton(
focusNode: _stillWatchingPauseFocusNode,
onPressed: _onStillWatchingPause,
autoScroll: false,
onNavigateRight: () => _stillWatchingContinueFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
child: OutlinedButton(
onPressed: _onStillWatchingPause,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text(t.videoControls.pauseButton),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FocusableButton(
focusNode: _stillWatchingContinueFocusNode,
onPressed: _onStillWatchingContinue,
autoScroll: false,
onNavigateLeft: () => _stillWatchingPauseFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
child: FilledButton(
onPressed: _onStillWatchingContinue,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$_stillWatchingCountdown'),
const SizedBox(width: 4),
Text(t.videoControls.continueWatching),
],
),
),
),
),
],
),
],
),
),
);
},
);
},
),
// Buffering indicator (also shows during initial load, but not when exiting)
// Hidden in PiP mode
ValueListenableBuilder<bool>(
+58 -5
View File
@@ -12,12 +12,18 @@ class SleepTimerService extends ChangeNotifier {
Timer? _timer;
DateTime? _endTime;
Duration? _duration;
Duration? _originalDuration;
VoidCallback? _onTimerComplete;
bool _needsRestart = false;
final StreamController<void> _completedController = StreamController<void>.broadcast();
final StreamController<void> _promptController = StreamController<void>.broadcast();
/// Emits when the sleep timer completes (not when cancelled)
Stream<void> get onCompleted => _completedController.stream;
/// Emits when the timer fires and wants to show a "still watching?" prompt
Stream<void> get onPrompt => _promptController.stream;
/// Whether a timer is currently active
bool get isActive => _timer != null && _timer!.isActive;
@@ -27,6 +33,9 @@ class SleepTimerService extends ChangeNotifier {
/// The original duration of the timer
Duration? get duration => _duration;
/// The user-selected duration (unmodified by extendTimer)
Duration? get originalDuration => _originalDuration;
/// Remaining time on the timer
Duration? get remainingTime {
if (_endTime == null) return null;
@@ -41,6 +50,7 @@ class SleepTimerService extends ChangeNotifier {
// Cancel any existing timer
cancelTimer();
_originalDuration = duration;
_duration = duration;
_endTime = DateTime.now().add(duration);
_onTimerComplete = onComplete;
@@ -52,9 +62,9 @@ class SleepTimerService extends ChangeNotifier {
final remaining = remainingTime;
if (remaining == null || remaining.inSeconds <= 0) {
appLogger.d('Sleep timer completed');
_executeCallback();
cancelTimer();
appLogger.d('Sleep timer completed - showing prompt');
_stopTimerOnly();
_promptController.add(null);
} else {
// Notify listeners to update UI
notifyListeners();
@@ -64,19 +74,51 @@ class SleepTimerService extends ChangeNotifier {
notifyListeners();
}
/// Cancel the active timer
/// Cancel the active timer (user-initiated, clears everything)
void cancelTimer() {
if (_timer != null) {
if (_timer != null || _originalDuration != null) {
appLogger.d('Sleep timer cancelled');
_timer?.cancel();
_timer = null;
_endTime = null;
_duration = null;
_originalDuration = null;
_onTimerComplete = null;
notifyListeners();
}
}
/// Restart the timer with the original user-selected duration
void restartTimer() {
if (_originalDuration != null && _onTimerComplete != null) {
final duration = _originalDuration!;
final callback = _onTimerComplete!;
startTimer(duration, callback);
}
}
/// Execute the completion callback directly (fallback path)
void executeCompletion() {
_executeCallback();
}
/// Mark that the timer should restart when a new playback session begins
/// (e.g. user exited the player and started something new)
void markNeedsRestart() {
if (isActive || _originalDuration != null) {
_needsRestart = true;
}
}
/// Restart the timer if it was marked for restart (new playback session).
/// [onComplete] provides the new callback for the fresh session.
void restartIfNeeded(VoidCallback onComplete) {
if (_needsRestart && _originalDuration != null) {
_needsRestart = false;
startTimer(_originalDuration!, onComplete);
}
}
/// Extend the current timer by the specified duration
void extendTimer(Duration additionalTime) {
if (_endTime != null) {
@@ -87,6 +129,16 @@ class SleepTimerService extends ChangeNotifier {
}
}
/// Stop the periodic timer but preserve _originalDuration and _onTimerComplete
/// for the prompt flow (restart/completion)
void _stopTimerOnly() {
_timer?.cancel();
_timer = null;
_endTime = null;
_duration = null;
notifyListeners();
}
void _executeCallback() {
if (_onTimerComplete != null) {
try {
@@ -102,6 +154,7 @@ class SleepTimerService extends ChangeNotifier {
void dispose() {
_timer?.cancel();
_completedController.close();
_promptController.close();
super.dispose();
}
}