From ad1d9b5888214dab09dcfa3d07844685cdecc031 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 23:33:11 +0100 Subject: [PATCH] fix(tv): pause inactivity timer during video playback --- app/(auth)/player/direct-player.tsx | 21 +++++++++++-- providers/InactivityProvider.tsx | 47 +++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b9c1b8ab..2f40a2d2 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -45,8 +45,8 @@ import { } from "@/modules"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; +import { useInactivity } from "@/providers/InactivityProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; - import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -105,6 +105,9 @@ export default function page() { // when data updates, only when the provider initializes const downloadedFiles = downloadUtils.getDownloadedItems(); + // Inactivity timer controls (TV only) + const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity(); + const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const lightHapticFeedback = useHaptic("light"); @@ -421,7 +424,9 @@ export default function page() { setIsPlaybackStopped(true); videoRef.current?.pause(); revalidateProgressCache(); - }, [videoRef, reportPlaybackStopped, progress]); + // Resume inactivity timer when leaving player (TV only) + resumeInactivityTimer(); + }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); @@ -729,6 +734,8 @@ export default function page() { setIsPlaying(true); setIsBuffering(false); setHasPlaybackStarted(true); + // Pause inactivity timer during playback (TV only) + pauseInactivityTimer(); if (item?.Id) { playbackManager.reportPlaybackProgress( currentPlayStateInfo() as PlaybackProgressInfo, @@ -740,6 +747,8 @@ export default function page() { if (isPaused) { setIsPlaying(false); + // Resume inactivity timer when paused (TV only) + resumeInactivityTimer(); if (item?.Id) { playbackManager.reportPlaybackProgress( currentPlayStateInfo() as PlaybackProgressInfo, @@ -753,7 +762,13 @@ export default function page() { setIsBuffering(isLoading); } }, - [playbackManager, item?.Id, progress], + [ + playbackManager, + item?.Id, + progress, + pauseInactivityTimer, + resumeInactivityTimer, + ], ); /** PiP handler for MPV */ diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx index d76a55f7..2c47ada6 100644 --- a/providers/InactivityProvider.tsx +++ b/providers/InactivityProvider.tsx @@ -16,6 +16,8 @@ const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY"; interface InactivityContextValue { resetInactivityTimer: () => void; + pauseInactivityTimer: () => void; + resumeInactivityTimer: () => void; } const InactivityContext = createContext( @@ -29,6 +31,7 @@ const InactivityContext = createContext( * Features: * - Tracks last activity timestamp (persisted to MMKV) * - Resets timer on any focus change (via resetInactivityTimer) + * - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer) * - Handles app backgrounding: logs out immediately if timeout exceeded while away * - No-op on mobile platforms */ @@ -39,6 +42,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const { logout } = useJellyfin(); const timerRef = useRef(null); const appStateRef = useRef(AppState.currentState); + const isPausedRef = useRef(false); const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled; const isEnabled = Platform.isTV && timeoutMs > 0; @@ -61,7 +65,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const startTimer = useCallback( (remainingMs?: number) => { - if (!isEnabled) return; + if (!isEnabled || isPausedRef.current) return; clearTimer(); @@ -75,8 +79,25 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ ); const resetInactivityTimer = useCallback(() => { + if (!isEnabled || isPausedRef.current) return; + + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + const pauseInactivityTimer = useCallback(() => { if (!isEnabled) return; + isPausedRef.current = true; + clearTimer(); + // Update last activity so when we resume, we start fresh + updateLastActivity(); + }, [isEnabled, clearTimer, updateLastActivity]); + + const resumeInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + isPausedRef.current = false; updateLastActivity(); startTimer(); }, [isEnabled, updateLastActivity, startTimer]); @@ -92,7 +113,14 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const isNowActive = nextAppState === "active"; if (wasBackground && isNowActive) { - // App returned to foreground - check if timeout exceeded + // App returned to foreground + // If paused (e.g., video playing), don't check timeout + if (isPausedRef.current) { + appStateRef.current = nextAppState; + return; + } + + // Check if timeout exceeded const lastActivity = getLastActivity(); const elapsed = Date.now() - lastActivity; @@ -130,6 +158,9 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ return; } + // Don't start timer if paused + if (isPausedRef.current) return; + // Check if we should logout based on last activity const lastActivity = getLastActivity(); const elapsed = Date.now() - lastActivity; @@ -151,7 +182,7 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ // Reset activity on initial mount when enabled useEffect(() => { - if (isEnabled) { + if (isEnabled && !isPausedRef.current) { updateLastActivity(); startTimer(); } @@ -159,6 +190,8 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ const contextValue: InactivityContextValue = { resetInactivityTimer, + pauseInactivityTimer, + resumeInactivityTimer, }; return ( @@ -169,16 +202,18 @@ export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ }; /** - * Hook to access the inactivity reset function. - * Returns a no-op function if not within the provider (safe on mobile). + * Hook to access the inactivity timer controls. + * Returns no-op functions if not within the provider (safe on mobile). */ export const useInactivity = (): InactivityContextValue => { const context = useContext(InactivityContext); - // Return a no-op if not within provider (e.g., on mobile) + // Return no-ops if not within provider (e.g., on mobile) if (!context) { return { resetInactivityTimer: () => {}, + pauseInactivityTimer: () => {}, + resumeInactivityTimer: () => {}, }; }