From 6e85c8d54ac6a3c72a74917157c542593cb6c611 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 31 Jan 2026 09:53:54 +0100 Subject: [PATCH] feat(tv): add user switching from settings --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 136 +++++++++++++- app/(auth)/tv-user-switch-modal.tsx | 174 ++++++++++++++++++ app/_layout.tsx | 8 + components/tv/TVUserCard.tsx | 174 ++++++++++++++++++ components/tv/index.ts | 3 + .../tv/settings/TVSettingsOptionButton.tsx | 1 + hooks/useTVUserSwitchModal.ts | 42 +++++ providers/JellyfinProvider.tsx | 8 + translations/en.json | 6 + utils/atoms/tvUserSwitchModal.ts | 12 ++ 10 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/tv-user-switch-modal.tsx create mode 100644 components/tv/TVUserCard.tsx create mode 100644 hooks/useTVUserSwitchModal.ts create mode 100644 utils/atoms/tvUserSwitchModal.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 3f7adc14..9e5b794d 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -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( + null, + ); + const [selectedAccount, setSelectedAccount] = + useState(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")} + {/* Account Section */} + + + {/* Audio Section */} {/* Subtitles Section */} @@ -570,6 +671,37 @@ export default function SettingsTV() { + + {/* PIN Entry Modal */} + { + 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 */} + { + setPasswordModalVisible(false); + setSelectedAccount(null); + setSelectedServer(null); + }} + onSubmit={handlePasswordSubmit} + username={selectedAccount?.username || ""} + /> ); } diff --git a/app/(auth)/tv-user-switch-modal.tsx b/app/(auth)/tv-user-switch-modal.tsx new file mode 100644 index 00000000..1478b0f7 --- /dev/null +++ b/app/(auth)/tv-user-switch-modal.tsx @@ -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(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 ( + + + + + + {t("home.settings.switch_user.title")} + + {modalState.serverName} + {isReady && ( + + {modalState.accounts.map((account, index) => { + const isCurrent = account.userId === modalState.currentUserId; + return ( + handleSelect(account)} + /> + ); + })} + + )} + + + + + ); +} + +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, + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index a7847180..a3dc6240 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -488,6 +488,14 @@ function Layout() { animation: "fade", }} /> + void; +} + +export const TVUserCard = React.forwardRef( + ( + { + 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 ( + + + {/* User Avatar */} + + + + + {/* Text column */} + + {/* Username */} + + + {username} + + {isCurrent && ( + + ({t("home.settings.switch_user.current")}) + + )} + + + {/* Security indicator */} + + + + {getSecurityText()} + + + + + + ); + }, +); diff --git a/components/tv/index.ts b/components/tv/index.ts index 76a6e60e..a35104eb 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -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"; diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx index 07f879ce..0166f99b 100644 --- a/components/tv/settings/TVSettingsOptionButton.tsx +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -47,6 +47,7 @@ export const TVSettingsOptionButton: React.FC = ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", + opacity: disabled ? 0.4 : 1, }, ]} > diff --git a/hooks/useTVUserSwitchModal.ts b/hooks/useTVUserSwitchModal.ts new file mode 100644 index 00000000..a0b0a944 --- /dev/null +++ b/hooks/useTVUserSwitchModal.ts @@ -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 }; +} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 4ad040de..97ba07e0 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -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)); diff --git a/translations/en.json b/translations/en.json index 78edbee0..651a1aa3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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" }, diff --git a/utils/atoms/tvUserSwitchModal.ts b/utils/atoms/tvUserSwitchModal.ts new file mode 100644 index 00000000..2df72df1 --- /dev/null +++ b/utils/atoms/tvUserSwitchModal.ts @@ -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(null);