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

@@ -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;