feat(tv): add user switching from settings

This commit is contained in:
Fredrik Burmester
2026-01-31 09:53:54 +01:00
parent bf518b4834
commit 6e85c8d54a
10 changed files with 562 additions and 2 deletions

View File

@@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import type { TVOptionItem } from "@/components/tv";
import {
TVLogoutButton,
@@ -17,6 +19,7 @@ import {
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -25,15 +28,21 @@ import {
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout } = useJellyfin();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
// Local state for OpenSubtitles API key (only commit on blur)
@@ -41,6 +50,89 @@ export default function SettingsTV() {
settings.openSubtitlesApiKey || "",
);
// PIN/Password modal state for user switching
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
// Get current server and other accounts
const currentServer = useMemo(() => {
if (!api?.basePath) return null;
const servers = getPreviousServers();
return servers.find((s) => s.address === api.basePath) || null;
}, [api?.basePath]);
const otherAccounts = useMemo(() => {
if (!currentServer || !user?.Id) return [];
return currentServer.accounts.filter(
(account) => account.userId !== user.Id,
);
}, [currentServer, user?.Id]);
const hasOtherAccounts = otherAccounts.length > 0;
// Handle account selection from modal
const handleAccountSelect = (account: SavedServerAccount) => {
if (!currentServer) return;
if (account.securityType === "none") {
// Direct login with saved credential
loginWithSavedCredential(currentServer.address, account.userId);
} else if (account.securityType === "pin") {
// Show PIN modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPinModalVisible(true);
} else if (account.securityType === "password") {
// Show password modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPasswordModalVisible(true);
}
};
// Handle successful PIN entry
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
await loginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
}
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle password submission
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await loginWithPassword(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle switch user button press
const handleSwitchUser = () => {
if (!currentServer || !user?.Id) return;
showUserSwitchModal(currentServer, user.Id, {
onAccountSelect: handleAccountSelect,
});
};
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
@@ -269,6 +361,16 @@ export default function SettingsTV() {
{t("home.settings.settings_title")}
</Text>
{/* Account Section */}
<TVSectionHeader title={t("home.settings.switch_user.account")} />
<TVSettingsOptionButton
label={t("home.settings.switch_user.switch_user")}
value={user?.Name || "-"}
onPress={handleSwitchUser}
disabled={!hasOtherAccounts || isAnyModalOpen}
isFirst
/>
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
@@ -282,7 +384,6 @@ export default function SettingsTV() {
updateSettings({ audioTranscodeMode: value }),
})
}
isFirst
/>
{/* Subtitles Section */}
@@ -570,6 +671,37 @@ export default function SettingsTV() {
</View>
</ScrollView>
</View>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
}

View File

@@ -0,0 +1,174 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import useRouter from "@/hooks/useAppRouter";
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
import type { SavedServerAccount } from "@/utils/secureCredentials";
import { store } from "@/utils/store";
export default function TVUserSwitchModalPage() {
const { t } = useTranslation();
const router = useRouter();
const modalState = useAtomValue(tvUserSwitchModalAtom);
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvUserSwitchModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (account: SavedServerAccount) => {
modalState?.onAccountSelect(account);
store.set(tvUserSwitchModalAtom, null);
router.back();
};
// If no modal state, just return null
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>
{t("home.settings.switch_user.title")}
</Text>
<Text style={styles.subtitle}>{modalState.serverName}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.accounts.map((account, index) => {
const isCurrent = account.userId === modalState.currentUserId;
return (
<TVUserCard
key={account.userId}
ref={index === 0 ? firstCardRef : undefined}
username={account.username}
securityType={account.securityType}
hasTVPreferredFocus={index === 0}
isCurrent={isCurrent}
onPress={() => handleSelect(account)}
/>
);
})}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
subtitle: {
fontSize: 14,
color: "rgba(255,255,255,0.4)",
marginBottom: 16,
paddingHorizontal: 48,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
},
});

View File

@@ -488,6 +488,14 @@ function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}

View File

