feat(tv): improve settings focus management with disabled props pattern

This commit is contained in:
Fredrik Burmester
2026-01-16 13:17:12 +01:00
parent b85549016d
commit fff7d4459f
3 changed files with 810 additions and 262 deletions

View File

@@ -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<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.

View File

@@ -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}
>
<Animated.View
style={{
@@ -82,7 +86,8 @@ const TVSettingsToggle: React.FC<{
value: boolean;
onToggle: (value: boolean) => 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}
>
<Animated.View
style={{
@@ -156,7 +163,16 @@ const TVSettingsStepper: React.FC<{
onIncrease: () => 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}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
@@ -217,6 +235,8 @@ const TVSettingsStepper: React.FC<{
setButtonFocused(null);
animateTo(minusScale, 1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
@@ -254,6 +274,8 @@ const TVSettingsStepper: React.FC<{
setButtonFocused(null);
animateTo(plusScale, 1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
@@ -274,71 +296,21 @@ const TVSettingsStepper: React.FC<{
);
};
// TV-optimized horizontal selector - navigate left/right through options
const TVSettingsSelector: React.FC<{
// Option item type for bottom sheet selector
type TVSettingsOptionItem<T> = {
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 (
<View
style={{
backgroundColor: rowFocused
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
}}
>
<Text
style={{
fontSize: 20,
color: "#FFFFFF",
marginBottom: 12,
}}
>
{label}
</Text>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
}}
>
{options.map((option, index) => (
<TVSelectorOption
key={option.value}
label={option.label}
selected={option.value === selectedValue}
onSelect={() => onSelect(option.value)}
onFocus={() => setRowFocused(true)}
onBlur={() => setRowFocused(false)}
isFirst={isFirst && index === currentIndex}
/>
))}
</View>
</View>
);
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 (
<Pressable
onPress={onSelect}
onPress={onPress}
onFocus={() => {
setFocused(true);
onFocus();
animateTo(1.08);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
onBlur();
animateTo(1);
}}
hasTVPreferredFocus={isFirst}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
backgroundColor: selected
? "#7c3aed"
: focused
? "rgba(124, 58, 237, 0.4)"
: "rgba(255, 255, 255, 0.1)",
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
borderWidth: focused ? 2 : 0,
borderColor: "#FFFFFF",
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: 12,
}}
>
{value}
</Text>
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
</View>
</Animated.View>
</Pressable>
);
};
// TV Settings Bottom Sheet - Apple TV style horizontal scrolling selector
const TVSettingsBottomSheet = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVSettingsOptionItem<T>[];
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 (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{title}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVSettingsOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
// 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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: "#FFFFFF",
fontWeight: selected ? "600" : "400",
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
@@ -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}
>
<Animated.View
style={{
@@ -472,6 +626,14 @@ const TVLogoutButton: React.FC<{ onPress: () => 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<SettingsModalType>(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 (
<ScrollView
style={{ flex: 1, backgroundColor: "#000000" }}
contentContainerStyle={{
paddingTop: insets.top + 120,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Text
style={{
fontSize: 42,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View
style={{ flex: 1, opacity: isModalOpen ? 0.3 : 1 }}
focusable={!isModalOpen}
isTVSelectable={!isModalOpen}
pointerEvents={isModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isModalOpen}
importantForAccessibility={isModalOpen ? "no-hide-descendants" : "auto"}
>
{t("home.settings.settings_title")}
</Text>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 120,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Text
style={{
fontSize: 42,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("home.settings.settings_title")}
</Text>
{/* Audio Section */}
<SectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsSelector
label={t("home.settings.audio.transcode_mode.title")}
{/* Audio Section */}
<SectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
onPress={() => setOpenModal("audioTranscode")}
isFirst
disabled={isModalOpen}
/>
{/* Subtitles Section */}
<SectionHeader title={t("home.settings.subtitles.subtitle_title")} />
<TVSettingsOptionButton
label={t("home.settings.subtitles.subtitle_mode")}
value={subtitleModeLabel}
onPress={() => setOpenModal("subtitleMode")}
disabled={isModalOpen}
/>
<TVSettingsToggle
label={t("home.settings.subtitles.set_subtitle_track")}
value={settings.rememberSubtitleSelections}
onToggle={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
disabled={isModalOpen}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
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 */}
<SectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
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}
/>
<TVSettingsStepper
label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
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}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() => setOpenModal("alignX")}
disabled={isModalOpen}
/>
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
onPress={() => setOpenModal("alignY")}
disabled={isModalOpen}
/>
{/* Appearance Section */}
<SectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
)}
value={settings.mergeNextUpAndContinueWatching}
onToggle={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
disabled={isModalOpen}
/>
{/* User Section */}
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}
showChevron={false}
disabled={isModalOpen}
/>
<TVSettingsRow
label={t("home.settings.user_info.server")}
value={api?.basePath || "-"}
showChevron={false}
disabled={isModalOpen}
/>
{/* Logout Button */}
<View style={{ marginTop: 48, alignItems: "center" }}>
<TVLogoutButton onPress={logout} disabled={isModalOpen} />
</View>
</ScrollView>
</View>
{/* Bottom sheet modals */}
<TVSettingsBottomSheet
visible={openModal === "audioTranscode"}
title={t("home.settings.audio.transcode_mode.title")}
options={audioTranscodeModeOptions}
selectedValue={settings.audioTranscodeMode || AudioTranscodeMode.Auto}
onSelect={(value) =>
updateSettings({ audioTranscodeMode: value as AudioTranscodeMode })
}
isFirst
onClose={() => setOpenModal(null)}
/>
{/* Subtitles Section */}
<SectionHeader title={t("home.settings.subtitles.subtitle_title")} />
<TVSettingsSelector
label={t("home.settings.subtitles.subtitle_mode")}
<TVSettingsBottomSheet
visible={openModal === "subtitleMode"}
title={t("home.settings.subtitles.subtitle_mode")}
options={subtitleModeOptions}
selectedValue={settings.subtitleMode || SubtitlePlaybackMode.Default}
onSelect={(value) =>
updateSettings({ subtitleMode: value as SubtitlePlaybackMode })
}
/>
<TVSettingsToggle
label={t("home.settings.subtitles.set_subtitle_track")}
value={settings.rememberSubtitleSelections}
onToggle={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
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 */}
<SectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
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`}
/>
<TVSettingsStepper
label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
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 });
}}
/>
<TVSettingsSelector
label='Horizontal Alignment'
<TVSettingsBottomSheet
visible={openModal === "alignX"}
title='Horizontal Alignment'
options={alignXOptions}
selectedValue={settings.mpvSubtitleAlignX ?? "center"}
onSelect={(value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
})
}
onClose={() => setOpenModal(null)}
/>
<TVSettingsSelector
label='Vertical Alignment'
<TVSettingsBottomSheet
visible={openModal === "alignY"}
title='Vertical Alignment'
options={alignYOptions}
selectedValue={settings.mpvSubtitleAlignY ?? "bottom"}
onSelect={(value) =>
updateSettings({
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
})
}
onClose={() => setOpenModal(null)}
/>
{/* Appearance Section */}
<SectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsToggle
label={t("home.settings.appearance.merge_next_up_continue_watching")}
value={settings.mergeNextUpAndContinueWatching}
onToggle={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
{/* User Section */}
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}
showChevron={false}
/>
<TVSettingsRow
label={t("home.settings.user_info.server")}
value={api?.basePath || "-"}
showChevron={false}
/>
{/* Logout Button */}
<View style={{ marginTop: 48, alignItems: "center" }}>
<TVLogoutButton onPress={logout} />
</View>
</ScrollView>
</View>
);
}

View File

@@ -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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 90,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
gap: 8,
}}
>
<Ionicons
name={icon}
size={28}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: 16,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// 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 (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{server.name || server.address}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={onLogin}
/>
<TVServerActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={onDelete}
/>
</ScrollView>
</View>
</BlurView>
</View>
);
};
interface TVPreviousServersListProps {
onServerSelect: (server: SavedServer) => void;
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
@@ -46,6 +230,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
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<TVPreviousServersListProps> = ({
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<TVPreviousServersListProps> = ({
</View>
</View>
</Modal>
{/* TV Server Action Sheet */}
<TVServerActionSheet
visible={showActionSheet}
server={selectedServer}
onLogin={handleServerLoginAction}
onDelete={handleServerDeleteAction}
onClose={() => setShowActionSheet(false)}
/>
</View>
);
};