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 ;
+}