From 15e4c18d5462ac1c1e7f467c8bd4dffb30536d5a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:42:53 +0100 Subject: [PATCH] fix(tvos): settings --- app/(auth)/(tabs)/(home)/settings.tsx | 17 +- app/(auth)/(tabs)/(home)/settings.tv.tsx | 719 +++++++++++++++++++++++ app/(auth)/(tabs)/(settings)/_layout.tsx | 21 + app/(auth)/(tabs)/(settings)/index.tsx | 5 + 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings.tv.tsx create mode 100644 app/(auth)/(tabs)/(settings)/_layout.tsx create mode 100644 app/(auth)/(tabs)/(settings)/index.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 76675ae8..1ed36fe2 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -14,7 +14,11 @@ import { UserInfo } from "@/components/settings/UserInfo"; import useRouter from "@/hooks/useAppRouter"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -export default function settings() { +// TV-specific settings component +const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; + +// Mobile settings component +function SettingsMobile() { const router = useRouter(); const insets = useSafeAreaInsets(); const [_user] = useAtom(userAtom); @@ -104,8 +108,17 @@ export default function settings() { - {!Platform.isTV && } + ); } + +export default function settings() { + // Use TV settings component on TV platforms + if (Platform.isTV && SettingsTV) { + return ; + } + + return ; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx new file mode 100644 index 00000000..24a08648 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -0,0 +1,719 @@ +import { Ionicons } from "@expo/vector-icons"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useAtom } from "jotai"; +import React, { 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"; +import { Text } from "@/components/common/Text"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; + +// TV-optimized focusable row component +const TVSettingsRow: React.FC<{ + label: string; + value: string; + onPress?: () => void; + isFirst?: boolean; + showChevron?: boolean; +}> = ({ label, value, onPress, isFirst, showChevron = true }) => { + 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.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + {value} + + {showChevron && ( + + )} + + + + ); +}; + +// TV-optimized toggle row component +const TVSettingsToggle: React.FC<{ + label: string; + value: boolean; + onToggle: (value: boolean) => void; + isFirst?: boolean; +}> = ({ label, value, onToggle, isFirst }) => { + 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 ( + onToggle(!value)} + onFocus={() => { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + + + + ); +}; + +// TV-optimized stepper row component +const TVSettingsStepper: React.FC<{ + label: string; + value: number; + onDecrease: () => void; + onIncrease: () => void; + formatValue?: (value: number) => string; + isFirst?: boolean; +}> = ({ label, value, onDecrease, onIncrease, formatValue, isFirst }) => { + const [focused, setFocused] = useState(false); + const [buttonFocused, setButtonFocused] = useState<"minus" | "plus" | null>( + null, + ); + const scale = useRef(new Animated.Value(1)).current; + const minusScale = useRef(new Animated.Value(1)).current; + const plusScale = useRef(new Animated.Value(1)).current; + + const animateTo = (ref: Animated.Value, v: number) => + Animated.timing(ref, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const displayValue = formatValue ? formatValue(value) : String(value); + + return ( + + { + setFocused(true); + animateTo(scale, 1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(scale, 1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + + { + setButtonFocused("minus"); + animateTo(minusScale, 1.1); + }} + onBlur={() => { + setButtonFocused(null); + animateTo(minusScale, 1); + }} + > + + + + + + {displayValue} + + { + setButtonFocused("plus"); + animateTo(plusScale, 1.1); + }} + onBlur={() => { + setButtonFocused(null); + animateTo(plusScale, 1); + }} + > + + + + + + + ); +}; + +// TV-optimized dropdown selector +const TVSettingsDropdown: React.FC<{ + label: string; + options: { label: string; value: string }[]; + selectedValue: string; + onSelect: (value: string) => void; + isFirst?: boolean; +}> = ({ label, options, selectedValue, onSelect, isFirst }) => { + const [expanded, setExpanded] = useState(false); + 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 selectedLabel = + options.find((o) => o.value === selectedValue)?.label || selectedValue; + + if (expanded) { + return ( + + + {label} + + {options.map((option, index) => ( + { + onSelect(option.value); + setExpanded(false); + }} + isFirst={index === 0} + /> + ))} + + ); + } + + return ( + setExpanded(true)} + onFocus={() => { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + + + {selectedLabel} + + + + + + ); +}; + +// Dropdown option component +const TVDropdownOption: React.FC<{ + label: string; + selected: boolean; + onSelect: () => void; + isFirst?: boolean; +}> = ({ label, selected, onSelect, isFirst }) => { + 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.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={isFirst} + > + + {label} + {selected && } + + + ); +}; + +// Section header component +const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( + + {title} + +); + +// Logout button component +const TVLogoutButton: React.FC<{ onPress: () => void }> = ({ onPress }) => { + const { t } = useTranslation(); + 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); + }} + > + + + + {t("home.settings.log_out_button")} + + + + + ); +}; + +export default function SettingsTV() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { settings, updateSettings } = useSettings(); + const { logout } = useJellyfin(); + const [user] = useAtom(userAtom); + const [api] = useAtom(apiAtom); + + // 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, + }, + ]; + + // 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, + }, + ]; + + // MPV alignment options + const alignXOptions = [ + { label: "Left", value: "left" }, + { label: "Center", value: "center" }, + { label: "Right", value: "right" }, + ]; + + const alignYOptions = [ + { label: "Top", value: "top" }, + { label: "Center", value: "center" }, + { label: "Bottom", value: "bottom" }, + ]; + + return ( + + {/* Header */} + + {t("home.settings.settings_title")} + + + {/* Audio Section */} + + + updateSettings({ audioTranscodeMode: value as AudioTranscodeMode }) + } + isFirst + /> + + {/* 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`} + /> + + {/* 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", + }) + } + /> + + updateSettings({ + mpvSubtitleAlignY: value as "top" | "center" | "bottom", + }) + } + /> + + {/* Appearance Section */} + + + updateSettings({ mergeNextUpAndContinueWatching: value }) + } + /> + + {/* User Section */} + + + + + {/* Logout Button */} + + + + + ); +} diff --git a/app/(auth)/(tabs)/(settings)/_layout.tsx b/app/(auth)/(tabs)/(settings)/_layout.tsx new file mode 100644 index 00000000..4f1ce035 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,21 @@ +import { Stack } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Platform } from "react-native"; + +export default function SettingsLayout() { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/app/(auth)/(tabs)/(settings)/index.tsx b/app/(auth)/(tabs)/(settings)/index.tsx new file mode 100644 index 00000000..52b86fb4 --- /dev/null +++ b/app/(auth)/(tabs)/(settings)/index.tsx @@ -0,0 +1,5 @@ +import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv"; + +export default function SettingsTabScreen() { + return ; +}