diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index bd01aa60..56ff0754 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(() => { diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index 5ee980c0..a21c5e32 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -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 diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 27b3fe3c..8bc0cc07 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -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 ( + + + + + + ); +}; + +// 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 ( + + + + {formatValue(value)} + + + + ); +}; + +// 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 ( + + + + {label} + + {selected && !focused && ( + + + + )} + + + ); +}; + export default function TVSubtitleModal() { const router = useRouter(); const { t } = useTranslation(); const modalState = useAtomValue(tvSubtitleModalAtom); + const { settings, updateSettings } = useSettings(); const [activeTab, setActiveTab] = useState("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")} /> + setActiveTab("settings")} + /> @@ -712,6 +890,104 @@ export default function TVSubtitleModal() { )} )} + + {/* Settings Tab Content */} + {activeTab === "settings" && isTabContentReady && ( + + + {/* Subtitle Scale */} + + `${v.toFixed(1)}x`} + onChange={(newValue) => { + updateSettings({ + mpvSubtitleScale: Math.round(newValue * 10) / 10, + }); + }} + hasTVPreferredFocus={true} + /> + + {t("home.settings.subtitles.mpv_subtitle_scale") || + "Subtitle Scale"} + + + + {/* Vertical Margin */} + + `${v}`} + onChange={(newValue) => { + updateSettings({ mpvSubtitleMarginY: newValue }); + }} + /> + + {t("home.settings.subtitles.mpv_subtitle_margin_y") || + "Vertical Margin"} + + + + {/* Horizontal Alignment */} + + + {(["left", "center", "right"] as const).map((align) => ( + + updateSettings({ mpvSubtitleAlignX: align }) + } + /> + ))} + + + {t("home.settings.subtitles.mpv_subtitle_align_x") || + "Horizontal Align"} + + + + {/* Vertical Alignment */} + + + {(["top", "center", "bottom"] as const).map((align) => ( + + updateSettings({ mpvSubtitleAlignY: align }) + } + /> + ))} + + + {t("home.settings.subtitles.mpv_subtitle_align_y") || + "Vertical Align"} + + + + + )} @@ -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, + }, }); diff --git a/translations/en.json b/translations/en.json index bf549255..0cb51727 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",