feat(tv): add subtitle settings to subtitle modal

Add a new "Settings" tab to the TV subtitle modal with controls for:
- Subtitle Scale (0.5x to 2.0x)
- Vertical Margin (-100 to +100)
- Horizontal Alignment (left, center, right)
- Vertical Alignment (top, center, bottom)

All settings use mpvSubtitle* settings for direct MPV control.
Includes English translations for all new settings.
This commit is contained in:
Fredrik Burmester
2026-01-18 20:13:56 +01:00
parent f8d1fad6d5
commit 749473c1e8
4 changed files with 366 additions and 9 deletions

View File

@@ -956,6 +956,11 @@ export default function page() {
if (!refetchStreamRef.current) return [];
const newStream = await refetchStreamRef.current();
// Check if component is still mounted before updating state
// This callback may be invoked from a modal after the player unmounts
if (!isMounted) return [];
if (newStream) {
setStream(newStream);
return (
@@ -965,7 +970,7 @@ export default function page() {
);
}
return [];
}, []);
}, [isMounted]);
// TV: Navigate to next item
const goToNextItem = useCallback(() => {

View File

@@ -31,7 +31,7 @@ export default function TVOptionModal() {
return idx >= 0 ? idx : 0;
}, [modalState?.options]);
// Animate in on mount
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
@@ -53,7 +53,11 @@ export default function TVOptionModal() {
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvOptionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready

View File

@@ -26,11 +26,12 @@ import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
import { store } from "@/utils/store";
type TabType = "tracks" | "download";
type TabType = "tracks" | "download" | "settings";
// Track card for subtitle track selection
const TVTrackCard = React.forwardRef<
@@ -353,10 +354,161 @@ const SubtitleResultCard = React.forwardRef<
);
});
// Stepper button for subtitle size control
const TVStepperButton: React.FC<{
icon: "remove" | "add";
onPress: () => void;
disabled?: boolean;
hasTVPreferredFocus?: boolean;
}> = ({ icon, onPress, disabled, hasTVPreferredFocus }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.1 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.stepperButton,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: disabled
? "rgba(255,255,255,0.05)"
: "rgba(255,255,255,0.12)",
opacity: disabled ? 0.4 : 1,
},
]}
>
<Ionicons
name={icon}
size={28}
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
/>
</Animated.View>
</Pressable>
);
};
// Generic stepper control component
const TVStepperControl: React.FC<{
value: number;
min: number;
max: number;
step: number;
formatValue: (value: number) => string;
onChange: (newValue: number) => void;
hasTVPreferredFocus?: boolean;
}> = ({
value,
min,
max,
step,
formatValue,
onChange,
hasTVPreferredFocus,
}) => {
const canDecrease = value > min;
const canIncrease = value < max;
const handleDecrease = () => {
if (canDecrease) {
const newValue = Math.max(min, Math.round((value - step) * 10) / 10);
onChange(newValue);
}
};
const handleIncrease = () => {
if (canIncrease) {
const newValue = Math.min(max, Math.round((value + step) * 10) / 10);
onChange(newValue);
}
};
return (
<View style={styles.sizeControlContainer}>
<TVStepperButton
icon='remove'
onPress={handleDecrease}
disabled={!canDecrease}
hasTVPreferredFocus={hasTVPreferredFocus}
/>
<View style={styles.sizeValueContainer}>
<Text style={styles.sizeValueText}>{formatValue(value)}</Text>
</View>
<TVStepperButton
icon='add'
onPress={handleIncrease}
disabled={!canIncrease}
/>
</View>
);
};
// Alignment option card
const TVAlignmentCard: React.FC<{
label: string;
selected: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ label, selected, onPress, hasTVPreferredFocus }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.alignmentCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.alignmentCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
>
{label}
</Text>
{selected && !focused && (
<View style={styles.alignmentCheckmark}>
<Ionicons
name='checkmark'
size={14}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
export default function TVSubtitleModal() {
const router = useRouter();
const { t } = useTranslation();
const modalState = useAtomValue(tvSubtitleModalAtom);
const { settings, updateSettings } = useSettings();
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
@@ -397,8 +549,12 @@ export default function TVSubtitleModal() {
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
// Animate in on mount
// Track if component is mounted for async operations
const isMountedRef = useRef(true);
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
isMountedRef.current = true;
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
@@ -418,7 +574,12 @@ export default function TVSubtitleModal() {
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
return () => {
clearTimeout(timer);
isMountedRef.current = false;
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvSubtitleModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
useEffect(() => {
@@ -465,13 +626,23 @@ export default function TVSubtitleModal() {
try {
const downloadResult = await downloadAsync(result);
// Check if component is still mounted after async operation
if (!isMountedRef.current) return;
if (downloadResult.type === "server") {
// Give Jellyfin time to process the downloaded subtitle
await new Promise((resolve) => setTimeout(resolve, 5000));
// Check if component is still mounted after the wait
if (!isMountedRef.current) return;
// Refresh tracks and stay open for server-side downloads
if (modalState?.refreshSubtitleTracks) {
const newTracks = await modalState.refreshSubtitleTracks();
// Check if component is still mounted after fetching tracks
if (!isMountedRef.current) return;
// Update atom with new tracks
store.set(tvSubtitleModalAtom, {
...modalState,
@@ -493,7 +664,9 @@ export default function TVSubtitleModal() {
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {
setDownloadingId(null);
if (isMountedRef.current) {
setDownloadingId(null);
}
}
},
[downloadAsync, modalState, handleClose],
@@ -560,6 +733,11 @@ export default function TVSubtitleModal() {
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
<TVTabButton
label={t("player.settings") || "Settings"}
active={activeTab === "settings"}
onSelect={() => setActiveTab("settings")}
/>
</View>
</View>
@@ -712,6 +890,104 @@ export default function TVSubtitleModal() {
)}
</>
)}
{/* Settings Tab Content */}
{activeTab === "settings" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
showsVerticalScrollIndicator={false}
style={styles.settingsScroll}
contentContainerStyle={styles.settingsScrollContent}
>
{/* Subtitle Scale */}
<View style={styles.settingRow}>
<TVStepperControl
value={settings.mpvSubtitleScale ?? 1.0}
min={0.5}
max={2.0}
step={0.1}
formatValue={(v) => `${v.toFixed(1)}x`}
onChange={(newValue) => {
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
hasTVPreferredFocus={true}
/>
<Text style={styles.settingLabel}>
{t("home.settings.subtitles.mpv_subtitle_scale") ||
"Subtitle Scale"}
</Text>
</View>
{/* Vertical Margin */}
<View style={styles.settingRow}>
<TVStepperControl
value={settings.mpvSubtitleMarginY ?? 0}
min={-100}
max={100}
step={5}
formatValue={(v) => `${v}`}
onChange={(newValue) => {
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<Text style={styles.settingLabel}>
{t("home.settings.subtitles.mpv_subtitle_margin_y") ||
"Vertical Margin"}
</Text>
</View>
{/* Horizontal Alignment */}
<View style={styles.settingRow}>
<View style={styles.alignmentRow}>
{(["left", "center", "right"] as const).map((align) => (
<TVAlignmentCard
key={align}
label={
t(`home.settings.subtitles.align.${align}`) || align
}
selected={
(settings.mpvSubtitleAlignX ?? "center") === align
}
onPress={() =>
updateSettings({ mpvSubtitleAlignX: align })
}
/>
))}
</View>
<Text style={styles.settingLabel}>
{t("home.settings.subtitles.mpv_subtitle_align_x") ||
"Horizontal Align"}
</Text>
</View>
{/* Vertical Alignment */}
<View style={styles.settingRow}>
<View style={styles.alignmentRow}>
{(["top", "center", "bottom"] as const).map((align) => (
<TVAlignmentCard
key={align}
label={
t(`home.settings.subtitles.align.${align}`) || align
}
selected={
(settings.mpvSubtitleAlignY ?? "bottom") === align
}
onPress={() =>
updateSettings({ mpvSubtitleAlignY: align })
}
/>
))}
</View>
<Text style={styles.settingLabel}>
{t("home.settings.subtitles.mpv_subtitle_align_y") ||
"Vertical Align"}
</Text>
</View>
</ScrollView>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
@@ -933,4 +1209,64 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.4)",
fontSize: 12,
},
// Settings tab styles
settingsScroll: {
maxHeight: 300,
},
settingsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 24,
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
settingLabel: {
fontSize: 18,
fontWeight: "500",
color: "#fff",
},
sizeControlContainer: {
flexDirection: "row",
alignItems: "center",
gap: 16,
},
stepperButton: {
width: 56,
height: 56,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
sizeValueContainer: {
width: 80,
alignItems: "center",
},
sizeValueText: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
},
alignmentRow: {
flexDirection: "row",
gap: 10,
},
alignmentCard: {
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 12,
minWidth: 90,
alignItems: "center",
},
alignmentCardText: {
fontSize: 15,
textTransform: "capitalize",
},
alignmentCheckmark: {
position: "absolute",
top: 6,
right: 6,
},
});

View File

@@ -265,7 +265,18 @@
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers"
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -638,7 +649,8 @@
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback"
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings"
},
"item_card": {
"next_up": "Next Up",