From 3d406314a4e16e534a6bf97df92a8413b8b67c1c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 22:29:39 +0100 Subject: [PATCH] feat(tv): add configurable inactivity timeout with auto-logout --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 74 ++++++ app/_layout.tsx | 273 +++++++++++---------- components/tv/hooks/useTVFocusAnimation.ts | 5 +- providers/InactivityProvider.tsx | 186 ++++++++++++++ translations/en.json | 15 ++ utils/atoms/settings.ts | 16 ++ 6 files changed, 433 insertions(+), 136 deletions(-) create mode 100644 providers/InactivityProvider.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 9e5b794d..7a32fc9c 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -24,6 +24,7 @@ import { APP_LANGUAGES } from "@/i18n"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, + InactivityTimeout, type MpvCacheMode, TVTypographyScale, useSettings, @@ -300,6 +301,57 @@ export default function SettingsTV() { [t, currentLanguage], ); + // Inactivity timeout options (TV security feature) + const currentInactivityTimeout = + settings.inactivityTimeout ?? InactivityTimeout.Disabled; + + const inactivityTimeoutOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.security.inactivity_timeout.disabled"), + value: InactivityTimeout.Disabled, + selected: currentInactivityTimeout === InactivityTimeout.Disabled, + }, + { + label: t("home.settings.security.inactivity_timeout.1_minute"), + value: InactivityTimeout.OneMinute, + selected: currentInactivityTimeout === InactivityTimeout.OneMinute, + }, + { + label: t("home.settings.security.inactivity_timeout.5_minutes"), + value: InactivityTimeout.FiveMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.15_minutes"), + value: InactivityTimeout.FifteenMinutes, + selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.30_minutes"), + value: InactivityTimeout.ThirtyMinutes, + selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes, + }, + { + label: t("home.settings.security.inactivity_timeout.1_hour"), + value: InactivityTimeout.OneHour, + selected: currentInactivityTimeout === InactivityTimeout.OneHour, + }, + { + label: t("home.settings.security.inactivity_timeout.4_hours"), + value: InactivityTimeout.FourHours, + selected: currentInactivityTimeout === InactivityTimeout.FourHours, + }, + { + label: t("home.settings.security.inactivity_timeout.24_hours"), + value: InactivityTimeout.TwentyFourHours, + selected: + currentInactivityTimeout === InactivityTimeout.TwentyFourHours, + }, + ], + [t, currentInactivityTimeout], + ); + // Get display labels for option buttons const audioTranscodeLabel = useMemo(() => { const option = audioTranscodeModeOptions.find((o) => o.selected); @@ -337,6 +389,13 @@ export default function SettingsTV() { return option?.label || t("home.settings.languages.system"); }, [currentLanguage, t]); + const inactivityTimeoutLabel = useMemo(() => { + const option = inactivityTimeoutOptions.find((o) => o.selected); + return ( + option?.label || t("home.settings.security.inactivity_timeout.disabled") + ); + }, [inactivityTimeoutOptions, t]); + return ( @@ -371,6 +430,21 @@ export default function SettingsTV() { isFirst /> + {/* Security Section */} + + + showOptions({ + title: t("home.settings.security.inactivity_timeout.title"), + options: inactivityTimeoutOptions, + onSelect: (value) => + updateSettings({ inactivityTimeout: value }), + }) + } + /> + {/* Audio Section */} - - - - - - - - - - - - - - - - - - - - - - + {!Platform.isTV && } + + + + + + + + + + + + ); diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts index b76d39f0..b3418c8c 100644 --- a/components/tv/hooks/useTVFocusAnimation.ts +++ b/components/tv/hooks/useTVFocusAnimation.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { Animated, Easing } from "react-native"; +import { useInactivity } from "@/providers/InactivityProvider"; export interface UseTVFocusAnimationOptions { scaleAmount?: number; @@ -24,6 +25,7 @@ export const useTVFocusAnimation = ({ }: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; + const { resetInactivityTimer } = useInactivity(); const animateTo = useCallback( (value: number) => { @@ -40,8 +42,9 @@ export const useTVFocusAnimation = ({ const handleFocus = useCallback(() => { setFocused(true); animateTo(scaleAmount); + resetInactivityTimer(); onFocus?.(); - }, [animateTo, scaleAmount, onFocus]); + }, [animateTo, scaleAmount, resetInactivityTimer, onFocus]); const handleBlur = useCallback(() => { setFocused(false); diff --git a/providers/InactivityProvider.tsx b/providers/InactivityProvider.tsx new file mode 100644 index 00000000..d76a55f7 --- /dev/null +++ b/providers/InactivityProvider.tsx @@ -0,0 +1,186 @@ +import type React from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { AppState, type AppStateStatus, Platform } from "react-native"; +import { useJellyfin } from "@/providers/JellyfinProvider"; +import { InactivityTimeout, useSettings } from "@/utils/atoms/settings"; +import { storage } from "@/utils/mmkv"; + +const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY"; + +interface InactivityContextValue { + resetInactivityTimer: () => void; +} + +const InactivityContext = createContext( + undefined, +); + +/** + * TV-only provider that tracks user inactivity and auto-logs out + * when the configured timeout is exceeded. + * + * Features: + * - Tracks last activity timestamp (persisted to MMKV) + * - Resets timer on any focus change (via resetInactivityTimer) + * - Handles app backgrounding: logs out immediately if timeout exceeded while away + * - No-op on mobile platforms + */ +export const InactivityProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const { settings } = useSettings(); + const { logout } = useJellyfin(); + const timerRef = useRef(null); + const appStateRef = useRef(AppState.currentState); + + const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled; + const isEnabled = Platform.isTV && timeoutMs > 0; + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const updateLastActivity = useCallback(() => { + if (!isEnabled) return; + storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now()); + }, [isEnabled]); + + const getLastActivity = useCallback((): number => { + return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now(); + }, []); + + const startTimer = useCallback( + (remainingMs?: number) => { + if (!isEnabled) return; + + clearTimer(); + + const delay = remainingMs ?? timeoutMs; + timerRef.current = setTimeout(() => { + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + }, delay); + }, + [isEnabled, timeoutMs, clearTimer, logout], + ); + + const resetInactivityTimer = useCallback(() => { + if (!isEnabled) return; + + updateLastActivity(); + startTimer(); + }, [isEnabled, updateLastActivity, startTimer]); + + // Handle app state changes (background/foreground) + useEffect(() => { + if (!isEnabled) return; + + const handleAppStateChange = (nextAppState: AppStateStatus) => { + const wasBackground = + appStateRef.current === "background" || + appStateRef.current === "inactive"; + const isNowActive = nextAppState === "active"; + + if (wasBackground && isNowActive) { + // App returned to foreground - check if timeout exceeded + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Timeout exceeded while backgrounded - logout immediately + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Restart timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + } else if (nextAppState === "background" || nextAppState === "inactive") { + // App going to background - clear timer (time continues via timestamp) + clearTimer(); + } + + appStateRef.current = nextAppState; + }; + + const subscription = AppState.addEventListener( + "change", + handleAppStateChange, + ); + + return () => { + subscription.remove(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Initialize timer when enabled or timeout changes + useEffect(() => { + if (!isEnabled) { + clearTimer(); + return; + } + + // Check if we should logout based on last activity + const lastActivity = getLastActivity(); + const elapsed = Date.now() - lastActivity; + + if (elapsed >= timeoutMs) { + // Already timed out - logout + logout(); + storage.remove(INACTIVITY_LAST_ACTIVITY_KEY); + } else { + // Start timer with remaining time + const remainingMs = timeoutMs - elapsed; + startTimer(remainingMs); + } + + return () => { + clearTimer(); + }; + }, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]); + + // Reset activity on initial mount when enabled + useEffect(() => { + if (isEnabled) { + updateLastActivity(); + startTimer(); + } + }, []); + + const contextValue: InactivityContextValue = { + resetInactivityTimer, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to access the inactivity reset function. + * Returns a no-op function 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) + if (!context) { + return { + resetInactivityTimer: () => {}, + }; + } + + return context; +}; diff --git a/translations/en.json b/translations/en.json index bd25ba87..49cdc180 100644 --- a/translations/en.json +++ b/translations/en.json @@ -481,6 +481,21 @@ "error_deleting_files": "Error Deleting Files", "background_downloads_enabled": "Background downloads enabled", "background_downloads_disabled": "Background downloads disabled" + }, + "security": { + "title": "Security", + "inactivity_timeout": { + "title": "Inactivity Timeout", + "description": "Auto logout after inactivity", + "disabled": "Disabled", + "1_minute": "1 minute", + "5_minutes": "5 minutes", + "15_minutes": "15 minutes", + "30_minutes": "30 minutes", + "1_hour": "1 hour", + "4_hours": "4 hours", + "24_hours": "24 hours" + } } }, "sessions": { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 63b2ee16..31540ccb 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -154,6 +154,18 @@ export enum AudioTranscodeMode { AllowAll = "passthrough", // Direct play all audio formats } +// Inactivity timeout for TV - auto logout after period of no activity +export enum InactivityTimeout { + Disabled = 0, + OneMinute = 60000, + FiveMinutes = 300000, + FifteenMinutes = 900000, + ThirtyMinutes = 1800000, + OneHour = 3600000, + FourHours = 14400000, + TwentyFourHours = 86400000, +} + // MPV cache mode - controls how caching is enabled export type MpvCacheMode = "auto" | "yes" | "no"; @@ -234,6 +246,8 @@ export type Settings = { audioTranscodeMode: AudioTranscodeMode; // OpenSubtitles API key for client-side subtitle fetching openSubtitlesApiKey?: string; + // TV-only: Inactivity timeout for auto-logout + inactivityTimeout: InactivityTimeout; }; export interface Lockable { @@ -329,6 +343,8 @@ export const defaultValues: Settings = { preferLocalAudio: true, // Audio transcoding mode audioTranscodeMode: AudioTranscodeMode.Auto, + // TV-only: Inactivity timeout (disabled by default) + inactivityTimeout: InactivityTimeout.Disabled, }; const loadSettings = (): Partial => {