feat(tv): add configurable inactivity timeout with auto-logout

This commit is contained in:
Fredrik Burmester
2026-01-31 22:29:39 +01:00
parent e6598f0944
commit 3d406314a4
6 changed files with 433 additions and 136 deletions

View File

@@ -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<InactivityTimeout>[] = 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 (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -371,6 +430,21 @@ export default function SettingsTV() {
isFirst
/>
{/* Security Section */}
<TVSectionHeader title={t("home.settings.security.title")} />
<TVSettingsOptionButton
label={t("home.settings.security.inactivity_timeout.title")}
value={inactivityTimeoutLabel}
onPress={() =>
showOptions({
title: t("home.settings.security.inactivity_timeout.title"),
options: inactivityTimeoutOptions,
onSelect: (value) =>
updateSettings({ inactivityTimeout: value }),
})
}
/>
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton

View File

@@ -14,6 +14,7 @@ import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { InactivityProvider } from "@/providers/InactivityProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
@@ -385,143 +386,145 @@ function Layout() {
}}
>
<JellyfinProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<InactivityProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider>
</JellyfinProvider>
</PersistQueryClientProvider>
);

View File

@@ -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);

View File

@@ -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<InactivityContextValue | undefined>(
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<NodeJS.Timeout | null>(null);
const appStateRef = useRef<AppStateStatus>(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 (
<InactivityContext.Provider value={contextValue}>
{children}
</InactivityContext.Provider>
);
};
/**
* 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;
};

View File

@@ -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": {

View File

@@ -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<T> {
@@ -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<Settings> => {