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

@@ -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<Props> = ({
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<typeof playItem>[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<Props> = ({
});
break;
case 1:
goToPlayer(queryString);
goToPlayer(defaultOpts);
break;
case cancelButtonIndex:
break;
@@ -320,37 +276,24 @@ export const PlayButton: React.FC<Props> = ({
}, [
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<Props> = ({
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
goToPlayer({ forceOffline: true });
}}
color='purple'
>
@@ -416,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
goToPlayer({ forceOffline: true });
},
isPreferred: true,
},
@@ -446,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
isOffline,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {

View File

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

View File

@@ -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<Props> = ({
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<Props> = ({
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<Props> = ({
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItemCommon}
goToItem={handleGoToItem}
/>
) : (
<>
@@ -524,8 +417,8 @@ export const Controls: FC<Props> = ({
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<Props> = ({
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
<ContinueWatchingOverlay onContinue={handleContinueWatching} />
)}
</View>
);

View File

@@ -24,7 +24,7 @@ interface HeaderControlsProps {
startPictureInPicture?: () => Promise<void>;
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<HeaderControlsProps> = ({
)}
{nextItem && (
<TouchableOpacity
onPress={() => goToNextItem({ isAutoPlay: false })}
onPress={() => goToNextItem()}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons