Redo navigation for sync-play support

This commit is contained in:
Alex Kim
2026-06-05 23:13:52 +10:00
parent ab42e8a576
commit 2df63eb63c
8 changed files with 572 additions and 350 deletions

View File

@@ -35,6 +35,7 @@ import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { import {
@@ -54,7 +55,6 @@ import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types"; import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -1144,44 +1144,6 @@ export default function DirectPlayerPage() {
} }
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles) // TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => { const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true); await videoRef.current?.addSubtitleFile?.(path, true);
@@ -1211,45 +1173,25 @@ export default function DirectPlayerPage() {
return []; return [];
}, [isMounted]); }, [isMounted]);
// TV: Navigate to next item /*
const goToNextItem = useCallback(() => { * Item-level navigation (next / previous). Wraps SyncPlay dispatch,
if (!nextItem || !settings || isPlaybackStopped) return; * platform-appropriate local navigation (replace on TV), and offline
* param injection in a single hook so the in-player buttons and any
const { * future entry points (autoplay overlay, episode picker, etc.) share
mediaSource: newMediaSource, * one implementation.
audioIndex: defaultAudioIndex, */
subtitleIndex: defaultSubtitleIndex, const {
} = getDefaultPlaySettings(nextItem, settings, { goToNextItem: dispatchNextItem,
indexes: { goToPreviousItem: dispatchPreviousItem,
// Use the live selection (updated when the user changes tracks } = usePlayerItemNavigation({
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem, nextItem,
settings, previousItem,
currentSubtitleIndex, mediaSource: stream?.mediaSource,
currentAudioIndex, currentAudioIndex,
stream?.mediaSource, currentSubtitleIndex,
bitrateValue, bitrateValue,
router, isDisabled: isPlaybackStopped,
isPlaybackStopped, });
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
@@ -1454,8 +1396,8 @@ export default function DirectPlayerPage() {
onSubtitleIndexChange={handleSubtitleIndexChange} onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
goToPreviousItem={goToPreviousItem} goToPreviousItem={dispatchPreviousItem}
goToNextItem={goToNextItem} goToNextItem={dispatchNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks} onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile} addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo} showTechnicalInfo={showTechnicalInfo}

View File

@@ -23,14 +23,12 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -68,86 +66,44 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the // Single source of truth for all player navigation — SyncPlay,
// server, which broadcasts a PlayQueue: NewPlaylist update to every group // offline-vs-stream resolution, and the autoplay counter reset all
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider // live inside `playItem`.
// then performs the navigation uniformly for everyone, matching const { playItem } = usePlayerItemNavigation();
// jellyfin-web's playbackManager intercept (Controller.play).
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom; const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string) => { (opts: Parameters<typeof playItem>[1]) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) { void playItem(item, opts);
updateSettings({ autoPlayEpisodeCount: 0 });
}
router.push(`/player/direct-player?${q}`);
}, },
[router, isOffline], [item, playItem],
); );
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
// SyncPlay intercept: in a group, route playback through sthe server so // Default play options derived from the page's track / source / bitrate
// every member gets the same PlayQueue: NewPlaylist update and navigates // pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online
// together. Skips local navigation and the Chromecast prompt entirely — // routing; we just need to pick a destination (device vs Chromecast).
// SyncPlay + Chromecast isn't a supported combination yet, same as const defaultOpts = {
// jellyfin-web. audioIndex: selectedOptions.audioIndex,
if (isSyncPlayEnabled && syncPlayController && item.Id) { subtitleIndex: selectedOptions.subtitleIndex,
try { mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
// Pass the full `item` (not just the ID) so the SyncPlay controller bitrateValue: selectedOptions.bitrate?.value,
// can run `translateItemsForPlayback` with full context — this is };
// what jellyfin-web does, and it lets us expand Series / Season /
// BoxSet into real episode/track IDs before broadcasting the queue.
// Without expansion, receivers (jellyfin-web in particular) get
// container IDs they can't play and silently fail to open the
// player.
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString); goToPlayer(defaultOpts);
return; return;
} }
@@ -310,7 +266,7 @@ export const PlayButton: React.FC<Props> = ({
}); });
break; break;
case 1: case 1:
goToPlayer(queryString); goToPlayer(defaultOpts);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
@@ -320,37 +276,24 @@ export const PlayButton: React.FC<Props> = ({
}, [ }, [
item, item,
client, client,
settings,
api, api,
user, user,
router,
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
goToPlayer, goToPlayer,
isOffline,
t, t,
isSyncPlayEnabled,
syncPlayController,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
lightHapticFeedback();
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -373,13 +316,7 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -416,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
const queryParams = new URLSearchParams({ goToPlayer({ forceOffline: true });
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },
@@ -446,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
handleNormalPlayFlow(); handleNormalPlayFlow();
}, [ }, [
item, item,
lightHapticFeedback, isOffline,
handleNormalPlayFlow, handleNormalPlayFlow,
goToPlayer, goToPlayer,
t, t,
showModal, showModal,
hideModal, hideModal,
effectiveColors,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {

View File

@@ -7,14 +7,12 @@ import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps { export interface ContinueWatchingOverlayProps {
goToNextItem: (options: { /** Invoked when the user confirms they want to keep watching. */
isAutoPlay: boolean; onContinue: () => void;
resetWatchCount: boolean;
}) => void;
} }
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem, onContinue,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
@@ -29,13 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ? Are you still watching ?
</Text> </Text>
<Button <Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'>
onPress={() => {
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
}}
color={"purple"}
className='my-4 w-2/3'
>
{t("player.continue_watching")} {t("player.continue_watching")}
</Button> </Button>

View File

@@ -15,17 +15,15 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls"; import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls"; import { CenterControls } from "./CenterControls";
@@ -104,9 +102,7 @@ export const Controls: FC<Props> = ({
transcodeReasons, transcodeReasons,
}) => { }) => {
const offline = useOfflineMode(); const offline = useOfflineMode();
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const [episodeView, setEpisodeView] = useState(false); const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false);
@@ -338,130 +334,27 @@ export const Controls: FC<Props> = ({
maxMs, maxMs,
); );
const goToItemCommon = useCallback( /*
(item: BaseItemDto) => { * Single source of truth for next / previous / picker / autoplay
if (!item || !settings) { * navigation. Handles SyncPlay dispatch, autoplay count gating,
return; * platform-appropriate local navigation, and offline param injection.
} */
lightHapticFeedback(); const {
const previousIndexes = { goToNextItem: handleNextEpisodeManual,
subtitleIndex: subtitleIndex goToPreviousItem: handlePreviousItem,
? Number.parseInt(subtitleIndex, 10) goToItem: handleGoToItem,
: undefined, handleAutoPlayNext: handleNextEpisodeAutoPlay,
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, handleContinueWatching,
}; } = usePlayerItemNavigation({
nextItem,
const { previousItem,
mediaSource: newMediaSource, mediaSource,
audioIndex: defaultAudioIndex, currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
subtitleIndex: defaultSubtitleIndex, currentSubtitleIndex: subtitleIndex
} = getDefaultPlaySettings( ? Number.parseInt(subtitleIndex, 10)
item, : undefined,
settings, bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined,
{ });
indexes: previousIndexes,
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
// Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
});
},
[
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
return;
}
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon],
);
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
// Add a memoized handler for ContinueWatchingOverlay
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
setShowControls(false); setShowControls(false);
@@ -490,7 +383,7 @@ export const Controls: FC<Props> = ({
<EpisodeList <EpisodeList
item={item} item={item}
close={() => setEpisodeView(false)} close={() => setEpisodeView(false)}
goToItem={goToItemCommon} goToItem={handleGoToItem}
/> />
) : ( ) : (
<> <>
@@ -524,8 +417,8 @@ export const Controls: FC<Props> = ({
mediaSource={mediaSource} mediaSource={mediaSource}
startPictureInPicture={startPictureInPicture} startPictureInPicture={startPictureInPicture}
switchOnEpisodeMode={switchOnEpisodeMode} switchOnEpisodeMode={switchOnEpisodeMode}
goToPreviousItem={goToPreviousItem} goToPreviousItem={handlePreviousItem}
goToNextItem={goToNextItem} goToNextItem={handleNextEpisodeManual}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
@@ -597,7 +490,7 @@ export const Controls: FC<Props> = ({
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} /> <ContinueWatchingOverlay onContinue={handleContinueWatching} />
)} )}
</View> </View>
); );

