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",