diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b709b90ae..a0ccff78a 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -35,6 +35,7 @@ import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive"; import { useOrientation } from "@/hooks/useOrientation"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; +import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; import { @@ -54,7 +55,6 @@ import { useSyncPlay } from "@/providers/SyncPlay"; import type { PlayerControls } from "@/providers/SyncPlay/types"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { @@ -1144,44 +1144,6 @@ export default function DirectPlayerPage() { } }, [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) const addSubtitleFile = useCallback(async (path: string) => { await videoRef.current?.addSubtitleFile?.(path, true); @@ -1211,45 +1173,25 @@ export default function DirectPlayerPage() { return []; }, [isMounted]); - // TV: Navigate to next item - const goToNextItem = useCallback(() => { - if (!nextItem || !settings || isPlaybackStopped) return; - - const { - mediaSource: newMediaSource, - audioIndex: defaultAudioIndex, - subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings(nextItem, settings, { - indexes: { - // Use the live selection (updated when the user changes tracks - // 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); - }, [ + /* + * Item-level navigation (next / previous). Wraps SyncPlay dispatch, + * platform-appropriate local navigation (replace on TV), and offline + * param injection in a single hook so the in-player buttons and any + * future entry points (autoplay overlay, episode picker, etc.) share + * one implementation. + */ + const { + goToNextItem: dispatchNextItem, + goToPreviousItem: dispatchPreviousItem, + } = usePlayerItemNavigation({ nextItem, - settings, - currentSubtitleIndex, + previousItem, + mediaSource: stream?.mediaSource, currentAudioIndex, - stream?.mediaSource, + currentSubtitleIndex, bitrateValue, - router, - isPlaybackStopped, - ]); + isDisabled: isPlaybackStopped, + }); // Apply subtitle settings when video loads useEffect(() => { @@ -1454,8 +1396,8 @@ export default function DirectPlayerPage() { onSubtitleIndexChange={handleSubtitleIndexChange} previousItem={previousItem} nextItem={nextItem} - goToPreviousItem={goToPreviousItem} - goToNextItem={goToNextItem} + goToPreviousItem={dispatchPreviousItem} + goToNextItem={dispatchNextItem} onRefreshSubtitleTracks={handleRefreshSubtitleTracks} addSubtitleFile={addSubtitleFile} showTechnicalInfo={showTechnicalInfo} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index d7baf2805..17d60a667 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -23,14 +23,12 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import useRouter from "@/hooks/useAppRouter"; -import { useHaptic } from "@/hooks/useHaptic"; import type { ThemeColors } from "@/hooks/useImageColorsReturn"; +import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation"; import { getDownloadedItemById } from "@/providers/Downloads/database"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; -import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; @@ -68,86 +66,44 @@ export const PlayButton: React.FC = ({ const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - // SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the - // server, which broadcasts a PlayQueue: NewPlaylist update to every group - // member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider - // then performs the navigation uniformly for everyone, matching - // jellyfin-web's playbackManager intercept (Controller.play). - const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } = - useSyncPlay(); + // Single source of truth for all player navigation — SyncPlay, + // offline-vs-stream resolution, and the autoplay counter reset all + // live inside `playItem`. + const { playItem } = usePlayerItemNavigation(); // Use colors prop if provided, otherwise fallback to global atom const effectiveColors = colors || globalColorAtom; - const router = useRouter(); - const startWidth = useSharedValue(0); const targetWidth = useSharedValue(0); const endColor = useSharedValue(effectiveColors); const startColor = useSharedValue(effectiveColors); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); - const { settings, updateSettings } = useSettings(); - const lightHapticFeedback = useHaptic("light"); + const { settings } = useSettings(); const goToPlayer = useCallback( - (q: string) => { - if (settings.maxAutoPlayEpisodeCount.value !== -1) { - updateSettings({ autoPlayEpisodeCount: 0 }); - } - router.push(`/player/direct-player?${q}`); + (opts: Parameters[1]) => { + void playItem(item, opts); }, - [router, isOffline], + [item, playItem], ); const handleNormalPlayFlow = useCallback(async () => { if (!item) return; - // SyncPlay intercept: in a group, route playback through sthe server so - // every member gets the same PlayQueue: NewPlaylist update and navigates - // together. Skips local navigation and the Chromecast prompt entirely — - // SyncPlay + Chromecast isn't a supported combination yet, same as - // jellyfin-web. - if (isSyncPlayEnabled && syncPlayController && item.Id) { - try { - // Pass the full `item` (not just the ID) so the SyncPlay controller - // 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(); + // Default play options derived from the page's track / source / bitrate + // pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online + // routing; we just need to pick a destination (device vs Chromecast). + const defaultOpts = { + audioIndex: selectedOptions.audioIndex, + subtitleIndex: selectedOptions.subtitleIndex, + mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined, + bitrateValue: selectedOptions.bitrate?.value, + }; if (!client) { - goToPlayer(queryString); + goToPlayer(defaultOpts); return; } @@ -310,7 +266,7 @@ export const PlayButton: React.FC = ({ }); break; case 1: - goToPlayer(queryString); + goToPlayer(defaultOpts); break; case cancelButtonIndex: break; @@ -320,37 +276,24 @@ export const PlayButton: React.FC = ({ }, [ item, client, - settings, api, user, - router, showActionSheetWithOptions, mediaStatus, selectedOptions, goToPlayer, - isOffline, t, - isSyncPlayEnabled, - syncPlayController, ]); const onPress = useCallback(async () => { if (!item) return; - lightHapticFeedback(); - // Check if item is downloaded const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; // If already in offline mode, play downloaded file directly if (isOffline && downloadedItem) { - const queryParams = new URLSearchParams({ - itemId: item.Id!, - offline: "true", - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "0", - }); - goToPlayer(queryParams.toString()); + goToPlayer({ forceOffline: true }); return; } @@ -373,13 +316,7 @@ export const PlayButton: React.FC = ({ diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 3651876a4..c4c79942c 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -15,17 +15,15 @@ import Animated, { withTiming, } from "react-native-reanimated"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; -import useRouter from "@/hooks/useAppRouter"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; -import { useHaptic } from "@/hooks/useHaptic"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation"; import { useTrickplay } from "@/hooks/useTrickplay"; import type { TechnicalInfo } from "@/modules/mpv-player"; import { DownloadedItem } from "@/providers/Downloads/types"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { ticksToMs } from "@/utils/time"; import { BottomControls } from "./BottomControls"; import { CenterControls } from "./CenterControls"; @@ -104,9 +102,7 @@ export const Controls: FC = ({ transcodeReasons, }) => { const offline = useOfflineMode(); - const { settings, updateSettings } = useSettings(); - const router = useRouter(); - const lightHapticFeedback = useHaptic("light"); + const { settings } = useSettings(); const [episodeView, setEpisodeView] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false); @@ -338,130 +334,27 @@ export const Controls: FC = ({ maxMs, ); - const goToItemCommon = useCallback( - (item: BaseItemDto) => { - if (!item || !settings) { - return; - } - lightHapticFeedback(); - const previousIndexes = { - subtitleIndex: subtitleIndex - ? Number.parseInt(subtitleIndex, 10) - : undefined, - audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, - }; - - const { - mediaSource: newMediaSource, - audioIndex: defaultAudioIndex, - subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings( - item, - settings, - { - 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], - ); + /* + * Single source of truth for next / previous / picker / autoplay + * navigation. Handles SyncPlay dispatch, autoplay count gating, + * platform-appropriate local navigation, and offline param injection. + */ + const { + goToNextItem: handleNextEpisodeManual, + goToPreviousItem: handlePreviousItem, + goToItem: handleGoToItem, + handleAutoPlayNext: handleNextEpisodeAutoPlay, + handleContinueWatching, + } = usePlayerItemNavigation({ + nextItem, + previousItem, + mediaSource, + currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, + currentSubtitleIndex: subtitleIndex + ? Number.parseInt(subtitleIndex, 10) + : undefined, + bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined, + }); const hideControls = useCallback(() => { setShowControls(false); @@ -490,7 +383,7 @@ export const Controls: FC = ({ setEpisodeView(false)} - goToItem={goToItemCommon} + goToItem={handleGoToItem} /> ) : ( <> @@ -524,8 +417,8 @@ export const Controls: FC = ({ mediaSource={mediaSource} startPictureInPicture={startPictureInPicture} switchOnEpisodeMode={switchOnEpisodeMode} - goToPreviousItem={goToPreviousItem} - goToNextItem={goToNextItem} + goToPreviousItem={handlePreviousItem} + goToNextItem={handleNextEpisodeManual} previousItem={previousItem} nextItem={nextItem} aspectRatio={aspectRatio} @@ -597,7 +490,7 @@ export const Controls: FC = ({ )} {settings.maxAutoPlayEpisodeCount.value !== -1 && ( - + )} ); diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index d3cd7a285..34ef78357 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -24,7 +24,7 @@ interface HeaderControlsProps { startPictureInPicture?: () => Promise; switchOnEpisodeMode: () => void; goToPreviousItem: () => void; - goToNextItem: (options: { isAutoPlay?: boolean }) => void; + goToNextItem: () => void; previousItem?: BaseItemDto | null; nextItem?: BaseItemDto | null; aspectRatio?: AspectRatio; @@ -172,7 +172,7 @@ export const HeaderControls: FC = ({ )} {nextItem && ( goToNextItem({ isAutoPlay: false })} + onPress={() => goToNextItem()} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' > 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; +} + +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, 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 = { + 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; diff --git a/providers/SyncPlay/Controller.ts b/providers/SyncPlay/Controller.ts index a773892db..0a51360e1 100644 --- a/providers/SyncPlay/Controller.ts +++ b/providers/SyncPlay/Controller.ts @@ -160,6 +160,32 @@ export class Controller { 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; diff --git a/providers/SyncPlay/SyncPlayProvider.tsx b/providers/SyncPlay/SyncPlayProvider.tsx index 4d5ea8e1d..d7aee2f83 100644 --- a/providers/SyncPlay/SyncPlayProvider.tsx +++ b/providers/SyncPlay/SyncPlayProvider.tsx @@ -17,6 +17,7 @@ */ import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; +import { usePathname } from "expo-router"; import { useAtomValue } from "jotai"; import { createContext, @@ -33,6 +34,7 @@ import { toast } from "sonner-native"; import { useAppRouter } from "@/hooks/useAppRouter"; import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive"; import i18n from "@/i18n"; +import { getDownloadedItemById } from "@/providers/Downloads"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; import type { Controller as SyncPlayController } from "./Controller"; @@ -81,6 +83,15 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const [manager, setManager] = useState(null); 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 [groupInfo, setGroupInfo] = useState(null); const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState< @@ -171,6 +182,58 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { 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 playerWrapper.setLocalPlayHandler((options) => { const itemId = options.ids[0]; @@ -178,24 +241,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { console.warn("SyncPlay: localPlay called with no ids"); return; } - if (isNavigatingToPlayerRef.current) { - 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); + navigateToPlayer(itemId, options.startPositionTicks ?? 0, true); }); // localSetCurrentPlaylistItem → navigate to the new playlist item @@ -213,19 +259,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { ); return; } - if (isNavigatingToPlayerRef.current) return; - 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); + navigateToPlayer(itemId, queueCore.getStartPositionTicks(), false); }); mgr.on("enabled", (...args: unknown[]) => {