View File

@@ -24,7 +24,7 @@ interface HeaderControlsProps {
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
switchOnEpisodeMode: () => void; switchOnEpisodeMode: () => void;
goToPreviousItem: () => void; goToPreviousItem: () => void;
goToNextItem: (options: { isAutoPlay?: boolean }) => void; goToNextItem: () => void;
previousItem?: BaseItemDto | null; previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
aspectRatio?: AspectRatio; aspectRatio?: AspectRatio;
@@ -172,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
)} )}
{nextItem && ( {nextItem && (
<TouchableOpacity <TouchableOpacity
onPress={() => goToNextItem({ isAutoPlay: false })} onPress={() => goToNextItem()}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
> >
<Ionicons <Ionicons

View File

@@ -0,0 +1,405 @@
/**
* Single source of truth for *all* item-level navigation inside the
* player (next / previous / picked-from-episode-list / autoplay-next).
*
* This hook encapsulates three orthogonal concerns so callers don't
* have to:
*
* 1. **SyncPlay** — when a group is active, every advance/rewind
* dispatches through `Controller`. `SyncPlayProvider` handles the
* resulting `localPlay` / `localSetCurrentPlaylistItem` events and
* navigates the local screen.
* 2. **Autoplay gating** — `maxAutoPlayEpisodeCount` limits how many
* episodes auto-play before stopping. Manual presses bypass this.
* SyncPlay bypasses it too (the server drives the queue).
* 3. **Platform navigation** — mobile uses `router.setParams` so the
* player view stays mounted (avoids a full re-mount + bitrate /
* stream re-pick cycle). TV uses `router.replace` because MPV's
* native view can't be re-initialized in place. Offline state is
* preserved automatically by `useAppRouter`.
*/
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform } from "react-native";
import useAppRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { getDownloadedItemById } from "@/providers/Downloads";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface UsePlayerItemNavigationParams {
/**
* The adjacent item that "next" should target (from `usePlaybackManager`).
* Only needed by callers that use the in-session nav methods.
*/
nextItem?: BaseItemDto | null;
/** The adjacent item that "previous" should target. */
previousItem?: BaseItemDto | null;
/** The active media source for the *current* item; used to seed track defaults. */
mediaSource?: MediaSourceInfo | null;
/** Live audio track index (may differ from the URL param after the user changed tracks). */
currentAudioIndex?: number;
/** Live subtitle track index. */
currentSubtitleIndex?: number;
/** Currently-active bitrate cap. */
bitrateValue?: number;
/**
* Optional guard for "we're already stopping the player". TV passes
* `isPlaybackStopped` here to drop spurious next-item dispatches that
* fire during teardown.
*/
isDisabled?: boolean;
}
/** Options for `playItem` — the entry-point method used by PlayButton et al. */
export interface PlayItemOptions {
audioIndex?: number;
subtitleIndex?: number;
mediaSourceId?: string;
bitrateValue?: number;
/** Defaults to `item.UserData?.PlaybackPositionTicks`. */
playbackPosition?: number;
/**
* Force local-file playback even outside the offline UI context, and
* skip SyncPlay broadcasting. Used when the user explicitly picks the
* downloaded copy from a "play downloaded?" prompt.
*/
forceOffline?: boolean;
}
export interface PlayerItemNavigation {
/** SyncPlay-aware previous. No-op when there's no previous item. */
goToPreviousItem: () => void;
/**
* Manual next (e.g. user tapped the skip-forward / next button).
* Autoplay gating is bypassed.
*/
goToNextItem: () => void;
/** Jump to an arbitrary item (episode picker). */
goToItem: (item: BaseItemDto) => void;
/**
* Autoplay next (e.g. "Up Next" overlay countdown completed). Respects
* `maxAutoPlayEpisodeCount`. Bypassed when SyncPlay is active.
*/
handleAutoPlayNext: () => void;
/**
* Helper for the "Keep Watching" overlay button — advances and resets
* the auto-play counter so the next stretch of episodes can autoplay.
*/
handleContinueWatching: () => void;
/**
* Entry-point: start playback of an item from outside the player
* (PlayButton, Continue Watching, episode picker on the item page).
* SyncPlay-aware. Resets the autoplay counter.
*/
playItem: (item: BaseItemDto, opts?: PlayItemOptions) => Promise<void>;
}
export function usePlayerItemNavigation(
params: UsePlayerItemNavigationParams = {},
): PlayerItemNavigation {
const {
nextItem,
previousItem,
mediaSource,
currentAudioIndex,
currentSubtitleIndex,
bitrateValue,
isDisabled,
} = params;
const router = useAppRouter();
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
const lightHapticFeedback = useHaptic("light");
// Note: "offline mode" is a *UI context* flag (set by pages entered from
// the downloads tab), not a network-connectivity status. A user may be
// in offline mode with perfect internet, watching a downloaded copy.
const inOfflineContext = useOfflineMode();
/*
* Compute the destination URL params for a given target item, using
* the live selection state (which may differ from the URL params the
* episode started with — the user may have switched tracks mid-play).
*/
const buildNavigationParams = useCallback(
(target: BaseItemDto) => {
if (!settings) return null;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
target,
settings,
{
indexes: {
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
return {
itemId: target.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
target.UserData?.PlaybackPositionTicks?.toString() ?? "",
};
},
[
settings,
currentSubtitleIndex,
currentAudioIndex,
mediaSource,
bitrateValue,
],
);
/*
* Stamp the `offline` URL param onto a params object based on whether
* the target item is actually downloaded.
*
* - Online (no offline UI context) → pass through unchanged; the
* `offline` key is absent so `useAppRouter` doesn't touch it.
* - Offline context, target IS downloaded → `offline: "true"` (play
* the local copy).
* - Offline context, target is NOT downloaded → `offline: ""` (force
* online streaming; the key's presence blocks `useAppRouter` from
* auto-injecting `"true"`, and direct-player only treats the value
* `"true"` as offline).
*
* That last case is the important one: a user can be in the offline
* UI context with perfect internet (e.g. navigated in from downloads)
* and pick an episode they never downloaded. Without this the player
* would hang waiting for a local file that doesn't exist.
*/
const withOfflineParam = useCallback(
(params: Record<string, string>, target: BaseItemDto) => {
if (!inOfflineContext) return params;
const isDownloaded = !!(target.Id && getDownloadedItemById(target.Id));
return { ...params, offline: isDownloaded ? "true" : "" };
},
[inOfflineContext],
);
/*
* Platform-appropriate local navigation. Mobile keeps the same player
* view mounted via setParams; TV swaps the whole route via replace.
*/
const localNavigate = useCallback(
(target: BaseItemDto) => {
if (isDisabled) return;
const navParams = buildNavigationParams(target);
if (!navParams) return;
lightHapticFeedback();
const finalParams = withOfflineParam(navParams, target);
if (Platform.isTV) {
const queryString = new URLSearchParams(finalParams).toString();
router.replace(`/player/direct-player?${queryString}`);
} else {
router.setParams(finalParams);
}
},
[
isDisabled,
buildNavigationParams,
router,
lightHapticFeedback,
withOfflineParam,
],
);
const goToPreviousItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.previousItem();
return;
}
if (!previousItem) return;
localNavigate(previousItem);
}, [isSyncPlayEnabled, syncPlayController, previousItem, localNavigate]);
const goToNextItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
localNavigate(nextItem);
}, [isSyncPlayEnabled, syncPlayController, nextItem, localNavigate]);
const goToItem = useCallback(
(target: BaseItemDto) => {
if (isSyncPlayEnabled && syncPlayController && target.Id) {
syncPlayController.goToItem(target);
return;
}
localNavigate(target);
},
[isSyncPlayEnabled, syncPlayController, localNavigate],
);
const handleAutoPlayNext = useCallback(() => {
// SyncPlay always advances unconditionally — the server is the source
// of truth for queue progression and per-client gating would desync us.
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
const maxCount = settings?.maxAutoPlayEpisodeCount.value ?? 0;
const currentCount = settings?.autoPlayEpisodeCount ?? 0;
// -1 means "no limit"
if (maxCount === -1) {
localNavigate(nextItem);
return;
}
if (currentCount + 1 < maxCount) {
localNavigate(nextItem);
}
if (currentCount < maxCount) {
updateSettings({ autoPlayEpisodeCount: currentCount + 1 });
}
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
settings,
updateSettings,
localNavigate,
]);
const handleContinueWatching = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
updateSettings({ autoPlayEpisodeCount: 0 });
localNavigate(nextItem);
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
updateSettings,
localNavigate,
]);
/*
* Entry-point: start playback of an item from outside the player.
*
* Used by PlayButton, Continue Watching cards, episode pickers on the
* item page, etc. Unlike the in-session methods, this always uses
* `router.push` (we're entering the player, not navigating within it)
* and runs on both mobile and TV with the same shape.
*
* SyncPlay: when in a group and the user *didn't* explicitly request
* local playback, we route through `controller.play()` so every group
* member gets the same `PlayQueue: NewPlaylist` update and navigates
* together. Errors surface as an Alert and abort the local navigation
* (matches `PlayButton`'s previous behavior).
*/
const playItem = useCallback(
async (item: BaseItemDto, opts: PlayItemOptions = {}) => {
if (!item.Id) return;
lightHapticFeedback();
const startPositionTicks =
opts.playbackPosition ?? item.UserData?.PlaybackPositionTicks ?? 0;
// Fresh playback start — reset the autoplay budget so the next
// stretch of episodes can autoplay.
if (settings && settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
// SyncPlay: broadcast to the group instead of navigating locally.
// Skipped when the user explicitly picked the downloaded copy — a
// local file can't be part of a synced session.
if (!opts.forceOffline && isSyncPlayEnabled && syncPlayController) {
try {
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
// Build a string-record so we can run it through `withOfflineParam`
// and `URLSearchParams` uniformly.
const baseParams: Record<string, string> = {
itemId: item.Id,
playbackPosition: String(startPositionTicks),
};
if (opts.audioIndex !== undefined) {
baseParams.audioIndex = String(opts.audioIndex);
}
if (opts.subtitleIndex !== undefined) {
baseParams.subtitleIndex = String(opts.subtitleIndex);
}
if (opts.mediaSourceId) {
baseParams.mediaSourceId = opts.mediaSourceId;
}
if (opts.bitrateValue !== undefined) {
baseParams.bitrateValue = String(opts.bitrateValue);
}
const finalParams = opts.forceOffline
? { ...baseParams, offline: "true" }
: withOfflineParam(baseParams, item);
const queryString = new URLSearchParams(finalParams).toString();
router.push(`/player/direct-player?${queryString}`);
},
[
lightHapticFeedback,
settings,
updateSettings,
isSyncPlayEnabled,
syncPlayController,
withOfflineParam,
router,
t,
],
);
return {
goToPreviousItem,
goToNextItem,
goToItem,
handleAutoPlayNext,
handleContinueWatching,
playItem,
};
}
export default usePlayerItemNavigation;

View File

@@ -160,6 +160,32 @@ export class Controller {
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error); console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
} }
} }
/**
* Jump the group to `item`. If the item is already in the current queue
* (by `Id`), dispatches a cheap `SetPlaylistItem` so the queue stays
* intact. Otherwise starts a new playback request, which replaces the
* group's queue (matches jellyfin-web's playbackManager.play behavior
* when picking an episode from a different series/season).
*/
goToItem(item: BaseItemDto): void {
if (!item.Id) {
console.warn("SyncPlay Controller.goToItem called without item.Id");
return;
}
const queueEntry = this.manager
.getQueueCore()
.getPlaylist()
.find((q) => q.Id === item.Id);
if (queueEntry?.PlaylistItemId) {
this.setCurrentPlaylistItem(queueEntry.PlaylistItemId);
return;
}
void this.play({
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
}
} }
export default Controller; export default Controller;

