mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-14 00:02:24 +00:00
feat(tv): improve settings focus management with disabled props pattern
This commit is contained in:
32
CLAUDE.md
32
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<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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user