diff --git a/CLAUDE.md b/CLAUDE.md index d60543c3..f8652623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,3 +220,35 @@ For dropdown/select components on TV, use a **bottom sheet with horizontal scrol 4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip. **Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx` + +### TV Focus Management for Overlays/Modals + +**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation. + +**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: + +```typescript +// 1. Track modal state +const [openModal, setOpenModal] = useState(null); +const isModalOpen = openModal !== null; + +// 2. Each focusable component accepts disabled prop +const TVFocusableButton: React.FC<{ + onPress: () => void; + disabled?: boolean; +}> = ({ onPress, disabled }) => ( + + {/* content */} + +); + +// 3. Pass disabled to all background components when modal is open + +``` + +**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 518e6ad1..6b3777db 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -1,7 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { BlurView } from "expo-blur"; import { useAtom } from "jotai"; -import React, { useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Animated, Easing, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -16,7 +17,8 @@ const TVSettingsRow: React.FC<{ onPress?: () => void; isFirst?: boolean; showChevron?: boolean; -}> = ({ label, value, onPress, isFirst, showChevron = true }) => { + disabled?: boolean; +}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -39,7 +41,9 @@ const TVSettingsRow: React.FC<{ setFocused(false); animateTo(1); }} - hasTVPreferredFocus={isFirst} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} > void; isFirst?: boolean; -}> = ({ label, value, onToggle, isFirst }) => { + disabled?: boolean; +}> = ({ label, value, onToggle, isFirst, disabled }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -105,7 +110,9 @@ const TVSettingsToggle: React.FC<{ setFocused(false); animateTo(1); }} - hasTVPreferredFocus={isFirst} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} > void; formatValue?: (value: number) => string; isFirst?: boolean; -}> = ({ label, value, onDecrease, onIncrease, formatValue, isFirst }) => { + disabled?: boolean; +}> = ({ + label, + value, + onDecrease, + onIncrease, + formatValue, + isFirst, + disabled, +}) => { const [focused, setFocused] = useState(false); const [buttonFocused, setButtonFocused] = useState<"minus" | "plus" | null>( null, @@ -200,7 +216,9 @@ const TVSettingsStepper: React.FC<{ setFocused(false); animateTo(scale, 1); }} - hasTVPreferredFocus={isFirst} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} > {label} @@ -217,6 +235,8 @@ const TVSettingsStepper: React.FC<{ setButtonFocused(null); animateTo(minusScale, 1); }} + disabled={disabled} + focusable={!disabled} > = { label: string; - options: { label: string; value: string }[]; - selectedValue: string; - onSelect: (value: string) => void; - isFirst?: boolean; -}> = ({ label, options, selectedValue, onSelect, isFirst }) => { - const [rowFocused, setRowFocused] = useState(false); - - const currentIndex = options.findIndex((o) => o.value === selectedValue); - - return ( - - - {label} - - - {options.map((option, index) => ( - onSelect(option.value)} - onFocus={() => setRowFocused(true)} - onBlur={() => setRowFocused(false)} - isFirst={isFirst && index === currentIndex} - /> - ))} - - - ); + value: T; + selected: boolean; }; -// Individual option button for horizontal selector -const TVSelectorOption: React.FC<{ +// TV Settings Option Button - displays current value and opens bottom sheet +const TVSettingsOptionButton: React.FC<{ label: string; - selected: boolean; - onSelect: () => void; - onFocus: () => void; - onBlur: () => void; + value: string; + onPress: () => void; isFirst?: boolean; -}> = ({ label, selected, onSelect, onFocus, onBlur, isFirst }) => { + disabled?: boolean; +}> = ({ label, value, onPress, isFirst, disabled }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -352,43 +324,220 @@ const TVSelectorOption: React.FC<{ return ( { setFocused(true); - onFocus(); - animateTo(1.08); + animateTo(1.02); }} onBlur={() => { setFocused(false); - onBlur(); animateTo(1); }} - hasTVPreferredFocus={isFirst} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} > + {label} + + + {value} + + + + + + ); +}; + +// TV Settings Bottom Sheet - Apple TV style horizontal scrolling selector +const TVSettingsBottomSheet = ({ + visible, + title, + options, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + options: TVSettingsOptionItem[]; + onSelect: (value: T) => void; + onClose: () => void; +}) => { + const initialSelectedIndex = useMemo(() => { + const idx = options.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [options]); + + if (!visible) return null; + + return ( + + + + {/* Title */} + + {title} + + + {/* Horizontal options */} + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + + ); +}; + +// Option card for horizontal bottom sheet selector (Apple TV style) +const TVSettingsOptionCard: React.FC<{ + label: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, selected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + {label} + {selected && !focused && ( + + + + )} ); @@ -413,7 +562,10 @@ const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( ); // Logout button component -const TVLogoutButton: React.FC<{ onPress: () => void }> = ({ onPress }) => { +const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({ + onPress, + disabled, +}) => { const { t } = useTranslation(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -437,6 +589,8 @@ const TVLogoutButton: React.FC<{ onPress: () => void }> = ({ onPress }) => { setFocused(false); animateTo(1); }} + disabled={disabled} + focusable={!disabled} > void }> = ({ onPress }) => { ); }; +// Modal type for tracking open bottom sheets +type SettingsModalType = + | "audioTranscode" + | "subtitleMode" + | "alignX" + | "alignY" + | null; + export default function SettingsTV() { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -480,212 +642,338 @@ export default function SettingsTV() { const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); + // Modal state for option selectors + const [openModal, setOpenModal] = useState(null); + + const currentAudioTranscode = + settings.audioTranscodeMode || AudioTranscodeMode.Auto; + const currentSubtitleMode = + settings.subtitleMode || SubtitlePlaybackMode.Default; + const currentAlignX = settings.mpvSubtitleAlignX ?? "center"; + const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; + // Audio transcoding options - const audioTranscodeModeOptions = [ - { - label: t("home.settings.audio.transcode_mode.auto"), - value: AudioTranscodeMode.Auto, - }, - { - label: t("home.settings.audio.transcode_mode.stereo"), - value: AudioTranscodeMode.ForceStereo, - }, - { - label: t("home.settings.audio.transcode_mode.5_1"), - value: AudioTranscodeMode.Allow51, - }, - { - label: t("home.settings.audio.transcode_mode.passthrough"), - value: AudioTranscodeMode.AllowAll, - }, - ]; + const audioTranscodeModeOptions = useMemo( + () => [ + { + label: t("home.settings.audio.transcode_mode.auto"), + value: AudioTranscodeMode.Auto, + selected: currentAudioTranscode === AudioTranscodeMode.Auto, + }, + { + label: t("home.settings.audio.transcode_mode.stereo"), + value: AudioTranscodeMode.ForceStereo, + selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo, + }, + { + label: t("home.settings.audio.transcode_mode.5_1"), + value: AudioTranscodeMode.Allow51, + selected: currentAudioTranscode === AudioTranscodeMode.Allow51, + }, + { + label: t("home.settings.audio.transcode_mode.passthrough"), + value: AudioTranscodeMode.AllowAll, + selected: currentAudioTranscode === AudioTranscodeMode.AllowAll, + }, + ], + [t, currentAudioTranscode], + ); // Subtitle mode options - const subtitleModeOptions = [ - { - label: t("home.settings.subtitles.modes.Default"), - value: SubtitlePlaybackMode.Default, - }, - { - label: t("home.settings.subtitles.modes.Smart"), - value: SubtitlePlaybackMode.Smart, - }, - { - label: t("home.settings.subtitles.modes.OnlyForced"), - value: SubtitlePlaybackMode.OnlyForced, - }, - { - label: t("home.settings.subtitles.modes.Always"), - value: SubtitlePlaybackMode.Always, - }, - { - label: t("home.settings.subtitles.modes.None"), - value: SubtitlePlaybackMode.None, - }, - ]; + const subtitleModeOptions = useMemo( + () => [ + { + label: t("home.settings.subtitles.modes.Default"), + value: SubtitlePlaybackMode.Default, + selected: currentSubtitleMode === SubtitlePlaybackMode.Default, + }, + { + label: t("home.settings.subtitles.modes.Smart"), + value: SubtitlePlaybackMode.Smart, + selected: currentSubtitleMode === SubtitlePlaybackMode.Smart, + }, + { + label: t("home.settings.subtitles.modes.OnlyForced"), + value: SubtitlePlaybackMode.OnlyForced, + selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced, + }, + { + label: t("home.settings.subtitles.modes.Always"), + value: SubtitlePlaybackMode.Always, + selected: currentSubtitleMode === SubtitlePlaybackMode.Always, + }, + { + label: t("home.settings.subtitles.modes.None"), + value: SubtitlePlaybackMode.None, + selected: currentSubtitleMode === SubtitlePlaybackMode.None, + }, + ], + [t, currentSubtitleMode], + ); // MPV alignment options - const alignXOptions = [ - { label: "Left", value: "left" }, - { label: "Center", value: "center" }, - { label: "Right", value: "right" }, - ]; + const alignXOptions = useMemo( + () => [ + { label: "Left", value: "left", selected: currentAlignX === "left" }, + { + label: "Center", + value: "center", + selected: currentAlignX === "center", + }, + { label: "Right", value: "right", selected: currentAlignX === "right" }, + ], + [currentAlignX], + ); - const alignYOptions = [ - { label: "Top", value: "top" }, - { label: "Center", value: "center" }, - { label: "Bottom", value: "bottom" }, - ]; + const alignYOptions = useMemo( + () => [ + { label: "Top", value: "top", selected: currentAlignY === "top" }, + { + label: "Center", + value: "center", + selected: currentAlignY === "center", + }, + { + label: "Bottom", + value: "bottom", + selected: currentAlignY === "bottom", + }, + ], + [currentAlignY], + ); + + // Get display labels for option buttons + const audioTranscodeLabel = useMemo(() => { + const option = audioTranscodeModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.audio.transcode_mode.auto"); + }, [audioTranscodeModeOptions, t]); + + const subtitleModeLabel = useMemo(() => { + const option = subtitleModeOptions.find((o) => o.selected); + return option?.label || t("home.settings.subtitles.modes.Default"); + }, [subtitleModeOptions, t]); + + const alignXLabel = useMemo(() => { + const option = alignXOptions.find((o) => o.selected); + return option?.label || "Center"; + }, [alignXOptions]); + + const alignYLabel = useMemo(() => { + const option = alignYOptions.find((o) => o.selected); + return option?.label || "Bottom"; + }, [alignYOptions]); + + const isModalOpen = openModal !== null; return ( - - {/* Header */} - + - {t("home.settings.settings_title")} - + + {/* Header */} + + {t("home.settings.settings_title")} + - {/* Audio Section */} - - + setOpenModal("audioTranscode")} + isFirst + disabled={isModalOpen} + /> + + {/* Subtitles Section */} + + setOpenModal("subtitleMode")} + disabled={isModalOpen} + /> + + updateSettings({ rememberSubtitleSelections: value }) + } + disabled={isModalOpen} + /> + { + const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1); + updateSettings({ subtitleSize: Math.round(newValue * 100) }); + }} + onIncrease={() => { + const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1); + updateSettings({ subtitleSize: Math.round(newValue * 100) }); + }} + formatValue={(v) => `${v.toFixed(1)}x`} + disabled={isModalOpen} + /> + + {/* MPV Subtitles Section */} + + { + const newValue = Math.max( + 0.5, + (settings.mpvSubtitleScale ?? 1.0) - 0.1, + ); + updateSettings({ + mpvSubtitleScale: Math.round(newValue * 10) / 10, + }); + }} + onIncrease={() => { + const newValue = Math.min( + 2.0, + (settings.mpvSubtitleScale ?? 1.0) + 0.1, + ); + updateSettings({ + mpvSubtitleScale: Math.round(newValue * 10) / 10, + }); + }} + formatValue={(v) => `${v.toFixed(1)}x`} + disabled={isModalOpen} + /> + { + const newValue = Math.max( + 0, + (settings.mpvSubtitleMarginY ?? 0) - 5, + ); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + onIncrease={() => { + const newValue = Math.min( + 100, + (settings.mpvSubtitleMarginY ?? 0) + 5, + ); + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + disabled={isModalOpen} + /> + setOpenModal("alignX")} + disabled={isModalOpen} + /> + setOpenModal("alignY")} + disabled={isModalOpen} + /> + + {/* Appearance Section */} + + + updateSettings({ mergeNextUpAndContinueWatching: value }) + } + disabled={isModalOpen} + /> + + {/* User Section */} + + + + + {/* Logout Button */} + + + + + + + {/* Bottom sheet modals */} + updateSettings({ audioTranscodeMode: value as AudioTranscodeMode }) } - isFirst + onClose={() => setOpenModal(null)} /> - {/* Subtitles Section */} - - updateSettings({ subtitleMode: value as SubtitlePlaybackMode }) } - /> - - updateSettings({ rememberSubtitleSelections: value }) - } - /> - { - const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1); - updateSettings({ subtitleSize: Math.round(newValue * 100) }); - }} - onIncrease={() => { - const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1); - updateSettings({ subtitleSize: Math.round(newValue * 100) }); - }} - formatValue={(v) => `${v.toFixed(1)}x`} + onClose={() => setOpenModal(null)} /> - {/* MPV Subtitles Section */} - - { - const newValue = Math.max( - 0.5, - (settings.mpvSubtitleScale ?? 1.0) - 0.1, - ); - updateSettings({ mpvSubtitleScale: Math.round(newValue * 10) / 10 }); - }} - onIncrease={() => { - const newValue = Math.min( - 2.0, - (settings.mpvSubtitleScale ?? 1.0) + 0.1, - ); - updateSettings({ mpvSubtitleScale: Math.round(newValue * 10) / 10 }); - }} - formatValue={(v) => `${v.toFixed(1)}x`} - /> - { - const newValue = Math.max(0, (settings.mpvSubtitleMarginY ?? 0) - 5); - updateSettings({ mpvSubtitleMarginY: newValue }); - }} - onIncrease={() => { - const newValue = Math.min( - 100, - (settings.mpvSubtitleMarginY ?? 0) + 5, - ); - updateSettings({ mpvSubtitleMarginY: newValue }); - }} - /> - updateSettings({ mpvSubtitleAlignX: value as "left" | "center" | "right", }) } + onClose={() => setOpenModal(null)} /> - updateSettings({ mpvSubtitleAlignY: value as "top" | "center" | "bottom", }) } + onClose={() => setOpenModal(null)} /> - - {/* Appearance Section */} - - - updateSettings({ mergeNextUpAndContinueWatching: value }) - } - /> - - {/* User Section */} - - - - - {/* Logout Button */} - - - - + ); } diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index aa65b5dd..a2afce43 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -1,20 +1,204 @@ import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; import type React from "react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Modal, View } from "react-native"; +import { + Alert, + Animated, + Easing, + Modal, + Pressable, + ScrollView, + View, +} from "react-native"; import { useMMKVString } from "react-native-mmkv"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { deleteAccountCredential, getPreviousServers, + removeServerFromList, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; import { TVAccountCard } from "./TVAccountCard"; import { TVServerCard } from "./TVServerCard"; +// Action card for server action sheet (Apple TV style) +const TVServerActionCard: React.FC<{ + label: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: "default" | "destructive"; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isDestructive = variant === "destructive"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + + ); +}; + +// Server action sheet component (bottom sheet with horizontal scrolling) +const TVServerActionSheet: React.FC<{ + visible: boolean; + server: SavedServer | null; + onLogin: () => void; + onDelete: () => void; + onClose: () => void; +}> = ({ visible, server, onLogin, onDelete, onClose }) => { + const { t } = useTranslation(); + + if (!visible || !server) return null; + + return ( + + + + {/* Title */} + + {server.name || server.address} + + + {/* Horizontal options */} + + + + + + + + ); +}; + interface TVPreviousServersListProps { onServerSelect: (server: SavedServer) => void; onQuickLogin?: (serverUrl: string, userId: string) => Promise; @@ -46,6 +230,7 @@ export const TVPreviousServersList: React.FC = ({ null, ); const [showAccountsModal, setShowAccountsModal] = useState(false); + const [showActionSheet, setShowActionSheet] = useState(false); const previousServers = useMemo(() => { return JSON.parse(_previousServers || "[]") as SavedServer[]; @@ -96,19 +281,53 @@ export const TVPreviousServersList: React.FC = ({ const handleServerPress = (server: SavedServer) => { if (loadingServer) return; + setSelectedServer(server); + setShowActionSheet(true); + }; - const accountCount = server.accounts?.length || 0; + const handleServerLoginAction = () => { + if (!selectedServer) return; + setShowActionSheet(false); + + const accountCount = selectedServer.accounts?.length || 0; if (accountCount === 0) { - onServerSelect(server); + onServerSelect(selectedServer); } else if (accountCount === 1) { - handleAccountLogin(server, server.accounts[0]); + handleAccountLogin(selectedServer, selectedServer.accounts[0]); } else { - setSelectedServer(server); setShowAccountsModal(true); } }; + const handleServerDeleteAction = () => { + if (!selectedServer) return; + + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: selectedServer.name || selectedServer.address, + }), + [ + { + text: t("common.cancel"), + style: "cancel", + onPress: () => setShowActionSheet(false), + }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + await removeServerFromList(selectedServer.address); + refreshServers(); + setShowActionSheet(false); + setSelectedServer(null); + }, + }, + ], + ); + }; + const getServerSubtitle = (server: SavedServer): string | undefined => { const accountCount = server.accounts?.length || 0; @@ -279,6 +498,15 @@ export const TVPreviousServersList: React.FC = ({ + + {/* TV Server Action Sheet */} + setShowActionSheet(false)} + /> ); };