From 03ae18e3c06343cc79563a807a0bcb24eb153fe0 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 7 Jul 2026 00:36:31 +0200 Subject: [PATCH] fix(tv): only defer option-modal selection when it navigates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-first + runAfterInteractions change (needed so the in-player audio switch's replacePlayer isn't swallowed by the modal route) also runs for every other tv-option-modal caller — detail-page audio, library filters, settings — whose onSelect only updates state. Deferring those until after dismissal re-renders the page after focus returns and yanks TV focus, leaving navigation stuck. Gate the deferral behind deferApplyUntilDismissed (set only by the in-player audio caller); everyone else applies before closing, as before. --- app/(auth)/tv-option-modal.tsx | 20 ++++++++++++++----- .../video-player/controls/Controls.tv.tsx | 3 +++ hooks/useTVOptionModal.ts | 2 ++ utils/atoms/tvOptionModal.ts | 9 +++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index b4c66b33..442a29ec 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -76,13 +76,23 @@ export default function TVOptionModal() { }, [isReady]); const handleSelect = (value: any) => { - // Close FIRST, run the callback after this modal route is dismissed: an - // onSelect that navigates (e.g. the transcode audio switch replacing the - // player) would otherwise target the MODAL route and be swallowed. - const onSelect = modalState?.onSelect; + if (modalState?.deferApplyUntilDismissed) { + // onSelect navigates (the transcode audio switch replacing the player); + // a router.replace fired while this modal is the active route would be + // swallowed. Close FIRST, apply after dismissal. + const onSelect = modalState.onSelect; + store.set(tvOptionModalAtom, null); + router.back(); + InteractionManager.runAfterInteractions(() => onSelect?.(value)); + return; + } + // State-only callers (detail page, library filters, settings): run before + // closing so the re-render happens while the modal is up. Deferring it until + // after dismissal re-renders the page after focus returns and yanks TV + // focus, leaving navigation stuck. + modalState?.onSelect(value); store.set(tvOptionModalAtom, null); router.back(); - InteractionManager.runAfterInteractions(() => onSelect?.(value)); }; const handleClose = useCallback(() => { diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index a85f6215..29e5d1f0 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -564,6 +564,9 @@ export const Controls: FC = ({ title: t("item_card.audio"), options: audioOptions, onSelect: handleAudioChange, + // In-player audio selection navigates (replacePlayer while transcoding); + // apply it after the modal is dismissed so it isn't swallowed. + deferApplyUntilDismissed: true, }); controlsInteractionRef.current(); }, [showOptions, t, audioOptions, handleAudioChange]); diff --git a/hooks/useTVOptionModal.ts b/hooks/useTVOptionModal.ts index c6acffe8..68db4de8 100644 --- a/hooks/useTVOptionModal.ts +++ b/hooks/useTVOptionModal.ts @@ -12,6 +12,7 @@ interface ShowOptionsParams { onSelect: (value: T) => void; cardWidth?: number; cardHeight?: number; + deferApplyUntilDismissed?: boolean; } export const useTVOptionModal = () => { @@ -26,6 +27,7 @@ export const useTVOptionModal = () => { onSelect: params.onSelect, cardWidth: params.cardWidth, cardHeight: params.cardHeight, + deferApplyUntilDismissed: params.deferApplyUntilDismissed, }); router.push("/(auth)/tv-option-modal"); }, diff --git a/utils/atoms/tvOptionModal.ts b/utils/atoms/tvOptionModal.ts index 74bc2ab5..c6fe230b 100644 --- a/utils/atoms/tvOptionModal.ts +++ b/utils/atoms/tvOptionModal.ts @@ -13,6 +13,15 @@ export type TVOptionModalState = { onSelect: (value: any) => void; cardWidth?: number; cardHeight?: number; + /** + * Run onSelect AFTER the modal route is dismissed. Needed only when onSelect + * navigates (the in-player audio switch replacing the player while + * transcoding), which the still-active modal route would otherwise swallow. + * Default (false) runs onSelect before closing, so state-only callers (detail + * page, library filters, settings) don't re-render after focus returns and + * lose TV focus. + */ + deferApplyUntilDismissed?: boolean; } | null; export const tvOptionModalAtom = atom(null);