diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 877dd163..515e39d1 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -893,6 +893,27 @@ export default function DirectPlayerPage() { // Check if we're transcoding const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl); + // A transcoded stream only carries the audio track the server encoded + // into it — switching requires re-negotiating the stream with the new + // index (like the mobile menu's replacePlayer), not an mpv aid change. + if (isTranscoding) { + const queryParams = new URLSearchParams({ + itemId: item?.Id ?? "", + audioIndex: String(index), + subtitleIndex: String(currentSubtitleIndex), + mediaSourceId: stream?.mediaSource?.Id ?? "", + bitrateValue: bitrateValue?.toString() ?? "", + playbackPosition: msToTicks(progress.get()).toString(), + }).toString(); + // Destroy the current mpv instance BEFORE navigating, same rationale as + // goToNextItem/goToPreviousItem: Expo Router briefly holds two players + // during the transition, and two simultaneous decoders OOM-kill low-RAM + // devices. Resume is preserved via the playbackPosition param. + videoRef.current?.destroy().catch(() => {}); + router.replace(`player/direct-player?${queryParams}` as any); + return; + } + // Convert Jellyfin index to MPV track ID const mpvTrackId = getMpvAudioId( stream?.mediaSource, @@ -904,7 +925,14 @@ export default function DirectPlayerPage() { await videoRef.current?.setAudioTrack?.(mpvTrackId); } }, - [stream?.mediaSource], + [ + stream?.mediaSource, + item?.Id, + currentSubtitleIndex, + bitrateValue, + router, + progress, + ], ); // TV subtitle track change handler diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index 180228e3..442a29ec 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Animated, Easing, + InteractionManager, ScrollView, StyleSheet, TVFocusGuideView, @@ -75,6 +76,20 @@ export default function TVOptionModal() { }, [isReady]); const handleSelect = (value: any) => { + 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(); 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);