From 5221ed1963ca793cc3c8b0aeebcdfce74bdd7ed9 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 7 Jul 2026 00:33:09 +0200 Subject: [PATCH] fix(tv): only defer subtitle selection when it navigates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-first + runAfterInteractions pattern is needed only in the player, where selecting a subtitle can navigate (replacePlayer for a burn-in switch while transcoding). On the item detail page the selection just updates state, so deferring it until after the modal is dismissed re-renders the page after focus returns and yanks TV focus — leaving navigation stuck. Gate the deferral behind deferApplyUntilDismissed (set by the in-player caller only); the detail page runs the selection before closing, preserving focus. --- app/(auth)/tv-subtitle-modal.tsx | 23 ++++++++++++------- .../video-player/controls/Controls.tv.tsx | 3 +++ hooks/useTVSubtitleModal.ts | 2 ++ utils/atoms/tvSubtitleModal.ts | 8 +++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index b29fa644..a5bd27ab 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -646,16 +646,23 @@ export default function TVSubtitleModal() { const handleTrackSelect = useCallback( (option: { setTrack?: () => void }) => { - // Close FIRST, apply after the modal route is dismissed: setTrack can - // trigger replacePlayer (burn-in switches while transcoding), and a - // router.replace fired while this modal is the active route targets the - // MODAL instead of the player screen — the navigation is swallowed and - // the selection silently no-ops (same trap as the setParams note in - // Controls.tv's handleOpenSubtitleSheet). + if (modalState?.deferApplyUntilDismissed) { + // Player: setTrack can navigate (replacePlayer for a burn-in switch + // while transcoding); a router.replace fired while this modal is the + // active route targets the MODAL and is swallowed. Close FIRST, apply + // after dismissal. + handleClose(); + InteractionManager.runAfterInteractions(() => option.setTrack?.()); + return; + } + // Detail page: setTrack only updates state. Run it BEFORE closing so the + // re-render happens while the modal is up; deferring it until after + // dismissal re-renders the detail page after focus returns and yanks TV + // focus, leaving navigation stuck. + option.setTrack?.(); handleClose(); - InteractionManager.runAfterInteractions(() => option.setTrack?.()); }, - [handleClose], + [handleClose, modalState?.deferApplyUntilDismissed], ); const handleDownload = useCallback( diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index e0175104..993c34cc 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -601,6 +601,9 @@ export const Controls: FC = ({ mediaSourceId: mediaSource?.Id, subtitleTracks: tracksWithoutDisable, currentSubtitleIndex: subtitleIndex ?? -1, + // In-player selection can navigate (replacePlayer for burn-in switches); + // apply it after the modal route is dismissed so it isn't swallowed. + deferApplyUntilDismissed: true, onDisableSubtitles: () => { // Find and call the "Disable" track's setTrack from VideoContext const disableTrack = videoContextSubtitleTracks?.find( diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts index 38d44223..76295608 100644 --- a/hooks/useTVSubtitleModal.ts +++ b/hooks/useTVSubtitleModal.ts @@ -14,6 +14,7 @@ interface ShowSubtitleModalParams { onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; refreshSubtitleTracks?: () => Promise; + deferApplyUntilDismissed?: boolean; } export const useTVSubtitleModal = () => { @@ -30,6 +31,7 @@ export const useTVSubtitleModal = () => { onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, refreshSubtitleTracks: params.refreshSubtitleTracks, + deferApplyUntilDismissed: params.deferApplyUntilDismissed, }); router.push("/(auth)/tv-subtitle-modal"); }, diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts index 3a940c12..c73784b2 100644 --- a/utils/atoms/tvSubtitleModal.ts +++ b/utils/atoms/tvSubtitleModal.ts @@ -11,6 +11,14 @@ export type TVSubtitleModalState = { onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; refreshSubtitleTracks?: () => Promise; + /** + * Run the selection callback AFTER the modal route is dismissed. Needed when + * `setTrack` navigates (the player's replacePlayer for a burn-in switch), + * which would be swallowed by the still-active modal route. Leave false for + * callers whose selection only updates state (the item detail page), so the + * update runs before dismissal and doesn't yank focus after it returns. + */ + deferApplyUntilDismissed?: boolean; } | null; export const tvSubtitleModalAtom = atom(null);