View File

@@ -17,6 +17,7 @@
*/ */
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { usePathname } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
createContext, createContext,
@@ -33,6 +34,7 @@ import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter"; import { useAppRouter } from "@/hooks/useAppRouter";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive"; import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { getDownloadedItemById } from "@/providers/Downloads";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { Controller as SyncPlayController } from "./Controller"; import type { Controller as SyncPlayController } from "./Controller";
@@ -81,6 +83,15 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const [manager, setManager] = useState<SyncPlayManager | null>(null); const [manager, setManager] = useState<SyncPlayManager | null>(null);
const isNavigatingToPlayerRef = useRef(false); const isNavigatingToPlayerRef = useRef(false);
// Keep a live ref of the current route pathname so the
// navigateToPlayer helper (wired up once inside the manager-lifecycle
// effect) can read the *current* page without stale-closure issues.
const pathname = usePathname();
const pathnameRef = useRef(pathname);
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null); const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState< const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
@@ -171,6 +182,58 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const playerWrapper = mgr.getPlayerWrapper(); const playerWrapper = mgr.getPlayerWrapper();
/*
* Both localPlay (new queue / joining a group) and
* localSetCurrentPlaylistItem (queue advance to next episode)
* end up navigating to the same screen with the same params;
* jellyfin-web treats them as distinct because one calls full
* playbackManager.play() and the other does a cheap item swap,
* but on RN both have to re-mount direct-player either way.
*/
const navigateToPlayer = (
itemId: string,
startPositionTicks: number,
withJoinToast: boolean,
) => {
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
isNavigatingToPlayerRef.current = true;
if (withJoinToast) {
toast(i18n.t("syncplay.joining_playback"));
}
// Opportunistic local playback: if we have a downloaded copy of
// the target item, use it instead of streaming. Matters most when
// the group advances to an episode you've downloaded — the local
// file starts instantly and survives spotty wifi. SyncPlay's
// position/pause/seek commands keep flowing normally; only the
// source changes.
const isDownloaded = !!getDownloadedItemById(itemId);
const queryParams = new URLSearchParams({
itemId,
playbackPosition: String(startPositionTicks),
syncPlay: "true",
...(isDownloaded && { offline: "true" }),
}).toString();
// Use `replace` when we're already on the player screen so queue
// advances don't stack a second player on the nav stack; `push`
// otherwise so the user can back out to where they came from.
const onPlayerScreen =
pathnameRef.current?.startsWith("/player/direct-player") ?? false;
if (onPlayerScreen) {
router.replace(`/player/direct-player?${queryParams}`);
} else {
router.push(`/player/direct-player?${queryParams}`);
}
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
};
// localPlay → navigate to direct-player with syncPlay=true // localPlay → navigate to direct-player with syncPlay=true
playerWrapper.setLocalPlayHandler((options) => { playerWrapper.setLocalPlayHandler((options) => {
const itemId = options.ids[0]; const itemId = options.ids[0];
@@ -178,24 +241,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
console.warn("SyncPlay: localPlay called with no ids"); console.warn("SyncPlay: localPlay called with no ids");
return; return;
} }
if (isNavigatingToPlayerRef.current) { navigateToPlayer(itemId, options.startPositionTicks ?? 0, true);
console.debug("SyncPlay: already navigating to player");
return;
}
isNavigatingToPlayerRef.current = true;
toast(i18n.t("syncplay.joining_playback"));
const queryParams = new URLSearchParams({
itemId,
playbackPosition: String(options.startPositionTicks ?? 0),
syncPlay: "true",
}).toString();
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
}); });
// localSetCurrentPlaylistItem → navigate to the new playlist item // localSetCurrentPlaylistItem → navigate to the new playlist item
@@ -213,19 +259,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
); );
return; return;
} }
if (isNavigatingToPlayerRef.current) return; navigateToPlayer(itemId, queueCore.getStartPositionTicks(), false);
isNavigatingToPlayerRef.current = true;
const queryParams = new URLSearchParams({
itemId,
playbackPosition: String(queueCore.getStartPositionTicks()),
syncPlay: "true",
}).toString();
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
}); });
mgr.on("enabled", (...args: unknown[]) => { mgr.on("enabled", (...args: unknown[]) => {