fix(tv): only defer subtitle selection when it navigates

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.
This commit is contained in:
Gauvain
2026-07-07 00:33:09 +02:00
parent 461d055c7a
commit 5221ed1963
4 changed files with 28 additions and 8 deletions

View File

@@ -646,16 +646,23 @@ export default function TVSubtitleModal() {
const handleTrackSelect = useCallback( const handleTrackSelect = useCallback(
(option: { setTrack?: () => void }) => { (option: { setTrack?: () => void }) => {
// Close FIRST, apply after the modal route is dismissed: setTrack can if (modalState?.deferApplyUntilDismissed) {
// trigger replacePlayer (burn-in switches while transcoding), and a // Player: setTrack can navigate (replacePlayer for a burn-in switch
// router.replace fired while this modal is the active route targets the // while transcoding); a router.replace fired while this modal is the
// MODAL instead of the player screen — the navigation is swallowed and // active route targets the MODAL and is swallowed. Close FIRST, apply
// the selection silently no-ops (same trap as the setParams note in // after dismissal.
// Controls.tv's handleOpenSubtitleSheet). 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(); handleClose();
InteractionManager.runAfterInteractions(() => option.setTrack?.());
}, },
[handleClose], [handleClose, modalState?.deferApplyUntilDismissed],
); );
const handleDownload = useCallback( const handleDownload = useCallback(

View File

@@ -601,6 +601,9 @@ export const Controls: FC<Props> = ({
mediaSourceId: mediaSource?.Id, mediaSourceId: mediaSource?.Id,
subtitleTracks: tracksWithoutDisable, subtitleTracks: tracksWithoutDisable,
currentSubtitleIndex: subtitleIndex ?? -1, 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: () => { onDisableSubtitles: () => {
// Find and call the "Disable" track's setTrack from VideoContext // Find and call the "Disable" track's setTrack from VideoContext
const disableTrack = videoContextSubtitleTracks?.find( const disableTrack = videoContextSubtitleTracks?.find(

View File

@@ -14,6 +14,7 @@ interface ShowSubtitleModalParams {
onServerSubtitleDownloaded?: () => void; onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void; onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>; refreshSubtitleTracks?: () => Promise<Track[]>;
deferApplyUntilDismissed?: boolean;
} }
export const useTVSubtitleModal = () => { export const useTVSubtitleModal = () => {
@@ -30,6 +31,7 @@ export const useTVSubtitleModal = () => {
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
refreshSubtitleTracks: params.refreshSubtitleTracks, refreshSubtitleTracks: params.refreshSubtitleTracks,
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
}); });
router.push("/(auth)/tv-subtitle-modal"); router.push("/(auth)/tv-subtitle-modal");
}, },

View File

@@ -11,6 +11,14 @@ export type TVSubtitleModalState = {
onServerSubtitleDownloaded?: () => void; onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void; onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>; refreshSubtitleTracks?: () => Promise<Track[]>;
/**
* 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; } | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null); export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);