mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-06 05:58:35 +01:00
Redo navigation for sync-play support
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
405
hooks/usePlayerItemNavigation.ts
Normal file
405
hooks/usePlayerItemNavigation.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user