@@ -0,0 +1,174 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type { AccountSecurityType } from "@/utils/secureCredentials";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVUserCardProps {
username: string;
securityType: AccountSecurityType;
hasTVPreferredFocus?: boolean;
isCurrent?: boolean;
onPress: () => void;
}
export const TVUserCard = React.forwardRef<View, TVUserCardProps>(
(
{
username,
securityType,
hasTVPreferredFocus = false,
isCurrent = false,
onPress,
},
ref,
) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: isCurrent ? 1.02 : 1.05 });
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
switch (securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const getSecurityText = (): string => {
switch (securityType) {
case "pin":
return t("save_account.pin_code");
case "password":
return t("save_account.password");
default:
return t("save_account.no_protection");
}
};
const getBackgroundColor = () => {
if (isCurrent) {
return focused ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.04)";
}
return focused ? "#fff" : "rgba(255,255,255,0.08)";
};
const getTextColor = () => {
if (isCurrent) {
return "rgba(255,255,255,0.4)";
}
return focused ? "#000" : "#fff";
};
const getSecondaryColor = () => {
if (isCurrent) {
return "rgba(255,255,255,0.25)";
}
return focused ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.5)";
};
return (
<Pressable
ref={ref}
onPress={isCurrent ? undefined : onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
flexDirection: "row",
alignItems: "center",
backgroundColor: getBackgroundColor(),
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 14,
},
]}
>
{/* User Avatar */}
<View
style={{
width: 44,
height: 44,
backgroundColor: isCurrent
? "rgba(255,255,255,0.08)"
: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.15)",
borderRadius: 22,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='person' size={24} color={getTextColor()} />
</View>
{/* Text column */}
<View style={{ gap: 4 }}>
{/* Username */}
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Text
style={{
fontSize: typography.callout,
color: getTextColor(),
fontWeight: "600",
}}
numberOfLines={1}
>
{username}
</Text>
{isCurrent && (
<Text
style={{
fontSize: typography.callout - 4,
color: "rgba(255,255,255,0.3)",
fontStyle: "italic",
}}
>
({t("home.settings.switch_user.current")})
</Text>
)}
</View>
{/* Security indicator */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 4,
}}
>
<Ionicons
name={getSecurityIcon()}
size={12}
color={getSecondaryColor()}
/>
<Text
style={{
fontSize: typography.callout - 4,
color: getSecondaryColor(),
}}
numberOfLines={1}
>
{getSecurityText()}
</Text>
</View>
</View>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -65,3 +65,6 @@ export { TVThemeMusicIndicator } from "./TVThemeMusicIndicator";
// Subtitle sheet components
export type { TVTrackCardProps } from "./TVTrackCard";
export { TVTrackCard } from "./TVTrackCard";
// User switching
export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard";

View File

@@ -47,6 +47,7 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
opacity: disabled ? 0.4 : 1,
},
]}
>

View File

@@ -0,0 +1,42 @@
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
interface UseTVUserSwitchModalOptions {
onAccountSelect: (account: SavedServerAccount) => void;
}
export function useTVUserSwitchModal() {
const router = useRouter();
const showUserSwitchModal = useCallback(
(
server: SavedServer,
currentUserId: string,
options: UseTVUserSwitchModalOptions,
) => {
// Need at least 2 accounts (current + at least one other)
if (server.accounts.length < 2) {
return;
}
store.set(tvUserSwitchModalAtom, {
serverUrl: server.address,
serverName: server.name || server.address,
accounts: server.accounts,
currentUserId,
onAccountSelect: options.onAccountSelect,
});
router.push("/(auth)/tv-user-switch-modal");
},
[router],
);
return { showUserSwitchModal };
}

View File

@@ -389,6 +389,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
try {
const response = await getUserApi(apiInstance).getCurrentUser();
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Token is valid, update state
setApi(apiInstance);
setUser(response.data);
@@ -437,6 +441,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const auth = await apiInstance.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
setUser(auth.data.User);
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));

View File

@@ -112,6 +112,12 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User",
"current": "current"
},
"categories": {
"title": "Categories"
},

View File

@@ -0,0 +1,12 @@
import { atom } from "jotai";
import type { SavedServerAccount } from "@/utils/secureCredentials";
export type TVUserSwitchModalState = {
serverUrl: string;
serverName: string;
accounts: SavedServerAccount[];
currentUserId: string;
onAccountSelect: (account: SavedServerAccount) => void;
} | null;
export const tvUserSwitchModalAtom = atom<TVUserSwitchModalState>(null);