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:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user