mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 11:08:26 +01:00
feat(sync-play): squash feature/sync-play
This commit is contained in:
@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
|
|||||||
|
|
||||||
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
|
|
||||||
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
const SyncPlayButtonComponent = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
@@ -33,6 +36,7 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -128,6 +129,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
|
syncPlay: syncPlayStr,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -137,9 +139,24 @@ export default function DirectPlayerPage() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
|
/** Whether playback was initiated by SyncPlay */
|
||||||
|
syncPlay?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
||||||
|
const openedViaSyncPlay = syncPlayStr === "true";
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
// SyncPlay integration
|
||||||
|
const syncPlay = useSyncPlay();
|
||||||
|
const {
|
||||||
|
isEnabled: isSyncPlayEnabled,
|
||||||
|
controller: syncPlayController,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
} = syncPlay;
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
@@ -403,8 +420,102 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
// SyncPlay: Connect player controls when video is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideoLoaded || !videoRef.current || offline) {
|
||||||
|
setPlayerControls(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls: PlayerControls = {
|
||||||
|
play: () => videoRef.current?.play(),
|
||||||
|
pause: () => videoRef.current?.pause(),
|
||||||
|
seekTo: (positionMs: number) => {
|
||||||
|
const positionSec = positionMs / 1000;
|
||||||
|
console.log(
|
||||||
|
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
||||||
|
);
|
||||||
|
videoRef.current?.seekTo(positionSec);
|
||||||
|
},
|
||||||
|
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
||||||
|
getSpeed: () => currentPlaybackSpeed,
|
||||||
|
getCurrentPosition: () => progress.get(),
|
||||||
|
isPlaying: () => isPlaying,
|
||||||
|
isBuffering: () => isBuffering,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlayerControls(controls);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setPlayerControls(null);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isVideoLoaded,
|
||||||
|
offline,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
currentPlaybackSpeed,
|
||||||
|
progress,
|
||||||
|
setPlayerControls,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Report buffering/ready state to server.
|
||||||
|
//
|
||||||
|
// CRITICAL: We must report `buffering` to the server *during* initial
|
||||||
|
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
||||||
|
// and proceeds without waiting for us. jellyfin-web reports this for
|
||||||
|
// free via the HTML5 video element's `waiting` event; for us, the
|
||||||
|
// initial load itself is the buffering window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocallyReady = isVideoLoaded && !isBuffering;
|
||||||
|
if (isLocallyReady) {
|
||||||
|
notifyReady();
|
||||||
|
} else {
|
||||||
|
notifyBuffering();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
isVideoLoaded,
|
||||||
|
isBuffering,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Pause playback when group is waiting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupState = syncPlay.groupInfo?.State;
|
||||||
|
const isLocalReady = isVideoLoaded && !isBuffering;
|
||||||
|
const isWaitingForGroup = groupState === "Waiting";
|
||||||
|
|
||||||
|
// Pause playback when waiting for group
|
||||||
|
if (isLocalReady && isWaitingForGroup && isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlay.groupInfo?.State,
|
||||||
|
isVideoLoaded,
|
||||||
|
isBuffering,
|
||||||
|
isPlaying,
|
||||||
|
]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Route through SyncPlay when active
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.playPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
@@ -638,10 +749,12 @@ export default function DirectPlayerPage() {
|
|||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
|
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
||||||
|
const shouldAutoplay = !openedViaSyncPlay;
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: shouldAutoplay,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
@@ -860,10 +973,20 @@ export default function DirectPlayerPage() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback(
|
||||||
// MPV expects seconds, convert from ms
|
(position: number) => {
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
// Route through SyncPlay when active
|
||||||
}, []);
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||||
|
syncPlayController.seekMs(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPV expects seconds, convert from ms
|
||||||
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
// TV audio track change handler
|
// TV audio track change handler
|
||||||
const handleAudioIndexChange = useCallback(
|
const handleAudioIndexChange = useCallback(
|
||||||
|
|||||||
254
app/_layout.tsx
254
app/_layout.tsx
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
|||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
|
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -409,133 +410,136 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<SyncPlayProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<IntroSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<IntroSheetProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/now-playing'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent:
|
||||||
|
Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
{!Platform.isTV && <GlobalModal />}
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</IntroSheetProvider>
|
||||||
headerShown: false,
|
</BottomSheetModalProvider>
|
||||||
title: "",
|
</GlobalModalProvider>
|
||||||
header: () => null,
|
</MusicPlayerProvider>
|
||||||
}}
|
</DownloadProvider>
|
||||||
/>
|
</SyncPlayProvider>
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/now-playing'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-action-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-user-switch-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
{!Platform.isTV && <GlobalModal />}
|
|
||||||
</ThemeProvider>
|
|
||||||
</IntroSheetProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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";
|
||||||
@@ -67,6 +68,14 @@ 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
|
||||||
|
// 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();
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
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
|
||||||
|
// 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({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
goToPlayer,
|
goToPlayer,
|
||||||
isOffline,
|
isOffline,
|
||||||
t,
|
t,
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
|||||||
235
components/syncplay/GroupSelectionMenu.tsx
Normal file
235
components/syncplay/GroupSelectionMenu.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* GroupSelectionMenu
|
||||||
|
*
|
||||||
|
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
|
||||||
|
* owned by SyncPlayButton). Calls `onClose` after successful actions to
|
||||||
|
* dismiss the parent sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
|
||||||
|
|
||||||
|
interface GroupSelectionMenuProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
} = useSyncPlay();
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedGroups = await getGroups();
|
||||||
|
if (!cancelled) {
|
||||||
|
setGroups(fetchedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch groups", error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getGroups]);
|
||||||
|
|
||||||
|
const handleJoinGroup = useCallback(
|
||||||
|
async (groupId: string) => {
|
||||||
|
try {
|
||||||
|
await joinGroup(groupId);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to join group", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[joinGroup, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateGroup = useCallback(async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create group", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [createGroup, onClose]);
|
||||||
|
|
||||||
|
const handleLeaveGroup = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await leaveGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to leave group", error);
|
||||||
|
}
|
||||||
|
}, [leaveGroup, onClose]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
paddingTop: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEnabled && groupInfo) {
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people' size={24} color='#00a4dc' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
|
||||||
|
<View className='flex-row items-center justify-between mb-3'>
|
||||||
|
<Text className='text-neutral-100 font-semibold text-lg'>
|
||||||
|
{groupInfo.GroupName}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
|
||||||
|
<Text className='text-white text-xs font-medium'>
|
||||||
|
{groupInfo.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='person' size={16} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{groupInfo.Participants.length} {t("syncplay.members")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button onPress={handleLeaveGroup} color='red'>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='exit-outline' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.leave_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people-outline' size={24} color='white' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View className='py-8 items-center'>
|
||||||
|
<ActivityIndicator color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
|
||||||
|
{t("syncplay.available_groups")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={group.GroupId ?? index}
|
||||||
|
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
|
||||||
|
className={`flex-row items-center p-4 ${
|
||||||
|
index < groups.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='people' size={20} color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{group.GroupName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{group.Participants?.length ?? 0} {t("syncplay.members")} •{" "}
|
||||||
|
{group.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length === 0 && (
|
||||||
|
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
|
||||||
|
<Ionicons name='people-outline' size={40} color='#6b7280' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-3'>
|
||||||
|
{t("syncplay.available_groups")}: 0{"\n"}
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreateGroups && (
|
||||||
|
<Button
|
||||||
|
onPress={handleCreateGroup}
|
||||||
|
color='purple'
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
components/syncplay/SyncPlayButton.tsx
Normal file
97
components/syncplay/SyncPlayButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayButton
|
||||||
|
*
|
||||||
|
* Header button for accessing SyncPlay functionality.
|
||||||
|
* Shows group status and opens the group selection sheet.
|
||||||
|
*
|
||||||
|
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
|
||||||
|
* Compose ModalBottomSheet on Android). Because it presents natively, it
|
||||||
|
* works correctly even when triggered from `headerRight` — no portal or
|
||||||
|
* provider context is required (unlike @gorhom/bottom-sheet, which fails
|
||||||
|
* silently from detached UINavigationItem subtrees).
|
||||||
|
*
|
||||||
|
* Safe to import statically: this whole module is lazy-required only on
|
||||||
|
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BottomSheetMethods,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@expo/ui/community/bottom-sheet";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
|
||||||
|
interface SyncPlayButtonProps {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
|
||||||
|
const { isEnabled, canJoinGroups } = useSyncPlay();
|
||||||
|
const { isConnected } = useNetworkStatus();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const sheetRef = useRef<BottomSheetMethods>(null);
|
||||||
|
|
||||||
|
const isCasting = !!castDevice;
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isCasting) {
|
||||||
|
toast("SyncPlay not available while casting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sheetRef.current?.present();
|
||||||
|
}, [isCasting]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
sheetRef.current?.dismiss();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
if (!canJoinGroups) return null;
|
||||||
|
if (!isConnected) return null;
|
||||||
|
|
||||||
|
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
className='mr-4'
|
||||||
|
onPress={handlePress}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<View className='relative'>
|
||||||
|
<Ionicons
|
||||||
|
name={isEnabled ? "people" : "people-outline"}
|
||||||
|
size={size}
|
||||||
|
color={iconColor}
|
||||||
|
/>
|
||||||
|
{isEnabled && !isCasting && (
|
||||||
|
<View
|
||||||
|
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#171717",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
|
||||||
|
enablePanDownToClose
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<GroupSelectionMenu onClose={handleDismiss} />
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
components/syncplay/SyncPlayIndicator.tsx
Normal file
165
components/syncplay/SyncPlayIndicator.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayIndicator
|
||||||
|
*
|
||||||
|
* Visual indicator shown during SyncPlay operations.
|
||||||
|
* Only appears when user's stream is ready but waiting for other group members.
|
||||||
|
*
|
||||||
|
* Key principle: SyncPlay indicator = "You're ready, waiting on others"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
// SyncPlay cyan color (matches Jellyfin-web)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
interface SyncPlayIndicatorProps {
|
||||||
|
/**
|
||||||
|
* Whether the indicator should be visible.
|
||||||
|
* Should only be true when:
|
||||||
|
* 1. User's stream has loaded
|
||||||
|
* 2. Waiting for other group members
|
||||||
|
*/
|
||||||
|
visible: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional message to display
|
||||||
|
*/
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayIndicator({
|
||||||
|
visible,
|
||||||
|
message,
|
||||||
|
}: SyncPlayIndicatorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayMessage = message ?? t("syncplay.waiting_for_group");
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
opacity.value = withTiming(1, { duration: 200 });
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withTiming(1.15, {
|
||||||
|
duration: 800,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
opacity.value = withTiming(0, { duration: 200 });
|
||||||
|
scale.value = 1;
|
||||||
|
}
|
||||||
|
}, [visible, opacity, scale]);
|
||||||
|
|
||||||
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.container, containerStyle]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Pulsing icon container */}
|
||||||
|
<Animated.View style={[styles.iconContainer, pulseStyle]}>
|
||||||
|
<View style={styles.iconCircle}>
|
||||||
|
<Ionicons name='people' size={28} color='white' />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<Text style={styles.message}>{displayMessage}</Text>
|
||||||
|
|
||||||
|
{/* SyncPlay badge */}
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Ionicons name='sync' size={12} color='white' />
|
||||||
|
<Text style={styles.badgeText}>SyncPlay</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: SYNC_PLAY_COLOR,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
// Glow effect
|
||||||
|
shadowColor: SYNC_PLAY_COLOR,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 164, 220, 0.2)",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: SYNC_PLAY_COLOR,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: SYNC_PLAY_COLOR,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook-compatible version that reads SyncPlay state directly
|
||||||
|
*/
|
||||||
|
export function useSyncPlayIndicatorState(
|
||||||
|
isLocalReady: boolean,
|
||||||
|
isGroupWaiting: boolean,
|
||||||
|
): boolean {
|
||||||
|
// Show indicator only when:
|
||||||
|
// 1. User's local stream has loaded (isLocalReady)
|
||||||
|
// 2. Group is still waiting for others (isGroupWaiting)
|
||||||
|
return isLocalReady && isGroupWaiting;
|
||||||
|
}
|
||||||
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlaySpinner
|
||||||
|
*
|
||||||
|
* Compact rotating SyncPlay icon shown in place of the play/pause button
|
||||||
|
* while a play/pause command is in flight to the server (the "schedule-play"
|
||||||
|
* indicator from jellyfin-web).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
// SyncPlay cyan color (matches jellyfin-web)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
interface SyncPlaySpinnerProps {
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlaySpinner({
|
||||||
|
size,
|
||||||
|
color = SYNC_PLAY_COLOR,
|
||||||
|
}: SyncPlaySpinnerProps) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 1200,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<Ionicons name='sync' size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
components/syncplay/index.ts
Normal file
10
components/syncplay/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay UI Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
export { SyncPlayButton } from "./SyncPlayButton";
|
||||||
|
export {
|
||||||
|
SyncPlayIndicator,
|
||||||
|
useSyncPlayIndicatorState,
|
||||||
|
} from "./SyncPlayIndicator";
|
||||||
@@ -4,11 +4,16 @@ import { Platform, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
import { ICON_SIZES } from "./constants";
|
import { ICON_SIZES } from "./constants";
|
||||||
|
|
||||||
|
// SyncPlay cyan color (matches Jellyfin-web)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
interface CenterControlsProps {
|
interface CenterControlsProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -44,6 +49,18 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// SyncPlay state from global provider
|
||||||
|
const {
|
||||||
|
isEnabled: isSyncPlayEnabled,
|
||||||
|
groupInfo,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
} = useSyncPlay();
|
||||||
|
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
|
||||||
|
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
|
||||||
|
// play/pause request is in flight to the server.
|
||||||
|
const isSyncPlayScheduling =
|
||||||
|
isSyncPlayEnabled && pendingPlaybackCommand !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -121,7 +138,17 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
|
|
||||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
{!isBuffering ? (
|
{isSyncPlayScheduling ? (
|
||||||
|
// SyncPlay command in flight - rotating spinner ("schedule-play")
|
||||||
|
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
|
||||||
|
) : isSyncPlayWaiting ? (
|
||||||
|
// SyncPlay waiting indicator - clock icon, still pressable to toggle
|
||||||
|
<Ionicons
|
||||||
|
name='time'
|
||||||
|
size={ICON_SIZES.CENTER}
|
||||||
|
color={SYNC_PLAY_COLOR}
|
||||||
|
/>
|
||||||
|
) : !isBuffering ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={ICON_SIZES.CENTER}
|
size={ICON_SIZES.CENTER}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
interface UseWebSocketProps {
|
interface UseWebSocketProps {
|
||||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
|||||||
playTrailers,
|
playTrailers,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { lastMessage } = useWebSocketContext();
|
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { clearLastMessage } = useWebSocketContext();
|
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
if (!lastMessage) return;
|
||||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
|
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
|
||||||
|
const isSyncPlayCommand =
|
||||||
|
lastMessage.MessageType === "SyncPlayCommand" ||
|
||||||
|
lastMessage.MessageType === "SyncPlayGroupUpdate";
|
||||||
|
const isPlaybackCommand = [
|
||||||
|
"PlayPause",
|
||||||
|
"Pause",
|
||||||
|
"Unpause",
|
||||||
|
"Stop",
|
||||||
|
"Seek",
|
||||||
|
"NextTrack",
|
||||||
|
"PreviousTrack",
|
||||||
|
].includes(command ?? "");
|
||||||
|
|
||||||
|
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
|
||||||
|
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
setupView()
|
setupView()
|
||||||
|
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
private func setupView() {
|
||||||
|
|||||||
402
providers/SyncPlay/Controller.ts
Normal file
402
providers/SyncPlay/Controller.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Controller
|
||||||
|
*
|
||||||
|
* Exposes SyncPlay API calls to external modules.
|
||||||
|
* Provides methods for controlling synchronized playback.
|
||||||
|
*
|
||||||
|
* Based on jellyfin-web's Controller.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {
|
||||||
|
getItemsForPlayback,
|
||||||
|
msToTicks,
|
||||||
|
translateItemsForPlayback,
|
||||||
|
} from "./Helper";
|
||||||
|
import type { SyncPlayManager } from "./Manager";
|
||||||
|
import type { QueueCore } from "./QueueCore";
|
||||||
|
import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncPlay Controller - External API for controlling SyncPlay
|
||||||
|
*/
|
||||||
|
export class SyncPlayController {
|
||||||
|
private api: Api;
|
||||||
|
private manager: SyncPlayManager;
|
||||||
|
private queueCore: QueueCore;
|
||||||
|
|
||||||
|
constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) {
|
||||||
|
this.api = api;
|
||||||
|
this.manager = manager;
|
||||||
|
this.queueCore = queueCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle play/pause
|
||||||
|
*/
|
||||||
|
playPause(): void {
|
||||||
|
// Use server group state (with pending in-flight command preferred) as
|
||||||
|
// the source of truth. The local player can lag the group by hundreds of
|
||||||
|
// ms while a scheduled command is pending, so reading `playerControls`
|
||||||
|
// here would cause rapid taps to send duplicate / wrong commands and
|
||||||
|
// desync other clients.
|
||||||
|
const state = this.manager.getEffectivePlayState();
|
||||||
|
console.log(`SyncPlay Controller: playPause - effectiveState=${state}`);
|
||||||
|
if (state === "Playing") {
|
||||||
|
console.log("SyncPlay Controller: requesting PAUSE");
|
||||||
|
this.pause();
|
||||||
|
} else {
|
||||||
|
console.log("SyncPlay Controller: requesting UNPAUSE");
|
||||||
|
this.unpause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request unpause (play)
|
||||||
|
*/
|
||||||
|
async unpause(): Promise<void> {
|
||||||
|
// Drop duplicate rapid taps while a previous request is still in flight
|
||||||
|
// (cleared when the server broadcasts back via SyncPlayCommand, or after
|
||||||
|
// a safety timeout).
|
||||||
|
if (this.manager.getPendingPlaybackCommand() === "Unpause") {
|
||||||
|
console.debug("SyncPlay Controller: unpause ignored — already pending");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.manager.markPendingPlaybackCommand("Unpause");
|
||||||
|
try {
|
||||||
|
console.log("SyncPlay Controller: sending syncPlayUnpause to server");
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayUnpause();
|
||||||
|
console.log("SyncPlay Controller: syncPlayUnpause sent successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to unpause", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request pause
|
||||||
|
*/
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.manager.getPendingPlaybackCommand() === "Pause") {
|
||||||
|
console.debug("SyncPlay Controller: pause ignored — already pending");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.manager.markPendingPlaybackCommand("Pause");
|
||||||
|
try {
|
||||||
|
console.log("SyncPlay Controller: sending syncPlayPause to server");
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayPause();
|
||||||
|
console.log("SyncPlay Controller: syncPlayPause sent successfully");
|
||||||
|
|
||||||
|
// Also pause locally for immediate feedback
|
||||||
|
this.manager.getPlayerControls()?.pause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to pause", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request seek to position
|
||||||
|
*/
|
||||||
|
async seek(positionTicks: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`,
|
||||||
|
);
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySeek({
|
||||||
|
seekRequestDto: {
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("SyncPlay Controller: syncPlaySeek sent successfully");
|
||||||
|
|
||||||
|
// Also seek locally for immediate feedback
|
||||||
|
const positionMs = positionTicks / 10000;
|
||||||
|
this.manager.getPlayerControls()?.seekTo(positionMs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to seek", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request seek to position in milliseconds
|
||||||
|
*/
|
||||||
|
async seekMs(positionMs: number): Promise<void> {
|
||||||
|
console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
|
||||||
|
await this.seek(msToTicks(positionMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request stop
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayStop();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to stop", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback with a new SyncPlay group queue.
|
||||||
|
*
|
||||||
|
* Mirrors jellyfin-web's `Controller.play`:
|
||||||
|
*
|
||||||
|
* - If the caller passed full `items` objects, translate them directly
|
||||||
|
* (Series → episodes, BoxSet → children, etc.).
|
||||||
|
* - Otherwise fetch the items by ID first (`getItemsForPlayback`), then
|
||||||
|
* translate.
|
||||||
|
* - Send the translated, real playable IDs to
|
||||||
|
* `syncPlaySetNewQueue` so every group member receives a queue of
|
||||||
|
* playable items — not container IDs (Series / Season / BoxSet) that
|
||||||
|
* receivers like jellyfin-web silently drop.
|
||||||
|
*
|
||||||
|
* `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web.
|
||||||
|
*/
|
||||||
|
async play(options: PlayOptions): Promise<void> {
|
||||||
|
const { items, ids, startIndex = 0, startPositionTicks = 0 } = options;
|
||||||
|
|
||||||
|
if ((!ids || ids.length === 0) && (!items || items.length === 0)) {
|
||||||
|
console.error("SyncPlay Controller: no items or ids to play");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied
|
||||||
|
// items (no extra round trip), fall back to a fetch by IDs.
|
||||||
|
const sourceItems: BaseItemDto[] =
|
||||||
|
items && items.length > 0
|
||||||
|
? items
|
||||||
|
: await getItemsForPlayback(this.api, ids ?? []);
|
||||||
|
|
||||||
|
if (!sourceItems.length) {
|
||||||
|
console.error(
|
||||||
|
"SyncPlay Controller: getItemsForPlayback returned no items",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode
|
||||||
|
// into the real playable queue.
|
||||||
|
const translated = await translateItemsForPlayback(
|
||||||
|
this.api,
|
||||||
|
sourceItems,
|
||||||
|
{ ids, queryOptions: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
const queueIds = translated
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
if (!queueIds.length) {
|
||||||
|
console.error(
|
||||||
|
"SyncPlay Controller: translateItemsForPlayback produced empty queue",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3 — broadcast the resolved queue to the group.
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetNewQueue({
|
||||||
|
playRequestDto: {
|
||||||
|
PlayingQueue: queueIds,
|
||||||
|
PlayingItemPosition: startIndex,
|
||||||
|
StartPositionTicks: startPositionTicks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Surface the server response body when available — a SetNewQueue
|
||||||
|
// that 4xx's silently is the most common "why didn't the other
|
||||||
|
// client start?" cause. Without the body we'd just see a generic
|
||||||
|
// axios error and have no way to tell whether it was a permission
|
||||||
|
// problem, an unknown item ID, or the server rejecting the queue.
|
||||||
|
const err = error as {
|
||||||
|
response?: { status?: number; data?: unknown };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
console.error("SyncPlay Controller: failed to set new queue", {
|
||||||
|
status: err?.response?.status,
|
||||||
|
data: err?.response?.data,
|
||||||
|
message: err?.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current item in playlist
|
||||||
|
*/
|
||||||
|
async setCurrentPlaylistItem(playlistItemId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetPlaylistItem({
|
||||||
|
setPlaylistItemRequestDto: {
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to set playlist item", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play next item
|
||||||
|
*/
|
||||||
|
async nextItem(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayNextItem({
|
||||||
|
nextItemRequestDto: {
|
||||||
|
PlaylistItemId:
|
||||||
|
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to play next", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play previous item
|
||||||
|
*/
|
||||||
|
async previousItem(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayPreviousItem({
|
||||||
|
previousItemRequestDto: {
|
||||||
|
PlaylistItemId:
|
||||||
|
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to play previous", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add items to queue
|
||||||
|
*/
|
||||||
|
async queue(
|
||||||
|
itemIds: string[],
|
||||||
|
mode: "Queue" | "QueueNext" = "Queue",
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayQueue({
|
||||||
|
queueRequestDto: {
|
||||||
|
ItemIds: itemIds,
|
||||||
|
Mode: mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to queue items", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add items to play next
|
||||||
|
*/
|
||||||
|
async queueNext(itemIds: string[]): Promise<void> {
|
||||||
|
await this.queue(itemIds, "QueueNext");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove items from playlist
|
||||||
|
*/
|
||||||
|
async removeFromPlaylist(playlistItemIds: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayRemoveFromPlaylist({
|
||||||
|
removeFromPlaylistRequestDto: {
|
||||||
|
PlaylistItemIds: playlistItemIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"SyncPlay Controller: failed to remove from playlist",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move item in playlist
|
||||||
|
*/
|
||||||
|
async movePlaylistItem(
|
||||||
|
playlistItemId: string,
|
||||||
|
newIndex: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayMovePlaylistItem({
|
||||||
|
movePlaylistItemRequestDto: {
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
NewIndex: newIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to move playlist item", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playback Settings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set repeat mode
|
||||||
|
*/
|
||||||
|
async setRepeatMode(mode: GroupRepeatMode): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetRepeatMode({
|
||||||
|
setRepeatModeRequestDto: {
|
||||||
|
Mode: mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to set repeat mode", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set shuffle mode
|
||||||
|
*/
|
||||||
|
async setShuffleMode(mode: GroupShuffleMode): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetShuffleMode({
|
||||||
|
setShuffleModeRequestDto: {
|
||||||
|
Mode: mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller: failed to set shuffle mode", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle shuffle mode
|
||||||
|
*/
|
||||||
|
async toggleShuffleMode(): Promise<void> {
|
||||||
|
const currentMode = this.queueCore.getShuffleMode();
|
||||||
|
const newMode: GroupShuffleMode =
|
||||||
|
currentMode === "Sorted" ? "Shuffle" : "Sorted";
|
||||||
|
await this.setShuffleMode(newMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
448
providers/SyncPlay/Helper.ts
Normal file
448
providers/SyncPlay/Helper.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Helper
|
||||||
|
*
|
||||||
|
* Utility functions for SyncPlay functionality.
|
||||||
|
* Based on jellyfin-web's Helper.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { TicksPerMillisecond } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an event to be triggered, with optional timeout.
|
||||||
|
*/
|
||||||
|
export function waitForEvent<T>(
|
||||||
|
eventEmitter: {
|
||||||
|
addEventListener: (event: string, handler: (data: T) => void) => void;
|
||||||
|
removeEventListener: (event: string, handler: (data: T) => void) => void;
|
||||||
|
},
|
||||||
|
eventType: string,
|
||||||
|
timeout?: number,
|
||||||
|
rejectEvents?: string[],
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
eventEmitter.removeEventListener(eventType, handler);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (rejectEvents) {
|
||||||
|
for (const event of rejectEvents) {
|
||||||
|
eventEmitter.removeEventListener(event, rejectHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (data: T) => {
|
||||||
|
cleanup();
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectHandler = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Rejected by event"));
|
||||||
|
};
|
||||||
|
|
||||||
|
eventEmitter.addEventListener(eventType, handler);
|
||||||
|
|
||||||
|
if (rejectEvents) {
|
||||||
|
for (const event of rejectEvents) {
|
||||||
|
eventEmitter.addEventListener(event, rejectHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Timed out waiting for event"));
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a promise-based callback, with timeout.
|
||||||
|
*/
|
||||||
|
export function waitWithTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error("Operation timed out"));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((result) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(result);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ticks to milliseconds.
|
||||||
|
*/
|
||||||
|
export function ticksToMs(ticks: number): number {
|
||||||
|
return ticks / TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert milliseconds to ticks.
|
||||||
|
*/
|
||||||
|
export function msToTicks(ms: number): number {
|
||||||
|
return Math.round(ms * TicksPerMillisecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a GUID string to standard format.
|
||||||
|
*/
|
||||||
|
export function stringToGuid(input: string): string {
|
||||||
|
return input.replace(
|
||||||
|
/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/,
|
||||||
|
"$1-$2-$3-$4-$5",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a date string to Date object.
|
||||||
|
*/
|
||||||
|
export function parseDate(dateString: string): Date {
|
||||||
|
return new Date(dateString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current time as ISO string for API requests.
|
||||||
|
*/
|
||||||
|
export function nowAsIsoString(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a value between min and max.
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple event emitter for internal use.
|
||||||
|
*/
|
||||||
|
export class EventEmitter {
|
||||||
|
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
|
||||||
|
|
||||||
|
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
this.listeners.get(event)?.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.listeners.get(event)?.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in event handler for ${event}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(event?: string): void {
|
||||||
|
if (event) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
} else {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the next emission of an event on our internal {@link EventEmitter},
|
||||||
|
* or reject after `timeoutMs`. Auto-cleans the listener.
|
||||||
|
*/
|
||||||
|
export function waitForOwnEvent(
|
||||||
|
emitter: EventEmitter,
|
||||||
|
event: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handler = (...args: unknown[]) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
emitter.off(event, handler);
|
||||||
|
resolve(args);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
emitter.off(event, handler);
|
||||||
|
reject(new Error(`Timed out waiting for "${event}"`));
|
||||||
|
}, timeoutMs);
|
||||||
|
emitter.on(event, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Item fetching / queue translation
|
||||||
|
//
|
||||||
|
// Faithful port of jellyfin-web's `getItemsForPlayback` and
|
||||||
|
// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`.
|
||||||
|
//
|
||||||
|
// Why this matters for SyncPlay:
|
||||||
|
// - The server takes the queue we send via `syncPlaySetNewQueue` and
|
||||||
|
// broadcasts it verbatim to every group member. If we send a Series /
|
||||||
|
// Season / BoxSet ID, every receiver tries to load that container as a
|
||||||
|
// playable item, which silently fails on jellyfin-web (it never opens
|
||||||
|
// the player). Sending an Episode ID without sibling expansion breaks
|
||||||
|
// next-episode auto-advance for everyone in the group.
|
||||||
|
// - jellyfin-web's `playbackManager.play` runs the same translation
|
||||||
|
// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue
|
||||||
|
// request so the broadcast carries real playable item IDs.
|
||||||
|
// - We replicate the same translation here so a mobile sender produces
|
||||||
|
// the same broadcast a jellyfin-web sender would.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Options bag accepted by `translateItemsForPlayback`. */
|
||||||
|
export interface TranslateOptions {
|
||||||
|
ids?: string[];
|
||||||
|
shuffle?: boolean;
|
||||||
|
queryOptions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields jellyfin-web requests for any playback queue. */
|
||||||
|
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
|
||||||
|
|
||||||
|
/** Resolve the current user. Cached only for the duration of one call. */
|
||||||
|
async function getCurrentUser(api: Api) {
|
||||||
|
const user = (await getUserApi(api).getCurrentUser()).data;
|
||||||
|
if (!user?.Id) {
|
||||||
|
throw new Error("SyncPlay Helper: no authenticated user");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic `getItems` wrapper with the playback defaults jellyfin-web uses
|
||||||
|
* (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`,
|
||||||
|
* `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`).
|
||||||
|
*
|
||||||
|
* Callers pass camelCase params straight to the SDK — no PascalCase shim.
|
||||||
|
*/
|
||||||
|
async function queryItems(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
const res = await getItemsApi(api).getItems({
|
||||||
|
limit: 300,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
excludeLocationTypes: ["Virtual"] as unknown as never,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
...params,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return res.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive "fetch children/tracks under X" — the shape MusicArtist /
|
||||||
|
* MusicGenre / Photo / PhotoAlbum / IsFolder all share.
|
||||||
|
*/
|
||||||
|
function fetchSiblings(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
filters: ["IsNotFolder"],
|
||||||
|
recursive: true,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve item IDs into full `BaseItemDto`s.
|
||||||
|
* Mirrors jellyfin-web's `Helper.getItemsForPlayback`:
|
||||||
|
* - single ID → `getUserLibraryApi.getItem` (cheap)
|
||||||
|
* - multi ID → `getItemsApi.getItems` with playback defaults
|
||||||
|
*/
|
||||||
|
export async function getItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const userId = (await getCurrentUser(api)).Id as string;
|
||||||
|
if (ids.length === 1) {
|
||||||
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
|
userId,
|
||||||
|
itemId: ids[0],
|
||||||
|
});
|
||||||
|
return res.data ? [res.data] : [];
|
||||||
|
}
|
||||||
|
return queryItems(api, userId, { ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a "first item" into a real playable queue.
|
||||||
|
*
|
||||||
|
* Mirrors jellyfin-web's `Helper.translateItemsForPlayback`:
|
||||||
|
* - Program → channel items
|
||||||
|
* - Playlist → playlist children
|
||||||
|
* - MusicArtist → artist tracks
|
||||||
|
* - MusicGenre → genre tracks
|
||||||
|
* - Photo / PhotoAlbum → sibling photos
|
||||||
|
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
|
||||||
|
* - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes
|
||||||
|
* - anything else → passthrough (Movies, Audio, single Episodes when autoplay off)
|
||||||
|
*
|
||||||
|
* Preserves the original `options.ids` order so the receiver sees the same
|
||||||
|
* queue order the sender intended.
|
||||||
|
*/
|
||||||
|
export async function translateItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
items: BaseItemDto[],
|
||||||
|
options: TranslateOptions = {},
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
|
||||||
|
const workingItems =
|
||||||
|
items.length > 1 && options.ids
|
||||||
|
? [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(options.ids ?? []).indexOf(a.Id ?? "") -
|
||||||
|
(options.ids ?? []).indexOf(b.Id ?? ""),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const firstItem = workingItems[0];
|
||||||
|
const defaultSortBy = options.shuffle ? "Random" : "SortName";
|
||||||
|
|
||||||
|
// Program → channel's playable items. Doesn't need a user lookup.
|
||||||
|
if (firstItem.Type === "Program" && firstItem.ChannelId) {
|
||||||
|
return getItemsForPlayback(api, [firstItem.ChannelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the user once for every branch that follows. Saves 1-2 round
|
||||||
|
// trips vs. each helper resolving independently.
|
||||||
|
const user = await getCurrentUser(api);
|
||||||
|
const userId = user.Id as string;
|
||||||
|
|
||||||
|
if (firstItem.Type === "Playlist") {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
sortBy: options.shuffle ? ["Random"] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "MusicArtist") {
|
||||||
|
return fetchSiblings(api, userId, {
|
||||||
|
artistIds: firstItem.Id ? [firstItem.Id] : undefined,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
sortBy: options.shuffle
|
||||||
|
? ["Random"]
|
||||||
|
: ["Album", "ParentIndexNumber", "IndexNumber", "SortName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.MediaType === "Photo") {
|
||||||
|
const siblings = await fetchSiblings(api, userId, {
|
||||||
|
parentId: firstItem.ParentId,
|
||||||
|
recursive: false,
|
||||||
|
mediaTypes: ["Photo", "Video"],
|
||||||
|
sortBy: [defaultSortBy],
|
||||||
|
});
|
||||||
|
// Re-anchor startIndex to the chosen photo, same as jellyfin-web.
|
||||||
|
// SyncPlay doesn't currently consume startIndex from queryOptions,
|
||||||
|
// but we keep parity for any future caller.
|
||||||
|
if (siblings.length && options.queryOptions) {
|
||||||
|
const idx = siblings.findIndex((i) => i.Id === firstItem.Id);
|
||||||
|
if (idx >= 0) options.queryOptions.startIndex = idx;
|
||||||
|
}
|
||||||
|
return siblings;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "PhotoAlbum") {
|
||||||
|
return fetchSiblings(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
recursive: false,
|
||||||
|
mediaTypes: ["Photo", "Video"],
|
||||||
|
sortBy: [defaultSortBy],
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "MusicGenre") {
|
||||||
|
return fetchSiblings(api, userId, {
|
||||||
|
genreIds: firstItem.Id ? [firstItem.Id] : undefined,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
sortBy: [defaultSortBy],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.IsFolder) {
|
||||||
|
// Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets
|
||||||
|
// SortBy for shuffle or BoxSet — everything else inherits server-side
|
||||||
|
// sort order (typically index/premiere date).
|
||||||
|
const sortBy = options.shuffle
|
||||||
|
? ["Random"]
|
||||||
|
: firstItem.Type === "BoxSet"
|
||||||
|
? ["SortName"]
|
||||||
|
: undefined;
|
||||||
|
return fetchSiblings(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
mediaTypes: ["Audio", "Video"],
|
||||||
|
sortBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "Episode" && workingItems.length === 1) {
|
||||||
|
// Single-episode auto-next: drop everything before this episode so
|
||||||
|
// playback starts here and auto-advances through the rest of the
|
||||||
|
// series. Gated on the user's `EnableNextEpisodeAutoPlay` like
|
||||||
|
// jellyfin-web does.
|
||||||
|
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: firstItem.SeriesId,
|
||||||
|
userId,
|
||||||
|
isMissing: false,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
});
|
||||||
|
const all = res.data.Items ?? [];
|
||||||
|
const foundIdx = Math.max(
|
||||||
|
0,
|
||||||
|
all.findIndex((e) => e.Id === firstItem.Id),
|
||||||
|
);
|
||||||
|
return all.slice(foundIdx);
|
||||||
|
} catch (error) {
|
||||||
|
// Don't block playback on a translation failure — fall back to the
|
||||||
|
// single-item queue the caller already supplied.
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay Helper: Episode translation failed, falling back to single item",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (Movie, Audio, ...) plays as-is.
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
797
providers/SyncPlay/Manager.ts
Normal file
797
providers/SyncPlay/Manager.ts
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Manager
|
||||||
|
*
|
||||||
|
* Main orchestrator for SyncPlay functionality.
|
||||||
|
* Manages group state, coordinates time sync, playback, and queue.
|
||||||
|
*
|
||||||
|
* Based on jellyfin-web's Manager.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { EventEmitter, msToTicks } from "./Helper";
|
||||||
|
import { TimeSyncCore } from "./TimeSyncCore";
|
||||||
|
import type {
|
||||||
|
GroupInfoDto,
|
||||||
|
PlayerControls,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
SendCommand,
|
||||||
|
SyncPlayStats,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncPlay Manager - Main orchestrator
|
||||||
|
*/
|
||||||
|
export class SyncPlayManager extends EventEmitter {
|
||||||
|
private api: Api;
|
||||||
|
private timeSyncCore: TimeSyncCore;
|
||||||
|
|
||||||
|
// Group state
|
||||||
|
private groupInfo: GroupInfoDto | null = null;
|
||||||
|
private syncPlayEnabledAt: Date | null = null;
|
||||||
|
private syncPlayReady = false;
|
||||||
|
private queuedCommand: SendCommand | null = null;
|
||||||
|
private followingGroupPlayback = true;
|
||||||
|
private lastPlaybackCommand: SendCommand | null = null;
|
||||||
|
|
||||||
|
// Pending play/pause request awaiting server broadcast.
|
||||||
|
// Used to (1) ignore duplicate rapid taps and (2) treat the server's
|
||||||
|
// upcoming state as the source of truth while a request is in flight.
|
||||||
|
private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
|
||||||
|
private pendingPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Safety: drop the pending guard after this long if no broadcast arrives.
|
||||||
|
private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
// Player state
|
||||||
|
private playerControls: PlayerControls | null = null;
|
||||||
|
private syncMethod = "None";
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
|
||||||
|
private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
|
||||||
|
private onGetPlaylistItemId: (() => string | null) | null = null;
|
||||||
|
// Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
|
||||||
|
// own scheduled timers / cached command so we don't carry stale state into
|
||||||
|
// the next group.
|
||||||
|
private onDisable: (() => void) | null = null;
|
||||||
|
// Fired when SyncPlay is disabled — QueueCore wires up here to clear its
|
||||||
|
// last PlayQueue snapshot. Without this, re-joining the same group later
|
||||||
|
// causes the first PlayQueue echo (which can have a `LastUpdate` equal to
|
||||||
|
// or older than the snapshot we cached before the disable) to be dropped
|
||||||
|
// by the stale-update guard in `QueueCore.updatePlayQueue`.
|
||||||
|
private onQueueClear: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
super();
|
||||||
|
this.api = api;
|
||||||
|
this.timeSyncCore = new TimeSyncCore(api);
|
||||||
|
|
||||||
|
// Listen for time sync updates
|
||||||
|
this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
|
||||||
|
if (error) {
|
||||||
|
console.debug("SyncPlay Manager: time sync error", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("time-sync-update", timeOffset, ping);
|
||||||
|
|
||||||
|
// Report ping to server when enabled
|
||||||
|
if (this.isSyncPlayEnabled() && ping !== null) {
|
||||||
|
this.sendPing(ping);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the manager
|
||||||
|
*/
|
||||||
|
init(): void {
|
||||||
|
this.timeSyncCore.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the API client
|
||||||
|
*/
|
||||||
|
updateApiClient(api: Api): void {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API client
|
||||||
|
*/
|
||||||
|
getApiClient(): Api {
|
||||||
|
return this.api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the time sync core
|
||||||
|
*/
|
||||||
|
getTimeSyncCore(): TimeSyncCore {
|
||||||
|
return this.timeSyncCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set player controls for playback management
|
||||||
|
*/
|
||||||
|
setPlayerControls(controls: PlayerControls | null): void {
|
||||||
|
this.playerControls = controls;
|
||||||
|
|
||||||
|
// When player controls are connected and SyncPlay is active, sync to group state
|
||||||
|
if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
|
||||||
|
const state = this.groupInfo?.State;
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: player controls connected, group state is ${state}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRITICAL: Tell server we're following group playback
|
||||||
|
// This ensures the server sends us SyncPlayCommand messages
|
||||||
|
this.followGroupPlayback();
|
||||||
|
|
||||||
|
// Reconcile position: if we know the last command and group is playing,
|
||||||
|
// estimate where the group is *now* and seek there before resuming. This
|
||||||
|
// fixes the case where the player attaches mid-stream and would
|
||||||
|
// otherwise resume from 0 or the last-known local position.
|
||||||
|
const last = this.lastPlaybackCommand;
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
(last.Command === "Unpause" || last.Command === "Pause") &&
|
||||||
|
last.When &&
|
||||||
|
last.PositionTicks != null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const commandWhen = new Date(last.When);
|
||||||
|
let targetTicks = last.PositionTicks;
|
||||||
|
if (last.Command === "Unpause") {
|
||||||
|
const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
|
||||||
|
targetTicks +=
|
||||||
|
(remoteNow.getTime() - commandWhen.getTime()) * 10000;
|
||||||
|
}
|
||||||
|
const targetMs = Math.max(0, targetTicks / 10000);
|
||||||
|
const currentMs = controls.getCurrentPosition();
|
||||||
|
if (Math.abs(currentMs - targetMs) > 500) {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
|
||||||
|
);
|
||||||
|
controls.seekTo(targetMs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay: failed to estimate group position on attach",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "Playing" && !controls.isPlaying()) {
|
||||||
|
console.log("SyncPlay: starting playback to match group");
|
||||||
|
controls.play();
|
||||||
|
} else if (state === "Paused" && controls.isPlaying()) {
|
||||||
|
console.log("SyncPlay: pausing to match group");
|
||||||
|
controls.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current player controls
|
||||||
|
*/
|
||||||
|
getPlayerControls(): PlayerControls | null {
|
||||||
|
return this.playerControls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for playback commands
|
||||||
|
*/
|
||||||
|
setPlaybackCommandHandler(
|
||||||
|
handler: ((command: SendCommand) => void) | null,
|
||||||
|
): void {
|
||||||
|
this.onPlaybackCoreCommand = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for queue updates
|
||||||
|
*/
|
||||||
|
setQueueUpdateHandler(
|
||||||
|
handler: ((update: PlayQueueUpdate) => void) | null,
|
||||||
|
): void {
|
||||||
|
this.onQueueUpdate = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for getting current playlist item ID
|
||||||
|
*/
|
||||||
|
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
|
||||||
|
this.onGetPlaylistItemId = getter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
|
||||||
|
* here so it can flush scheduled commands and stale state.
|
||||||
|
*/
|
||||||
|
setDisableHandler(handler: (() => void) | null): void {
|
||||||
|
this.onDisable = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a callback invoked when SyncPlay is disabled. QueueCore registers
|
||||||
|
* here so it can drop the cached PlayQueue snapshot and treat the next
|
||||||
|
* server update as fresh.
|
||||||
|
*/
|
||||||
|
setQueueClearHandler(handler: (() => void) | null): void {
|
||||||
|
this.onQueueClear = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Group Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SyncPlay is enabled (user is in a group)
|
||||||
|
*/
|
||||||
|
isSyncPlayEnabled(): boolean {
|
||||||
|
return this.syncPlayEnabledAt !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SyncPlay is ready (time sync complete)
|
||||||
|
*/
|
||||||
|
isSyncPlayReady(): boolean {
|
||||||
|
return this.syncPlayReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current group info
|
||||||
|
*/
|
||||||
|
getGroupInfo(): GroupInfoDto | null {
|
||||||
|
return this.groupInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last playback command
|
||||||
|
*/
|
||||||
|
getLastPlaybackCommand(): SendCommand | null {
|
||||||
|
return this.lastPlaybackCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently playing
|
||||||
|
*/
|
||||||
|
isPlaying(): boolean {
|
||||||
|
// First check actual player state
|
||||||
|
if (this.playerControls) {
|
||||||
|
return this.playerControls.isPlaying();
|
||||||
|
}
|
||||||
|
// Fall back to group state
|
||||||
|
if (this.groupInfo?.State) {
|
||||||
|
return this.groupInfo.State === "Playing";
|
||||||
|
}
|
||||||
|
// Last resort: check last command
|
||||||
|
return this.lastPlaybackCommand?.Command === "Unpause";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective play state for SyncPlay routing decisions.
|
||||||
|
*
|
||||||
|
* Prefers (1) a pending in-flight command we just sent, (2) the server's
|
||||||
|
* group state, and only falls back to the local player. This avoids the
|
||||||
|
* race where a rapid second tap reads the local player (which hasn't
|
||||||
|
* applied the scheduled command yet) and sends a duplicate request that
|
||||||
|
* either re-broadcasts with a new `When` or flips the group the wrong way.
|
||||||
|
*/
|
||||||
|
getEffectivePlayState(): "Playing" | "Paused" {
|
||||||
|
if (this.pendingPlaybackCommand === "Unpause") return "Playing";
|
||||||
|
if (this.pendingPlaybackCommand === "Pause") return "Paused";
|
||||||
|
if (this.groupInfo?.State === "Playing") return "Playing";
|
||||||
|
if (this.groupInfo?.State === "Paused") return "Paused";
|
||||||
|
return this.playerControls?.isPlaying() ? "Playing" : "Paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the in-flight play/pause request, if any.
|
||||||
|
*/
|
||||||
|
getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
|
||||||
|
return this.pendingPlaybackCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a play/pause request as in flight. Auto-clears on a safety timeout
|
||||||
|
* in case the server broadcast is missed.
|
||||||
|
*/
|
||||||
|
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
|
||||||
|
this.pendingPlaybackCommand = command;
|
||||||
|
if (this.pendingPlaybackTimeout) {
|
||||||
|
clearTimeout(this.pendingPlaybackTimeout);
|
||||||
|
}
|
||||||
|
this.pendingPlaybackTimeout = setTimeout(() => {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay Manager: pending playback command timed out",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
this.pendingPlaybackCommand = null;
|
||||||
|
this.pendingPlaybackTimeout = null;
|
||||||
|
this.emit("pending-playback-change", null);
|
||||||
|
}, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
|
||||||
|
this.emit("pending-playback-change", command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPendingPlaybackCommand(): void {
|
||||||
|
if (this.pendingPlaybackTimeout) {
|
||||||
|
clearTimeout(this.pendingPlaybackTimeout);
|
||||||
|
this.pendingPlaybackTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.pendingPlaybackCommand !== null) {
|
||||||
|
this.pendingPlaybackCommand = null;
|
||||||
|
this.emit("pending-playback-change", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if following group playback
|
||||||
|
*/
|
||||||
|
isFollowingGroupPlayback(): boolean {
|
||||||
|
return this.followingGroupPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable SyncPlay (join a group)
|
||||||
|
*/
|
||||||
|
enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
|
||||||
|
if (this.isSyncPlayEnabled()) {
|
||||||
|
if (groupInfo.GroupId === this.groupInfo?.GroupId) {
|
||||||
|
console.debug(
|
||||||
|
`SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
|
||||||
|
);
|
||||||
|
this.disableSyncPlay(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.groupInfo = groupInfo;
|
||||||
|
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
|
||||||
|
? new Date(groupInfo.LastUpdatedAt)
|
||||||
|
: new Date();
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.syncPlayReady = false;
|
||||||
|
|
||||||
|
console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
|
||||||
|
|
||||||
|
this.emit("enabled", true);
|
||||||
|
|
||||||
|
// Wait for time sync to be ready
|
||||||
|
const checkReady = () => {
|
||||||
|
if (this.timeSyncCore.isReady()) {
|
||||||
|
this.syncPlayReady = true;
|
||||||
|
|
||||||
|
// CRITICAL: Tell server we're following group playback
|
||||||
|
// This ensures the server sends us SyncPlayCommand messages
|
||||||
|
this.followGroupPlayback();
|
||||||
|
|
||||||
|
if (this.queuedCommand) {
|
||||||
|
this.processCommand(this.queuedCommand);
|
||||||
|
this.queuedCommand = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act on initial group state if player is connected
|
||||||
|
if (this.playerControls && groupInfo.State) {
|
||||||
|
console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
|
||||||
|
if (groupInfo.State === "Playing") {
|
||||||
|
this.playerControls.play();
|
||||||
|
} else if (groupInfo.State === "Paused") {
|
||||||
|
this.playerControls.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTimeout(checkReady, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.timeSyncCore.forceUpdate();
|
||||||
|
checkReady();
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
toast(i18n.t("syncplay.enabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable SyncPlay (leave group)
|
||||||
|
*/
|
||||||
|
disableSyncPlay(showMessage = false): void {
|
||||||
|
this.syncPlayEnabledAt = null;
|
||||||
|
this.syncPlayReady = false;
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.lastPlaybackCommand = null;
|
||||||
|
this.queuedCommand = null;
|
||||||
|
this.groupInfo = null;
|
||||||
|
this.clearPendingPlaybackCommand();
|
||||||
|
|
||||||
|
// Tell PlaybackCore (or whoever subscribed) to flush any scheduled
|
||||||
|
// commands / cached state so a future re-enable starts clean.
|
||||||
|
try {
|
||||||
|
this.onDisable?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("SyncPlay: onDisable handler threw", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the cached PlayQueue snapshot so a future re-join doesn't get
|
||||||
|
// its first PlayQueue update silently dropped as "older than what we
|
||||||
|
// already have".
|
||||||
|
try {
|
||||||
|
this.onQueueClear?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("SyncPlay: onQueueClear handler threw", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("enabled", false);
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
toast(i18n.t("syncplay.disabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Server Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send ping to server
|
||||||
|
*/
|
||||||
|
private async sendPing(ping: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlayPing({
|
||||||
|
pingRequestDto: { Ping: Math.round(ping) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.debug("SyncPlay: failed to send ping", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that we're ready (not buffering)
|
||||||
|
*/
|
||||||
|
async reportReady(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
const now = new Date();
|
||||||
|
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
|
||||||
|
const currentPositionTicks = msToTicks(currentPosition);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"SyncPlay Manager: reporting READY at position",
|
||||||
|
currentPositionTicks,
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncPlayApi.syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: currentPositionTicks,
|
||||||
|
IsPlaying: this.playerControls?.isPlaying() ?? false,
|
||||||
|
PlaylistItemId:
|
||||||
|
this.onGetPlaylistItemId?.() ??
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("SyncPlay Manager: READY sent successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Manager: failed to report ready", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow group playback
|
||||||
|
*/
|
||||||
|
async followGroupPlayback(): Promise<void> {
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetIgnoreWait({
|
||||||
|
ignoreWaitRequestDto: { IgnoreWait: false },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to follow group playback", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halt group playback (stop following)
|
||||||
|
*/
|
||||||
|
async haltGroupPlayback(): Promise<void> {
|
||||||
|
this.followingGroupPlayback = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
await syncPlayApi.syncPlaySetIgnoreWait({
|
||||||
|
ignoreWaitRequestDto: { IgnoreWait: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop local playback
|
||||||
|
this.playerControls?.pause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to halt group playback", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Processing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a group update from the server
|
||||||
|
* Uses generic type to handle all possible update types from server
|
||||||
|
*/
|
||||||
|
processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
|
||||||
|
const { Type, Data } = update;
|
||||||
|
|
||||||
|
switch (Type) {
|
||||||
|
case "PlayQueue": {
|
||||||
|
const playQueueData = Data as PlayQueueUpdate;
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: received PlayQueue update - position:",
|
||||||
|
playQueueData.StartPositionTicks,
|
||||||
|
"reason:",
|
||||||
|
playQueueData.Reason,
|
||||||
|
);
|
||||||
|
this.onQueueUpdate?.(playQueueData);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UserJoined":
|
||||||
|
toast(i18n.t("syncplay.user_joined", { username: Data }));
|
||||||
|
if (this.groupInfo) {
|
||||||
|
if (!this.groupInfo.Participants) {
|
||||||
|
this.groupInfo.Participants = [Data as string];
|
||||||
|
} else {
|
||||||
|
this.groupInfo.Participants.push(Data as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "UserLeft":
|
||||||
|
toast(i18n.t("syncplay.user_left", { username: Data }));
|
||||||
|
if (this.groupInfo?.Participants) {
|
||||||
|
this.groupInfo.Participants = this.groupInfo.Participants.filter(
|
||||||
|
(user: string) => user !== Data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GroupJoined": {
|
||||||
|
const groupData = Data as GroupInfoDto;
|
||||||
|
this.enableSyncPlay(groupData, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SyncPlayIsDisabled":
|
||||||
|
toast(i18n.t("syncplay.permission_required"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "NotInGroup":
|
||||||
|
case "GroupLeft":
|
||||||
|
this.disableSyncPlay(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GroupUpdate": {
|
||||||
|
const updatedData = Data as GroupInfoDto;
|
||||||
|
this.groupInfo = updatedData;
|
||||||
|
this.emit("group-info-change", updatedData);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "StateUpdate": {
|
||||||
|
// Log full state data to see if position is included
|
||||||
|
console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
|
||||||
|
const stateData = Data as {
|
||||||
|
State: string;
|
||||||
|
Reason: string;
|
||||||
|
PositionTicks?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CRITICAL: Update the stored group state so subsequent checks use the correct value
|
||||||
|
if (this.groupInfo) {
|
||||||
|
this.groupInfo.State = stateData.State as any;
|
||||||
|
// Emit a fresh object so React state subscribers re-render —
|
||||||
|
// mutating in place would not trigger re-renders.
|
||||||
|
this.emit("group-info-change", { ...this.groupInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("group-state-update", stateData.State, stateData.Reason);
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle seek from StateUpdate if position is included
|
||||||
|
if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: StateUpdate contains seek position:",
|
||||||
|
stateData.PositionTicks,
|
||||||
|
);
|
||||||
|
this.emit("seek-from-state-update", stateData.PositionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
|
||||||
|
// This ensures we stay in sync even if the server doesn't send commands
|
||||||
|
if (this.playerControls) {
|
||||||
|
const currentlyPlaying = this.playerControls.isPlaying();
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stateData.State === "Paused" && currentlyPlaying) {
|
||||||
|
console.log("SyncPlay: StateUpdate -> PAUSING player");
|
||||||
|
this.playerControls.pause();
|
||||||
|
} else if (stateData.State === "Playing" && !currentlyPlaying) {
|
||||||
|
console.log("SyncPlay: StateUpdate -> PLAYING");
|
||||||
|
this.playerControls.play();
|
||||||
|
} else if (stateData.State === "Waiting") {
|
||||||
|
console.log("SyncPlay: StateUpdate -> Waiting for other members");
|
||||||
|
// Pause player when waiting
|
||||||
|
if (currentlyPlaying) {
|
||||||
|
this.playerControls.pause();
|
||||||
|
}
|
||||||
|
// Emit event so PlaybackCore can report ready
|
||||||
|
this.emit("waiting-for-ready");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("SyncPlay: StateUpdate but no playerControls!");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GroupDoesNotExist":
|
||||||
|
toast(i18n.t("syncplay.group_does_not_exist"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CreateGroupDenied":
|
||||||
|
toast(i18n.t("syncplay.create_denied"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "JoinGroupDenied":
|
||||||
|
toast(i18n.t("syncplay.join_denied"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "LibraryAccessDenied":
|
||||||
|
toast(i18n.t("syncplay.library_access_denied"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a playback command from the server
|
||||||
|
*/
|
||||||
|
processCommand(command: SendCommand): void {
|
||||||
|
console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
|
||||||
|
|
||||||
|
if (!this.isSyncPlayEnabled()) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay Manager: not enabled, ignoring command",
|
||||||
|
command.Command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
|
||||||
|
if (this.syncPlayEnabledAt && emittedAt) {
|
||||||
|
if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
|
||||||
|
console.debug("SyncPlay Manager: ignoring old command", command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject commands targeted at a different playlist item than the one we
|
||||||
|
// currently have loaded. Stop is always honored (it may be a teardown
|
||||||
|
// before a queue swap). This prevents (e.g.) seeking the wrong episode
|
||||||
|
// when a queue change is racing a command.
|
||||||
|
if (command.Command !== "Stop" && command.PlaylistItemId) {
|
||||||
|
const currentItemId = this.onGetPlaylistItemId?.();
|
||||||
|
if (currentItemId && currentItemId !== command.PlaylistItemId) {
|
||||||
|
console.debug(
|
||||||
|
`SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.syncPlayReady) {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay Manager: not ready, queuing command",
|
||||||
|
command.Command,
|
||||||
|
);
|
||||||
|
this.queuedCommand = command;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember the command even if we can't act on it yet. When the player
|
||||||
|
// attaches (setPlayerControls), the reconcile-on-attach path uses
|
||||||
|
// `lastPlaybackCommand` to seek to the estimated group position and
|
||||||
|
// resume/pause to match the group. Without this assignment, a command
|
||||||
|
// that arrives during the join→navigate→load window is lost.
|
||||||
|
this.lastPlaybackCommand = command;
|
||||||
|
|
||||||
|
// Clear pending guard once the matching broadcast arrives. We treat any
|
||||||
|
// Unpause/Pause arrival as satisfying the pending request (the server
|
||||||
|
// may coalesce or override our intent — either way we trust its decision).
|
||||||
|
if (command.Command === "Unpause" || command.Command === "Pause") {
|
||||||
|
this.clearPendingPlaybackCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.playerControls) {
|
||||||
|
// Expected when a command arrives between joining the group and the
|
||||||
|
// player finishing its initial load. The reconciliation in
|
||||||
|
// setPlayerControls will replay this command from `lastPlaybackCommand`
|
||||||
|
// once controls attach.
|
||||||
|
console.debug(
|
||||||
|
`SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay Manager: delegating ${command.Command} to playback core`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delegate to playback handler
|
||||||
|
if (this.onPlaybackCoreCommand) {
|
||||||
|
this.onPlaybackCoreCommand(command);
|
||||||
|
} else {
|
||||||
|
console.error("SyncPlay Manager: no playback command handler set!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stats
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SyncPlay stats for display
|
||||||
|
*/
|
||||||
|
getStats(): SyncPlayStats {
|
||||||
|
return {
|
||||||
|
timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
|
||||||
|
timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
|
||||||
|
playbackDiff: "0.00",
|
||||||
|
syncMethod: this.syncMethod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show sync icon
|
||||||
|
*/
|
||||||
|
showSyncIcon(method: string): void {
|
||||||
|
this.syncMethod = method;
|
||||||
|
this.emit("syncing", true, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear sync icon
|
||||||
|
*/
|
||||||
|
clearSyncIcon(): void {
|
||||||
|
this.syncMethod = "None";
|
||||||
|
this.emit("syncing", false, "None");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the manager
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.timeSyncCore.destroy();
|
||||||
|
this.disableSyncPlay(false);
|
||||||
|
this.removeAllListeners();
|
||||||
|
this.playerControls = null;
|
||||||
|
this.onPlaybackCoreCommand = null;
|
||||||
|
this.onQueueUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
881
providers/SyncPlay/PlaybackCore.ts
Normal file
881
providers/SyncPlay/PlaybackCore.ts
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
/**
|
||||||
|
* PlaybackCore
|
||||||
|
*
|
||||||
|
* Manages synchronized playback for SyncPlay.
|
||||||
|
* Handles scheduling commands at precise times and sync correction.
|
||||||
|
*
|
||||||
|
* Based on jellyfin-web's PlaybackCore.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper";
|
||||||
|
import type { TimeSyncCore } from "./TimeSyncCore";
|
||||||
|
import type {
|
||||||
|
PlayerControls,
|
||||||
|
SendCommand,
|
||||||
|
SyncPlayOsdAction,
|
||||||
|
SyncPlaySettings,
|
||||||
|
} from "./types";
|
||||||
|
import { TicksPerMillisecond } from "./types";
|
||||||
|
|
||||||
|
// Random offset added when re-issuing a duplicate Seek to force the player
|
||||||
|
// off-position so the next sync correction has something to chew on. Matches
|
||||||
|
// jellyfin-web's behavior (server tolerates a range, so we deliberately land
|
||||||
|
// just outside it).
|
||||||
|
const SEEK_FORCE_RANGE_MS = 100;
|
||||||
|
// Timeout for waiting on the local player's "ready" event after seek.
|
||||||
|
// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout.
|
||||||
|
const WAIT_FOR_READY_TIMEOUT_MS = 30000;
|
||||||
|
// How close player position must be to command position to consider it
|
||||||
|
// already in the correct place (fuzz to account for player imprecision).
|
||||||
|
const POSITION_MATCH_TOLERANCE_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlaybackCore - Handles synchronized playback
|
||||||
|
*/
|
||||||
|
export class PlaybackCore extends EventEmitter {
|
||||||
|
private api: Api;
|
||||||
|
private timeSyncCore: TimeSyncCore;
|
||||||
|
private playerControls: PlayerControls | null = null;
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
private syncEnabled = false;
|
||||||
|
private playbackDiffMillis = 0;
|
||||||
|
private syncAttempts = 0;
|
||||||
|
private lastSyncTime = new Date();
|
||||||
|
private playerIsBuffering = false;
|
||||||
|
|
||||||
|
// Command tracking
|
||||||
|
private lastCommand: SendCommand | null = null;
|
||||||
|
private scheduledCommandTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Last buffering state we reported to the server. Used to dedupe
|
||||||
|
// sendBufferingRequest so we only send on actual transitions —
|
||||||
|
// jellyfin-web gets this for free from the HTML5 `waiting`/`canplay`
|
||||||
|
// events, but our player exposes state, not events, and the React
|
||||||
|
// effect that drives notifyReady/notifyBuffering can re-run many times
|
||||||
|
// per second during normal playback. Without this guard we flood the
|
||||||
|
// server with redundant Ready/Buffering requests.
|
||||||
|
private lastBufferingSent: boolean | null = null;
|
||||||
|
private inflightBufferingRequest: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Debounce buffering notifications, matching jellyfin-web's
|
||||||
|
// `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short
|
||||||
|
// re-buffer blip (<3s) shouldn't notify the server at all — there's no
|
||||||
|
// reason to pause the whole group for a hiccup that resolves itself.
|
||||||
|
// Going Ready cancels any pending buffering notification.
|
||||||
|
private notifyBufferingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private readonly minBufferingThresholdMillis = 3000;
|
||||||
|
|
||||||
|
// Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new
|
||||||
|
// SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem
|
||||||
|
// / PreviousItem). On the next `onReady` we pause the player BEFORE
|
||||||
|
// sending SyncPlayReady so the server sees us as `IsPlaying:false`,
|
||||||
|
// parked at the start position, awaiting an Unpause command. Mirrors
|
||||||
|
// jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which
|
||||||
|
// registers a one-shot `playbackstart` listener that does the same.
|
||||||
|
// Without this the receiver's player auto-plays the moment it loads and
|
||||||
|
// the group's Unpause command arrives to an already-playing player —
|
||||||
|
// which leaves the receiver subtly out of sync with the sender (or, on
|
||||||
|
// slower devices, stuck on a blank loading screen because the early
|
||||||
|
// play attempt races the media load and never recovers).
|
||||||
|
private pendingInitialPause = false;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
private minDelaySpeedToSync = 60.0;
|
||||||
|
private maxDelaySpeedToSync = 3000.0;
|
||||||
|
private speedToSyncDuration = 1000.0;
|
||||||
|
private minDelaySkipToSync = 400.0;
|
||||||
|
private useSpeedToSync = true;
|
||||||
|
private useSkipToSync = true;
|
||||||
|
private enableSyncCorrection = false;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null;
|
||||||
|
private getCurrentPlaylistItemId: (() => string | null) | null = null;
|
||||||
|
|
||||||
|
constructor(api: Api, timeSyncCore: TimeSyncCore) {
|
||||||
|
super();
|
||||||
|
this.api = api;
|
||||||
|
this.timeSyncCore = timeSyncCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set player controls
|
||||||
|
*/
|
||||||
|
setPlayerControls(controls: PlayerControls | null): void {
|
||||||
|
this.playerControls = controls;
|
||||||
|
// A new (or detached) player means the server's view of our ready
|
||||||
|
// state is stale — drop the dedupe latch so the next notifyReady /
|
||||||
|
// notifyBuffering actually reaches the server.
|
||||||
|
this.lastBufferingSent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set OSD notification handler
|
||||||
|
*/
|
||||||
|
setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void {
|
||||||
|
this.onNotifyOsd = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set playlist item ID getter
|
||||||
|
*/
|
||||||
|
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
|
||||||
|
this.getCurrentPlaylistItemId = getter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings
|
||||||
|
*/
|
||||||
|
loadSettings(settings: Partial<SyncPlaySettings>): void {
|
||||||
|
if (settings.minDelaySpeedToSync !== undefined) {
|
||||||
|
this.minDelaySpeedToSync = settings.minDelaySpeedToSync;
|
||||||
|
}
|
||||||
|
if (settings.maxDelaySpeedToSync !== undefined) {
|
||||||
|
this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync;
|
||||||
|
}
|
||||||
|
if (settings.speedToSyncDuration !== undefined) {
|
||||||
|
this.speedToSyncDuration = settings.speedToSyncDuration;
|
||||||
|
}
|
||||||
|
if (settings.minDelaySkipToSync !== undefined) {
|
||||||
|
this.minDelaySkipToSync = settings.minDelaySkipToSync;
|
||||||
|
}
|
||||||
|
if (settings.useSpeedToSync !== undefined) {
|
||||||
|
this.useSpeedToSync = settings.useSpeedToSync;
|
||||||
|
}
|
||||||
|
if (settings.useSkipToSync !== undefined) {
|
||||||
|
this.useSkipToSync = settings.useSkipToSync;
|
||||||
|
}
|
||||||
|
if (settings.enableSyncCorrection !== undefined) {
|
||||||
|
this.enableSyncCorrection = settings.enableSyncCorrection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Player Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when playback starts
|
||||||
|
*/
|
||||||
|
onPlaybackStart(): void {
|
||||||
|
this.emit("playbackstart");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when playback stops
|
||||||
|
*/
|
||||||
|
onPlaybackStop(): void {
|
||||||
|
this.lastCommand = null;
|
||||||
|
this.emit("playbackstop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when player is ready to play
|
||||||
|
*/
|
||||||
|
onReady(): void {
|
||||||
|
this.playerIsBuffering = false;
|
||||||
|
// Cancel any pending buffering notification — we're ready before the
|
||||||
|
// 3s threshold fired, so the server never needs to know we hiccupped.
|
||||||
|
if (this.notifyBufferingTimeout) {
|
||||||
|
clearTimeout(this.notifyBufferingTimeout);
|
||||||
|
this.notifyBufferingTimeout = null;
|
||||||
|
}
|
||||||
|
// If we're handling the first ready event after a queue change,
|
||||||
|
// pause the player BEFORE reporting ready. The subsequent
|
||||||
|
// `sendBufferingRequest(false)` will then read `isPlaying() === false`
|
||||||
|
// and the server will hold the group until we receive an Unpause.
|
||||||
|
if (this.pendingInitialPause) {
|
||||||
|
this.pendingInitialPause = false;
|
||||||
|
if (this.playerControls?.isPlaying()) {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)",
|
||||||
|
);
|
||||||
|
this.playerControls.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sendBufferingRequest(false);
|
||||||
|
this.emit("ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the next `onReady` call as the initial ready for a new queue
|
||||||
|
* item. The player will be paused before SyncPlayReady is sent so the
|
||||||
|
* server keeps the group in `Waiting` until our Unpause arrives.
|
||||||
|
*
|
||||||
|
* Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`.
|
||||||
|
* Called by the provider when a PlayQueue update is `NewPlaylist`,
|
||||||
|
* `SetCurrentItem`, `NextItem`, or `PreviousItem`.
|
||||||
|
*/
|
||||||
|
scheduleReadyRequestOnPlaybackStart(): void {
|
||||||
|
this.pendingInitialPause = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when player is buffering
|
||||||
|
*/
|
||||||
|
onBuffering(): void {
|
||||||
|
// Debounce: only flip into "buffering" state (and notify the server)
|
||||||
|
// if the stall lasts longer than minBufferingThresholdMillis. Mirrors
|
||||||
|
// jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only
|
||||||
|
// calls `onBuffering()` after the 3s timeout elapses. Keeping
|
||||||
|
// playerIsBuffering=false during brief blips lets sync correction
|
||||||
|
// continue to run normally.
|
||||||
|
if (this.notifyBufferingTimeout) {
|
||||||
|
clearTimeout(this.notifyBufferingTimeout);
|
||||||
|
}
|
||||||
|
this.notifyBufferingTimeout = setTimeout(() => {
|
||||||
|
this.notifyBufferingTimeout = null;
|
||||||
|
this.playerIsBuffering = true;
|
||||||
|
this.sendBufferingRequest(true);
|
||||||
|
this.emit("buffering");
|
||||||
|
}, this.minBufferingThresholdMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is buffering
|
||||||
|
*/
|
||||||
|
isBuffering(): boolean {
|
||||||
|
return this.playerIsBuffering;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback difference in milliseconds
|
||||||
|
*/
|
||||||
|
getPlaybackDiff(): number {
|
||||||
|
return this.playbackDiffMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Server Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send buffering/ready request to server.
|
||||||
|
*
|
||||||
|
* NOTE: This must work even before player controls are bound, so that we
|
||||||
|
* can signal "I'm not ready yet, hold the group" while the video is still
|
||||||
|
* loading. jellyfin-web's HTML5 player gets this for free via the
|
||||||
|
* `waiting` event firing during initial buffering; we don't bind controls
|
||||||
|
* until the video is loaded, so we synthesize a position=0 buffering
|
||||||
|
* signal in the pre-bind window.
|
||||||
|
*/
|
||||||
|
async sendBufferingRequest(isBuffering: boolean): Promise<void> {
|
||||||
|
if (!this.api) {
|
||||||
|
console.warn("SyncPlay PlaybackCore: no api for buffering request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if the desired state matches what we last sent. Without this,
|
||||||
|
// the React effect that drives notifyReady/notifyBuffering will flood
|
||||||
|
// the server every time the video player's isBuffering momentarily
|
||||||
|
// toggles during normal playback.
|
||||||
|
if (this.lastBufferingSent === isBuffering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce: if a request is already in flight, wait for it. This
|
||||||
|
// prevents racing two requests when state flips rapidly.
|
||||||
|
if (this.inflightBufferingRequest) {
|
||||||
|
await this.inflightBufferingRequest;
|
||||||
|
// Re-check after the in-flight request settled — the new state may
|
||||||
|
// already match.
|
||||||
|
if (this.lastBufferingSent === isBuffering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.doSendBufferingRequest(isBuffering);
|
||||||
|
this.inflightBufferingRequest = request;
|
||||||
|
try {
|
||||||
|
await request;
|
||||||
|
this.lastBufferingSent = isBuffering;
|
||||||
|
} finally {
|
||||||
|
if (this.inflightBufferingRequest === request) {
|
||||||
|
this.inflightBufferingRequest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSendBufferingRequest(isBuffering: boolean): Promise<void> {
|
||||||
|
if (!this.api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
|
||||||
|
const currentPositionTicks = msToTicks(currentPosition);
|
||||||
|
const isPlaying = this.playerControls?.isPlaying() ?? false;
|
||||||
|
|
||||||
|
const now = this.timeSyncCore.localDateToRemote(new Date());
|
||||||
|
const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null;
|
||||||
|
|
||||||
|
const syncPlayApi = getSyncPlayApi(this.api);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`,
|
||||||
|
{
|
||||||
|
position: currentPositionTicks,
|
||||||
|
playlistItemId,
|
||||||
|
hasPlayerControls: !!this.playerControls,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBuffering) {
|
||||||
|
await syncPlayApi.syncPlayBuffering({
|
||||||
|
bufferRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: currentPositionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await syncPlayApi.syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: currentPositionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to send buffering request", error);
|
||||||
|
// On failure, clear the dedupe latch so the next attempt actually
|
||||||
|
// re-sends rather than getting stuck thinking the server knows.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Command Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a playback command
|
||||||
|
*/
|
||||||
|
async applyCommand(command: SendCommand): Promise<void> {
|
||||||
|
console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`);
|
||||||
|
|
||||||
|
// Parse the When time from string
|
||||||
|
const commandWhen = command.When ? new Date(command.When) : new Date();
|
||||||
|
const positionTicks = command.PositionTicks ?? 0;
|
||||||
|
|
||||||
|
// Duplicate command handling — don't blindly skip. Match jellyfin-web:
|
||||||
|
// if the duplicate's scheduled time has already passed and local player
|
||||||
|
// state doesn't match, re-apply (with a force-offset for seek). This
|
||||||
|
// self-heals after a missed broadcast, reconnect, or local drift.
|
||||||
|
if (this.lastCommand?.When) {
|
||||||
|
const lastWhen = new Date(this.lastCommand.When);
|
||||||
|
if (
|
||||||
|
lastWhen.getTime() === commandWhen.getTime() &&
|
||||||
|
this.lastCommand.PositionTicks === command.PositionTicks &&
|
||||||
|
this.lastCommand.Command === command.Command &&
|
||||||
|
this.lastCommand.PlaylistItemId === command.PlaylistItemId
|
||||||
|
) {
|
||||||
|
const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen);
|
||||||
|
if (whenLocal > new Date()) {
|
||||||
|
// Still in the future — already scheduled, nothing to do.
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay PlaybackCore: duplicate (still scheduled), skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.playerControls) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay PlaybackCore: duplicate past command but no player",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPositionMs = this.playerControls.getCurrentPosition();
|
||||||
|
const isPlaying = this.playerControls.isPlaying();
|
||||||
|
const targetMs = ticksToMs(positionTicks);
|
||||||
|
const positionMatches =
|
||||||
|
Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS;
|
||||||
|
|
||||||
|
switch (command.Command) {
|
||||||
|
case "Unpause":
|
||||||
|
if (!isPlaying) {
|
||||||
|
console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling");
|
||||||
|
await this.scheduleUnpause(commandWhen, positionTicks);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Pause":
|
||||||
|
if (isPlaying || !positionMatches) {
|
||||||
|
console.debug("SyncPlay PlaybackCore: dup Pause — reconciling");
|
||||||
|
this.schedulePause(commandWhen, positionTicks);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Stop":
|
||||||
|
if (isPlaying) {
|
||||||
|
console.debug("SyncPlay PlaybackCore: dup Stop — reconciling");
|
||||||
|
this.scheduleStop(commandWhen);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Seek": {
|
||||||
|
if (!isPlaying && positionMatches) {
|
||||||
|
// Already paused at target — just confirm ready.
|
||||||
|
this.sendBufferingRequest(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Force a re-seek with a small random offset so the player
|
||||||
|
// actually moves (server tolerates a range).
|
||||||
|
const randomOffsetTicks =
|
||||||
|
Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) *
|
||||||
|
TicksPerMillisecond;
|
||||||
|
console.debug(
|
||||||
|
`SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`,
|
||||||
|
);
|
||||||
|
this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(
|
||||||
|
"SyncPlay PlaybackCore: unrecognized duplicate command",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCommand = command;
|
||||||
|
|
||||||
|
if (!this.playerControls) {
|
||||||
|
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (command.Command) {
|
||||||
|
case "Unpause":
|
||||||
|
await this.scheduleUnpause(commandWhen, positionTicks);
|
||||||
|
break;
|
||||||
|
case "Pause":
|
||||||
|
this.schedulePause(commandWhen, positionTicks);
|
||||||
|
break;
|
||||||
|
case "Stop":
|
||||||
|
this.scheduleStop(commandWhen);
|
||||||
|
break;
|
||||||
|
case "Seek":
|
||||||
|
this.scheduleSeek(commandWhen, positionTicks);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("SyncPlay PlaybackCore: unrecognized command", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule an unpause at a specific time
|
||||||
|
*/
|
||||||
|
private async scheduleUnpause(
|
||||||
|
playAtTime: Date,
|
||||||
|
positionTicks: number,
|
||||||
|
): Promise<void> {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
|
||||||
|
const positionMs = ticksToMs(positionTicks);
|
||||||
|
|
||||||
|
if (playAtTimeLocal > currentTime) {
|
||||||
|
// Future command - schedule it
|
||||||
|
const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime();
|
||||||
|
|
||||||
|
// Pre-seek only when we're AHEAD of the target by more than the skip
|
||||||
|
// threshold. If we're behind, the unpause itself plays forward and
|
||||||
|
// SkipToSync/SpeedToSync will catch us up — forward-seeking now would
|
||||||
|
// just cause needless buffering. (Matches jellyfin-web.)
|
||||||
|
const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0;
|
||||||
|
const aheadByMs = currentPositionMs - positionMs;
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`,
|
||||||
|
);
|
||||||
|
if (aheadByMs > this.minDelaySkipToSync) {
|
||||||
|
console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`);
|
||||||
|
this.localSeek(positionMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduledCommandTimeout = setTimeout(() => {
|
||||||
|
this.localUnpause();
|
||||||
|
this.onNotifyOsd?.("unpause");
|
||||||
|
|
||||||
|
// Enable sync after a delay
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.syncEnabled = true;
|
||||||
|
}, this.maxDelaySpeedToSync / 2);
|
||||||
|
}, playTimeout);
|
||||||
|
|
||||||
|
console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`);
|
||||||
|
} else {
|
||||||
|
// Past command - play immediately and seek to estimated position
|
||||||
|
const elapsed = currentTime.getTime() - playAtTimeLocal.getTime();
|
||||||
|
const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond;
|
||||||
|
const serverPositionMs = ticksToMs(serverPositionTicks);
|
||||||
|
|
||||||
|
this.localUnpause();
|
||||||
|
this.localSeek(serverPositionMs);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.onNotifyOsd?.("unpause");
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.syncEnabled = true;
|
||||||
|
}, this.maxDelaySpeedToSync / 2);
|
||||||
|
|
||||||
|
console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a pause at a specific time
|
||||||
|
*/
|
||||||
|
private schedulePause(pauseAtTime: Date, positionTicks: number): void {
|
||||||
|
console.log("SyncPlay PlaybackCore: schedulePause called");
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
|
||||||
|
const positionMs = ticksToMs(positionTicks);
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW");
|
||||||
|
|
||||||
|
// If we're already paused at the target position, do nothing.
|
||||||
|
// jellyfin-web gets this for free because HTML5 video's seekTo is a
|
||||||
|
// no-op when the target equals currentTime, and pause() is a no-op
|
||||||
|
// when already paused. Our PlayerControls.seekTo always actually
|
||||||
|
// seeks, which triggers waiting→canplay and a notifyBuffering →
|
||||||
|
// notifyReady cycle. The server reacts by re-sending Pause, which
|
||||||
|
// re-enters this callback → infinite feedback loop. Guarding here
|
||||||
|
// breaks the loop while preserving normal pause behaviour.
|
||||||
|
if (this.playerControls) {
|
||||||
|
const isPlaying = this.playerControls.isPlaying();
|
||||||
|
const currentPositionMs = this.playerControls.getCurrentPosition();
|
||||||
|
const positionMatches =
|
||||||
|
positionMs <= 100 ||
|
||||||
|
Math.abs(currentPositionMs - positionMs) <=
|
||||||
|
POSITION_MATCH_TOLERANCE_MS;
|
||||||
|
if (!isPlaying && positionMatches) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay PlaybackCore: already paused at target position, skipping",
|
||||||
|
);
|
||||||
|
this.onNotifyOsd?.("pause");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.localPause();
|
||||||
|
// Only seek if we have a valid position (not 0 or very small)
|
||||||
|
if (positionMs > 100) {
|
||||||
|
this.localSeek(positionMs);
|
||||||
|
} else {
|
||||||
|
console.log("SyncPlay PlaybackCore: skipping seek (no valid position)");
|
||||||
|
}
|
||||||
|
this.onNotifyOsd?.("pause");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pauseAtTimeLocal > currentTime) {
|
||||||
|
const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime();
|
||||||
|
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
|
||||||
|
console.log(
|
||||||
|
`SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("SyncPlay PlaybackCore: immediate pause (past time)");
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a stop at a specific time
|
||||||
|
*/
|
||||||
|
private scheduleStop(stopAtTime: Date): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
this.localStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stopAtTimeLocal > currentTime) {
|
||||||
|
const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime();
|
||||||
|
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
|
||||||
|
console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
console.debug("SyncPlay: immediate stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a seek at a specific time.
|
||||||
|
*
|
||||||
|
* Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready"
|
||||||
|
* (player finished buffering at the new position) -> pause and report ready
|
||||||
|
* to the server so the group can resume. This handles the common case
|
||||||
|
* where the player must rebuffer after the seek.
|
||||||
|
*/
|
||||||
|
private scheduleSeek(seekAtTime: Date, positionTicks: number): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
|
||||||
|
const positionMs = ticksToMs(positionTicks);
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
this.localUnpause();
|
||||||
|
this.localSeek(positionMs);
|
||||||
|
this.onNotifyOsd?.("seek");
|
||||||
|
|
||||||
|
// Wait for the local player to report ready ("onReady" fires this),
|
||||||
|
// then pause and tell the server we're ready at the new position.
|
||||||
|
waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS)
|
||||||
|
.then(() => {
|
||||||
|
this.localPause();
|
||||||
|
this.sendBufferingRequest(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
`SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.localSeek(positionMs);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (seekAtTimeLocal > currentTime) {
|
||||||
|
const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime();
|
||||||
|
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
|
||||||
|
console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
console.debug("SyncPlay: immediate seek");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear scheduled command
|
||||||
|
*/
|
||||||
|
private clearScheduledCommand(): void {
|
||||||
|
if (this.scheduledCommandTimeout) {
|
||||||
|
clearTimeout(this.scheduledCommandTimeout);
|
||||||
|
this.scheduledCommandTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.syncTimeout) {
|
||||||
|
clearTimeout(this.syncTimeout);
|
||||||
|
this.syncTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncEnabled = false;
|
||||||
|
|
||||||
|
// Reset playback rate
|
||||||
|
if (this.playerControls && this.playerControls.getSpeed() !== 1.0) {
|
||||||
|
this.playerControls.setSpeed(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("syncing", false, "None");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Local Playback Control
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private localUnpause(): void {
|
||||||
|
this.playerControls?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
private localPause(): void {
|
||||||
|
this.playerControls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
private localSeek(positionMs: number): void {
|
||||||
|
console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`);
|
||||||
|
if (this.playerControls) {
|
||||||
|
this.playerControls.seekTo(positionMs);
|
||||||
|
console.log("SyncPlay PlaybackCore: seekTo called on playerControls");
|
||||||
|
} else {
|
||||||
|
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private localStop(): void {
|
||||||
|
this.playerControls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Time Sync
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate current position ticks given a past state
|
||||||
|
*/
|
||||||
|
estimateCurrentTicks(
|
||||||
|
ticks: number,
|
||||||
|
when: Date,
|
||||||
|
currentTime: Date = new Date(),
|
||||||
|
): number {
|
||||||
|
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
|
||||||
|
return (
|
||||||
|
ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync playback time during playback
|
||||||
|
*/
|
||||||
|
syncPlaybackTime(currentPositionMs: number): void {
|
||||||
|
if (!this.playerControls || !this.lastCommand) return;
|
||||||
|
|
||||||
|
// Only sync during unpause
|
||||||
|
if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return;
|
||||||
|
|
||||||
|
// Don't apply sync corrections if the active player isn't on the same
|
||||||
|
// playlist item that the group is playing (e.g. user switched item
|
||||||
|
// locally, or queue update in flight). Prevents seeking the wrong item.
|
||||||
|
const currentItemId = this.getCurrentPlaylistItemId?.();
|
||||||
|
if (
|
||||||
|
currentItemId &&
|
||||||
|
this.lastCommand.PlaylistItemId &&
|
||||||
|
this.lastCommand.PlaylistItemId !== currentItemId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
const currentPositionTicks = msToTicks(currentPositionMs);
|
||||||
|
const lastCommandWhen = this.lastCommand.When
|
||||||
|
? new Date(this.lastCommand.When)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
// Estimate server position
|
||||||
|
const serverPositionTicks = this.estimateCurrentTicks(
|
||||||
|
this.lastCommand.PositionTicks ?? 0,
|
||||||
|
lastCommandWhen,
|
||||||
|
currentTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate difference
|
||||||
|
const diffMillis =
|
||||||
|
(serverPositionTicks - currentPositionTicks) / TicksPerMillisecond;
|
||||||
|
this.playbackDiffMillis = diffMillis;
|
||||||
|
|
||||||
|
this.emit("playback-diff", diffMillis);
|
||||||
|
|
||||||
|
// Rate-limit sync attempts
|
||||||
|
const elapsed = currentTime.getTime() - this.lastSyncTime.getTime();
|
||||||
|
if (elapsed < this.maxDelaySpeedToSync / 2) return;
|
||||||
|
|
||||||
|
this.lastSyncTime = currentTime;
|
||||||
|
|
||||||
|
if (!this.syncEnabled || !this.enableSyncCorrection) return;
|
||||||
|
|
||||||
|
const absDiffMillis = Math.abs(diffMillis);
|
||||||
|
|
||||||
|
// SpeedToSync
|
||||||
|
if (
|
||||||
|
this.useSpeedToSync &&
|
||||||
|
absDiffMillis >= this.minDelaySpeedToSync &&
|
||||||
|
absDiffMillis < this.maxDelaySpeedToSync
|
||||||
|
) {
|
||||||
|
let speedToSyncTime = this.speedToSyncDuration;
|
||||||
|
|
||||||
|
// Prevent negative speed
|
||||||
|
const MinSpeed = 0.2;
|
||||||
|
if (diffMillis <= -speedToSyncTime * MinSpeed) {
|
||||||
|
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speed = 1 + diffMillis / speedToSyncTime;
|
||||||
|
|
||||||
|
if (speed > 0) {
|
||||||
|
this.playerControls.setSpeed(speed);
|
||||||
|
this.syncEnabled = false;
|
||||||
|
this.syncAttempts++;
|
||||||
|
this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`);
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.playerControls?.setSpeed(1.0);
|
||||||
|
this.syncEnabled = true;
|
||||||
|
this.emit("syncing", false, "None");
|
||||||
|
}, speedToSyncTime);
|
||||||
|
|
||||||
|
console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SkipToSync
|
||||||
|
else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
|
||||||
|
const serverPositionMs = ticksToMs(serverPositionTicks);
|
||||||
|
this.localSeek(serverPositionMs);
|
||||||
|
this.syncEnabled = false;
|
||||||
|
this.syncAttempts++;
|
||||||
|
this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`);
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
this.syncEnabled = true;
|
||||||
|
this.emit("syncing", false, "None");
|
||||||
|
}, this.maxDelaySpeedToSync / 2);
|
||||||
|
|
||||||
|
console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`);
|
||||||
|
} else {
|
||||||
|
// Synced
|
||||||
|
if (this.syncAttempts > 0) {
|
||||||
|
console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`);
|
||||||
|
}
|
||||||
|
this.syncAttempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset PlaybackCore state — used when SyncPlay is disabled so we don't
|
||||||
|
* carry stale commands, scheduled timers, or sync state into the next
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
this.lastCommand = null;
|
||||||
|
this.lastSyncTime = new Date();
|
||||||
|
this.syncAttempts = 0;
|
||||||
|
this.playbackDiffMillis = 0;
|
||||||
|
this.playerIsBuffering = false;
|
||||||
|
// Forget what we last told the server so the next session starts fresh.
|
||||||
|
this.lastBufferingSent = null;
|
||||||
|
this.inflightBufferingRequest = null;
|
||||||
|
if (this.notifyBufferingTimeout) {
|
||||||
|
clearTimeout(this.notifyBufferingTimeout);
|
||||||
|
this.notifyBufferingTimeout = null;
|
||||||
|
}
|
||||||
|
// Drop a pending pause-before-ready flag so it can't leak into the
|
||||||
|
// next group.
|
||||||
|
this.pendingInitialPause = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the playback core
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
this.removeAllListeners();
|
||||||
|
this.playerControls = null;
|
||||||
|
this.onNotifyOsd = null;
|
||||||
|
this.getCurrentPlaylistItemId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
292
providers/SyncPlay/QueueCore.ts
Normal file
292
providers/SyncPlay/QueueCore.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* QueueCore
|
||||||
|
*
|
||||||
|
* Manages the shared playlist/queue for SyncPlay.
|
||||||
|
* Handles queue updates from the server.
|
||||||
|
*
|
||||||
|
* Based on jellyfin-web's QueueCore.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "./Helper";
|
||||||
|
import type {
|
||||||
|
GroupRepeatMode,
|
||||||
|
GroupShuffleMode,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QueueCore - Manages the shared playlist
|
||||||
|
*/
|
||||||
|
export class QueueCore extends EventEmitter {
|
||||||
|
// Queue state
|
||||||
|
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
|
||||||
|
private playlist: SyncPlayQueueItem[] = [];
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private onStartPlayback: (() => void) | null = null;
|
||||||
|
private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the start playback callback
|
||||||
|
*/
|
||||||
|
setStartPlaybackHandler(handler: (() => void) | null): void {
|
||||||
|
this.onStartPlayback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the ticks estimator function
|
||||||
|
*/
|
||||||
|
setTicksEstimator(
|
||||||
|
estimator: ((ticks: number, when: Date) => number) | null,
|
||||||
|
): void {
|
||||||
|
this.estimateCurrentTicks = estimator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current playlist
|
||||||
|
*/
|
||||||
|
getPlaylist(): SyncPlayQueueItem[] {
|
||||||
|
return [...this.playlist];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if playlist is empty
|
||||||
|
*/
|
||||||
|
isPlaylistEmpty(): boolean {
|
||||||
|
return this.playlist.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current playing index
|
||||||
|
*/
|
||||||
|
getCurrentPlaylistIndex(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current playlist item ID
|
||||||
|
*/
|
||||||
|
getCurrentPlaylistItemId(): string | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
|
||||||
|
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
|
||||||
|
if (index === undefined || index === -1 || index >= this.playlist.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.playlist[index]?.PlaylistItemId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current item's Jellyfin ID (the actual media item ID)
|
||||||
|
*/
|
||||||
|
getCurrentItemId(): string | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
|
||||||
|
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
|
||||||
|
if (index === undefined || index === -1 || index >= this.playlist.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.playlist[index]?.ItemId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current item from the playlist
|
||||||
|
*/
|
||||||
|
getCurrentItem(): SyncPlayQueueItem | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
|
||||||
|
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
|
||||||
|
if (index === undefined || index === -1 || index >= this.playlist.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.playlist[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last update time
|
||||||
|
*/
|
||||||
|
getLastUpdate(): Date | null {
|
||||||
|
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
|
||||||
|
return lastUpdate ? new Date(lastUpdate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last update time as timestamp
|
||||||
|
*/
|
||||||
|
getLastUpdateTime(): number {
|
||||||
|
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
|
||||||
|
return lastUpdate ? new Date(lastUpdate).getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start position ticks
|
||||||
|
*/
|
||||||
|
getStartPositionTicks(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repeat mode
|
||||||
|
*/
|
||||||
|
getRepeatMode(): GroupRepeatMode {
|
||||||
|
return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shuffle mode
|
||||||
|
*/
|
||||||
|
getShuffleMode(): GroupShuffleMode {
|
||||||
|
return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playlist as item IDs
|
||||||
|
*/
|
||||||
|
getPlaylistAsItemIds(): (string | undefined)[] {
|
||||||
|
return this.playlist.map((item) => item.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the play queue from server
|
||||||
|
*/
|
||||||
|
async updatePlayQueue(update: PlayQueueUpdate): Promise<void> {
|
||||||
|
// Parse the last update time
|
||||||
|
const updateTime = update.LastUpdate
|
||||||
|
? new Date(update.LastUpdate).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Ignore old updates
|
||||||
|
if (updateTime <= this.getLastUpdateTime()) {
|
||||||
|
console.debug("SyncPlay QueueCore: ignoring old update", update);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("SyncPlay QueueCore: processing update", {
|
||||||
|
reason: update.Reason,
|
||||||
|
position: update.StartPositionTicks,
|
||||||
|
index: update.PlayingItemIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for position change (seek)
|
||||||
|
const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
|
||||||
|
const newPosition = update.StartPositionTicks ?? 0;
|
||||||
|
const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference
|
||||||
|
|
||||||
|
// Store the update
|
||||||
|
this.lastPlayQueueUpdate = update;
|
||||||
|
this.playlist = update.Playlist ?? [];
|
||||||
|
|
||||||
|
// Emit update event
|
||||||
|
this.emit("queue-update", update);
|
||||||
|
|
||||||
|
// Handle different update reasons
|
||||||
|
switch (update.Reason) {
|
||||||
|
case "NewPlaylist":
|
||||||
|
// Start playback with new playlist
|
||||||
|
this.onStartPlayback?.();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "SetCurrentItem":
|
||||||
|
case "NextItem":
|
||||||
|
case "PreviousItem":
|
||||||
|
// Item changed
|
||||||
|
this.emit("item-change", this.getCurrentPlaylistItemId());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "RemoveItems":
|
||||||
|
case "MoveItem":
|
||||||
|
case "Queue":
|
||||||
|
case "QueueNext":
|
||||||
|
// Playlist modified
|
||||||
|
this.emit("playlist-change", this.playlist);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "RepeatMode":
|
||||||
|
this.emit("repeat-mode-change", update.RepeatMode);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ShuffleMode":
|
||||||
|
this.emit("shuffle-mode-change", update.ShuffleMode);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay QueueCore: unhandled update reason",
|
||||||
|
update.Reason,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit seek if position changed significantly (likely a seek from another device)
|
||||||
|
if (positionChanged && update.Reason !== "NewPlaylist") {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`,
|
||||||
|
);
|
||||||
|
this.emit("seek", newPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get estimated start position based on last command
|
||||||
|
*/
|
||||||
|
getEstimatedStartPosition(
|
||||||
|
lastCommandPositionTicks: number | null,
|
||||||
|
lastCommandWhen: Date | null,
|
||||||
|
): number {
|
||||||
|
if (lastCommandPositionTicks !== null && lastCommandWhen !== null) {
|
||||||
|
// Use playback command if recent enough
|
||||||
|
if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) {
|
||||||
|
return (
|
||||||
|
this.estimateCurrentTicks?.(
|
||||||
|
lastCommandPositionTicks,
|
||||||
|
lastCommandWhen,
|
||||||
|
) ?? lastCommandPositionTicks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to queue update position
|
||||||
|
const startTicks = this.getStartPositionTicks();
|
||||||
|
const lastUpdate = this.getLastUpdate();
|
||||||
|
if (lastUpdate) {
|
||||||
|
return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the queue
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.lastPlayQueueUpdate = null;
|
||||||
|
this.playlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the queue core
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.clear();
|
||||||
|
this.removeAllListeners();
|
||||||
|
this.onStartPlayback = null;
|
||||||
|
this.estimateCurrentTicks = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
594
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
594
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayProvider
|
||||||
|
*
|
||||||
|
* React context provider for SyncPlay functionality.
|
||||||
|
* Manages the SyncPlay manager and exposes hooks for components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useAppRouter } from "@/hooks/useAppRouter";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SyncPlayController } from "./Controller";
|
||||||
|
import { ticksToMs } from "./Helper";
|
||||||
|
import { SyncPlayManager } from "./Manager";
|
||||||
|
import { PlaybackCore } from "./PlaybackCore";
|
||||||
|
import { QueueCore } from "./QueueCore";
|
||||||
|
import type {
|
||||||
|
GroupInfoDto,
|
||||||
|
PlayerControls,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
SendCommand,
|
||||||
|
SyncPlayOsdAction,
|
||||||
|
SyncPlayStats,
|
||||||
|
} from "./types";
|
||||||
|
import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SyncPlayContextValue {
|
||||||
|
// State
|
||||||
|
isEnabled: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
groupInfo: GroupInfoDto | null;
|
||||||
|
canJoinGroups: boolean;
|
||||||
|
canCreateGroups: boolean;
|
||||||
|
|
||||||
|
// Group management
|
||||||
|
joinGroup: (groupId: string) => Promise<void>;
|
||||||
|
createGroup: (groupName?: string) => Promise<void>;
|
||||||
|
leaveGroup: () => Promise<void>;
|
||||||
|
getGroups: () => Promise<GroupInfoDto[]>;
|
||||||
|
|
||||||
|
// Playback control delegation
|
||||||
|
controller: SyncPlayController | null;
|
||||||
|
|
||||||
|
// Player integration
|
||||||
|
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||||
|
notifyReady: () => void;
|
||||||
|
notifyBuffering: () => void;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
getStats: () => SyncPlayStats;
|
||||||
|
|
||||||
|
// OSD state
|
||||||
|
osdAction: SyncPlayOsdAction | null;
|
||||||
|
isSyncing: boolean;
|
||||||
|
syncMethod: string;
|
||||||
|
/** In-flight Unpause/Pause request, before the server has echoed back. */
|
||||||
|
pendingPlaybackCommand: "Unpause" | "Pause" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SyncPlayProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const router = useAppRouter();
|
||||||
|
|
||||||
|
// Core modules - use state for manager so WebSocket hook re-runs when ready
|
||||||
|
const [manager, setManager] = useState<SyncPlayManager | null>(null);
|
||||||
|
const playbackCoreRef = useRef<PlaybackCore | null>(null);
|
||||||
|
const queueCoreRef = useRef<QueueCore | null>(null);
|
||||||
|
const controllerRef = useRef<SyncPlayController | null>(null);
|
||||||
|
|
||||||
|
// Track if we're already on the player page to avoid duplicate navigations
|
||||||
|
const isNavigatingToPlayerRef = useRef(false);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
|
||||||
|
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [syncMethod, setSyncMethod] = useState("None");
|
||||||
|
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
||||||
|
"Unpause" | "Pause" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
const canJoinGroups = useMemo(() => {
|
||||||
|
const access = user?.Policy?.SyncPlayAccess;
|
||||||
|
return access !== "None" && access !== undefined;
|
||||||
|
}, [user?.Policy?.SyncPlayAccess]);
|
||||||
|
|
||||||
|
const canCreateGroups = useMemo(() => {
|
||||||
|
return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups";
|
||||||
|
}, [user?.Policy?.SyncPlayAccess]);
|
||||||
|
|
||||||
|
// Initialize manager
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
// Create manager and cores
|
||||||
|
const manager = new SyncPlayManager(api);
|
||||||
|
const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore());
|
||||||
|
const queueCore = new QueueCore();
|
||||||
|
const controller = new SyncPlayController(api, manager, queueCore);
|
||||||
|
|
||||||
|
setManager(manager);
|
||||||
|
playbackCoreRef.current = playbackCore;
|
||||||
|
queueCoreRef.current = queueCore;
|
||||||
|
controllerRef.current = controller;
|
||||||
|
|
||||||
|
// Wire up manager callbacks
|
||||||
|
manager.setPlaybackCommandHandler((command: SendCommand) => {
|
||||||
|
playbackCore.applyCommand(command);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.setQueueUpdateHandler((update: PlayQueueUpdate) => {
|
||||||
|
queueCore.updatePlayQueue(update);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.setPlaylistItemIdGetter(() => {
|
||||||
|
return queueCore.getCurrentPlaylistItemId();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When SyncPlay is disabled, flush PlaybackCore's scheduled commands and
|
||||||
|
// cached state so we don't carry ghost commands into the next group.
|
||||||
|
manager.setDisableHandler(() => {
|
||||||
|
playbackCore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also clear the cached PlayQueue snapshot on disable. If we don't, then
|
||||||
|
// when the user later re-joins the same group, the server's first
|
||||||
|
// PlayQueue echo (which can carry the same LastUpdate as the snapshot we
|
||||||
|
// saw last session) gets dropped by QueueCore's stale-update guard, and
|
||||||
|
// the receiver never auto-navigates to the group's content.
|
||||||
|
manager.setQueueClearHandler(() => {
|
||||||
|
queueCore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up playback core callbacks
|
||||||
|
playbackCore.setPlaylistItemIdGetter(() => {
|
||||||
|
return queueCore.getCurrentPlaylistItemId();
|
||||||
|
});
|
||||||
|
|
||||||
|
playbackCore.setOsdHandler((action) => {
|
||||||
|
setOsdAction(action);
|
||||||
|
// Clear after display
|
||||||
|
setTimeout(() => setOsdAction(null), 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up queue core
|
||||||
|
queueCore.setTicksEstimator((ticks, when) => {
|
||||||
|
return playbackCore.estimateCurrentTicks(ticks, when);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to player when group starts playing new content
|
||||||
|
queueCore.setStartPlaybackHandler(async () => {
|
||||||
|
const itemId = queueCore.getCurrentItemId();
|
||||||
|
const startPositionTicks = queueCore.getStartPositionTicks();
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("SyncPlay: new playlist but no current item ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid duplicate navigations
|
||||||
|
if (isNavigatingToPlayerRef.current) {
|
||||||
|
console.debug("SyncPlay: already navigating to player");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("SyncPlay: navigating to player for item", itemId);
|
||||||
|
isNavigatingToPlayerRef.current = true;
|
||||||
|
|
||||||
|
// Mirror jellyfin-web's `QueueCore.startPlayback` ordering:
|
||||||
|
// 1. followGroupPlayback (IgnoreWait:false) — tell server we follow
|
||||||
|
// 2. scheduleReadyRequestOnPlaybackStart — arm initial pause
|
||||||
|
// 3. playerWrapper.localPlay (== our router navigation) — start loading
|
||||||
|
// The arm-then-navigate order matters: scheduling must happen BEFORE
|
||||||
|
// navigation so the flag is set when the player attaches and fires
|
||||||
|
// its first `notifyReady`. Otherwise we race the player and the
|
||||||
|
// initial SyncPlayReady reports `IsPlaying:true`, defeating the
|
||||||
|
// server's "hold the group until everyone is parked" semantics.
|
||||||
|
await manager.followGroupPlayback();
|
||||||
|
playbackCore.scheduleReadyRequestOnPlaybackStart();
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
toast(i18n.t("syncplay.joining_playback"));
|
||||||
|
|
||||||
|
// Navigate to the player with the item. Use `replace` so repeated
|
||||||
|
// queue updates don't stack player screens on the history.
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: itemId,
|
||||||
|
playbackPosition: startPositionTicks.toString(),
|
||||||
|
syncPlay: "true", // Mark this as a SyncPlay-initiated playback
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
router.push(`/player/direct-player?${queryParams}` as any);
|
||||||
|
|
||||||
|
// Reset navigation flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
isNavigatingToPlayerRef.current = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also handle item changes (next/previous in playlist)
|
||||||
|
queueCore.on("item-change", () => {
|
||||||
|
const newItemId = queueCore.getCurrentItemId();
|
||||||
|
const startPositionTicks = queueCore.getStartPositionTicks();
|
||||||
|
|
||||||
|
if (!newItemId) {
|
||||||
|
console.warn("SyncPlay: item change but no current item ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid duplicate navigations
|
||||||
|
if (isNavigatingToPlayerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("SyncPlay: item changed, navigating to", newItemId);
|
||||||
|
isNavigatingToPlayerRef.current = true;
|
||||||
|
|
||||||
|
// Same pause-before-ready dance as NewPlaylist — the new item's
|
||||||
|
// player needs to park at the start position and report
|
||||||
|
// IsPlaying:false so the server holds the group until everyone is
|
||||||
|
// ready for the next Unpause. Mirrors jellyfin-web's
|
||||||
|
// `QueueCore.setCurrentPlaylistItem`.
|
||||||
|
playbackCore.scheduleReadyRequestOnPlaybackStart();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: newItemId,
|
||||||
|
playbackPosition: startPositionTicks.toString(),
|
||||||
|
syncPlay: "true",
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
router.push(`/player/direct-player?${queryParams}`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isNavigatingToPlayerRef.current = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle seek events from other devices - pause first, then seek (like Jellyfin-web)
|
||||||
|
queueCore.on("seek", (...args: unknown[]) => {
|
||||||
|
const positionTicks = args[0] as number;
|
||||||
|
const positionMs = ticksToMs(positionTicks);
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: seek event received, pausing then seeking to",
|
||||||
|
positionMs,
|
||||||
|
"ms",
|
||||||
|
);
|
||||||
|
const playerControls = manager.getPlayerControls();
|
||||||
|
if (playerControls) {
|
||||||
|
playerControls.pause();
|
||||||
|
playerControls.seekTo(positionMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to manager events
|
||||||
|
manager.on("enabled", (...args: unknown[]) => {
|
||||||
|
const enabled = args[0] as boolean;
|
||||||
|
setIsEnabled(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
setIsReady(false);
|
||||||
|
setGroupInfoDto(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.on("syncing", (...args: unknown[]) => {
|
||||||
|
const syncing = args[0] as boolean;
|
||||||
|
const method = args[1] as string;
|
||||||
|
setIsSyncing(syncing);
|
||||||
|
setSyncMethod(method);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep React-side groupInfo in sync with Manager mutations. Without this,
|
||||||
|
// CenterControls' `groupInfo.State === 'Waiting'` check is stale because
|
||||||
|
// Manager mutates the existing object reference rather than emitting a
|
||||||
|
// fresh one.
|
||||||
|
manager.on("group-info-change", (...args: unknown[]) => {
|
||||||
|
setGroupInfoDto(args[0] as GroupInfoDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose pending Unpause/Pause to consumers (e.g. CenterControls renders
|
||||||
|
// a spinner instead of the play/pause button while a request is in
|
||||||
|
// flight — mirrors jellyfin-web's "schedule-play" indicator).
|
||||||
|
manager.on("pending-playback-change", (...args: unknown[]) => {
|
||||||
|
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When entering Waiting state, report ready through PlaybackCore
|
||||||
|
manager.on("waiting-for-ready", () => {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()",
|
||||||
|
);
|
||||||
|
playbackCore.onReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek)
|
||||||
|
manager.on("seek-from-state-update", (...args: unknown[]) => {
|
||||||
|
const positionTicks = args[0] as number;
|
||||||
|
const positionMs = ticksToMs(positionTicks);
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: seek from StateUpdate, seeking to",
|
||||||
|
positionMs,
|
||||||
|
"ms",
|
||||||
|
);
|
||||||
|
const playerControls = manager.getPlayerControls();
|
||||||
|
if (playerControls) {
|
||||||
|
playerControls.pause();
|
||||||
|
playerControls.seekTo(positionMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
manager.init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
manager.destroy();
|
||||||
|
playbackCore.destroy();
|
||||||
|
queueCore.destroy();
|
||||||
|
setManager(null);
|
||||||
|
playbackCoreRef.current = null;
|
||||||
|
queueCoreRef.current = null;
|
||||||
|
controllerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// Update group info when enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEnabled && manager) {
|
||||||
|
setGroupInfoDto(manager.getGroupInfo());
|
||||||
|
setIsReady(manager.isSyncPlayReady());
|
||||||
|
}
|
||||||
|
}, [isEnabled, manager]);
|
||||||
|
|
||||||
|
// Connect to WebSocket messages - manager is now state so hook re-runs when ready
|
||||||
|
useSyncPlayWebSocket(manager);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Group Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
||||||
|
if (!api) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(api);
|
||||||
|
const response = await syncPlayApi.syncPlayGetGroups();
|
||||||
|
return (response.data as unknown as GroupInfoDto[]) ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to get groups", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const joinGroup = useCallback(
|
||||||
|
async (groupId: string): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(api);
|
||||||
|
await syncPlayApi.syncPlayJoinGroup({
|
||||||
|
joinGroupRequestDto: {
|
||||||
|
GroupId: groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to join group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createGroup = useCallback(
|
||||||
|
async (groupName?: string): Promise<void> => {
|
||||||
|
if (!api || !user) return;
|
||||||
|
|
||||||
|
const name = groupName || `${user.Name}'s Group`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(api);
|
||||||
|
await syncPlayApi.syncPlayCreateGroup({
|
||||||
|
newGroupRequestDto: {
|
||||||
|
GroupName: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to create group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveGroup = useCallback(async (): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const syncPlayApi = getSyncPlayApi(api);
|
||||||
|
await syncPlayApi.syncPlayLeaveGroup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to leave group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// Re-join the SyncPlay group when the app returns from background.
|
||||||
|
//
|
||||||
|
// Backgrounding tears down our WebSocket (see WebSocketProvider) and the
|
||||||
|
// server may drop us from the group after its inactivity timeout. Even
|
||||||
|
// when it doesn't, we likely missed any commands/state-updates broadcast
|
||||||
|
// while we were suspended. Re-issuing the join is idempotent on the
|
||||||
|
// server and gets us a fresh GroupJoined snapshot.
|
||||||
|
const lastGroupIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
|
||||||
|
}, [groupInfo?.GroupId]);
|
||||||
|
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
const previousAppState = appStateRef.current;
|
||||||
|
appStateRef.current = nextAppState;
|
||||||
|
|
||||||
|
const becameActive =
|
||||||
|
(previousAppState === "background" ||
|
||||||
|
previousAppState === "inactive") &&
|
||||||
|
nextAppState === "active";
|
||||||
|
|
||||||
|
if (!becameActive) return;
|
||||||
|
|
||||||
|
const groupId = lastGroupIdRef.current;
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
// Give the WebSocket a moment to reconnect (handled by
|
||||||
|
// WebSocketProvider on the same 'active' transition) so the
|
||||||
|
// server's GroupJoined broadcast actually reaches us.
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`);
|
||||||
|
getSyncPlayApi(api)
|
||||||
|
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("SyncPlay: failed to rejoin group", error);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Player Integration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const setPlayerControls = useCallback(
|
||||||
|
(controls: PlayerControls | null) => {
|
||||||
|
manager?.setPlayerControls(controls);
|
||||||
|
playbackCoreRef.current?.setPlayerControls(controls);
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyReady = useCallback(() => {
|
||||||
|
console.log("SyncPlay: notifyReady called");
|
||||||
|
playbackCoreRef.current?.onReady();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notifyBuffering = useCallback(() => {
|
||||||
|
console.log("SyncPlay: notifyBuffering called");
|
||||||
|
playbackCoreRef.current?.onBuffering();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stats
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getStats = useCallback((): SyncPlayStats => {
|
||||||
|
return (
|
||||||
|
manager?.getStats() ?? {
|
||||||
|
timeSyncDevice: "None",
|
||||||
|
timeSyncOffset: "0.00",
|
||||||
|
playbackDiff: "0.00",
|
||||||
|
syncMethod: "None",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Value
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const contextValue: SyncPlayContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
isEnabled,
|
||||||
|
isReady,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
controller: controllerRef.current,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
getStats,
|
||||||
|
osdAction,
|
||||||
|
isSyncing,
|
||||||
|
syncMethod,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
isEnabled,
|
||||||
|
isReady,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
getStats,
|
||||||
|
osdAction,
|
||||||
|
isSyncing,
|
||||||
|
syncMethod,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SyncPlayContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SyncPlayContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access SyncPlay state and actions
|
||||||
|
*/
|
||||||
|
export function useSyncPlay(): SyncPlayContextValue {
|
||||||
|
const context = useContext(SyncPlayContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the SyncPlay controller
|
||||||
|
*/
|
||||||
|
export function useSyncPlayController(): SyncPlayController | null {
|
||||||
|
const { controller } = useSyncPlay();
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
284
providers/SyncPlay/TimeSyncCore.ts
Normal file
284
providers/SyncPlay/TimeSyncCore.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* TimeSyncCore
|
||||||
|
*
|
||||||
|
* Manages time synchronization with the Jellyfin server.
|
||||||
|
* Uses NTP-like algorithm to calculate clock offset between client and server.
|
||||||
|
*
|
||||||
|
* Based on jellyfin-web's TimeSyncCore.js and TimeSync.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import type { TimeSyncMeasurement } from "./types";
|
||||||
|
|
||||||
|
// Time estimation constants
|
||||||
|
const NumberOfTrackedMeasurements = 8;
|
||||||
|
const PollingIntervalGreedy = 1000; // ms - fast polling initially
|
||||||
|
const PollingIntervalLowProfile = 60000; // ms - slow polling once synced
|
||||||
|
const GreedyPingCount = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a single time sync measurement
|
||||||
|
*/
|
||||||
|
class Measurement {
|
||||||
|
requestSent: number;
|
||||||
|
requestReceived: number;
|
||||||
|
responseSent: number;
|
||||||
|
responseReceived: number;
|
||||||
|
|
||||||
|
constructor(data: TimeSyncMeasurement) {
|
||||||
|
this.requestSent = data.requestSent;
|
||||||
|
this.requestReceived = data.requestReceived;
|
||||||
|
this.responseSent = data.responseSent;
|
||||||
|
this.responseReceived = data.responseReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate time offset from server, in milliseconds.
|
||||||
|
* Offset = (t1 - t0 + t2 - t3) / 2
|
||||||
|
* where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received
|
||||||
|
*/
|
||||||
|
getOffset(): number {
|
||||||
|
return (
|
||||||
|
(this.requestReceived -
|
||||||
|
this.requestSent +
|
||||||
|
(this.responseSent - this.responseReceived)) /
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get round-trip delay, in milliseconds.
|
||||||
|
*/
|
||||||
|
getDelay(): number {
|
||||||
|
return (
|
||||||
|
this.responseReceived -
|
||||||
|
this.requestSent -
|
||||||
|
(this.responseSent - this.requestReceived)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ping time (half of round-trip), in milliseconds.
|
||||||
|
*/
|
||||||
|
getPing(): number {
|
||||||
|
return this.getDelay() / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeSyncEventCallback = (
|
||||||
|
error: Error | null,
|
||||||
|
timeOffset: number | null,
|
||||||
|
ping: number | null,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimeSyncCore - Manages time synchronization with the server
|
||||||
|
*/
|
||||||
|
export class TimeSyncCore {
|
||||||
|
private api: Api;
|
||||||
|
private poller: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pingStop = true;
|
||||||
|
private pollingInterval = PollingIntervalGreedy;
|
||||||
|
private pings = 0;
|
||||||
|
private measurement: Measurement | null = null;
|
||||||
|
private measurements: Measurement[] = [];
|
||||||
|
private extraTimeOffset = 0;
|
||||||
|
private onUpdateCallback: TimeSyncEventCallback | null = null;
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for time sync updates
|
||||||
|
*/
|
||||||
|
onUpdate(callback: TimeSyncEventCallback): void {
|
||||||
|
this.onUpdateCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if time sync is ready (has at least one measurement)
|
||||||
|
*/
|
||||||
|
isReady(): boolean {
|
||||||
|
return this.measurement !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current time offset with server, in milliseconds.
|
||||||
|
*/
|
||||||
|
getTimeOffset(): number {
|
||||||
|
return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current ping time to server, in milliseconds.
|
||||||
|
*/
|
||||||
|
getPing(): number {
|
||||||
|
return this.measurement?.getPing() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set extra time offset for manual adjustment
|
||||||
|
*/
|
||||||
|
setExtraTimeOffset(offset: number): void {
|
||||||
|
this.extraTimeOffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert server time to local time.
|
||||||
|
*/
|
||||||
|
remoteDateToLocal(remote: Date): Date {
|
||||||
|
// remote - local = offset, so local = remote - offset
|
||||||
|
return new Date(remote.getTime() - this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert local time to server time.
|
||||||
|
*/
|
||||||
|
localDateToRemote(local: Date): Date {
|
||||||
|
// remote - local = offset, so remote = local + offset
|
||||||
|
return new Date(local.getTime() + this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name of the sync device
|
||||||
|
*/
|
||||||
|
getActiveDeviceName(): string {
|
||||||
|
return "Server";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a ping request to the server to measure time offset
|
||||||
|
*/
|
||||||
|
private async requestPing(): Promise<TimeSyncMeasurement> {
|
||||||
|
const requestSent = Date.now();
|
||||||
|
|
||||||
|
const timeSyncApi = getTimeSyncApi(this.api);
|
||||||
|
const response = await timeSyncApi.getUtcTime();
|
||||||
|
|
||||||
|
const responseReceived = Date.now();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
const requestReceived = new Date(data.RequestReceptionTime!).getTime();
|
||||||
|
const responseSent = new Date(data.ResponseTransmissionTime!).getTime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestSent,
|
||||||
|
requestReceived,
|
||||||
|
responseSent,
|
||||||
|
responseReceived,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update time offset with a new measurement
|
||||||
|
*/
|
||||||
|
private updateTimeOffset(measurement: Measurement): void {
|
||||||
|
this.measurements.push(measurement);
|
||||||
|
|
||||||
|
if (this.measurements.length > NumberOfTrackedMeasurements) {
|
||||||
|
this.measurements.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick measurement with minimum delay (most accurate)
|
||||||
|
const sortedMeasurements = [...this.measurements].sort(
|
||||||
|
(a, b) => a.getDelay() - b.getDelay(),
|
||||||
|
);
|
||||||
|
this.measurement = sortedMeasurements[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal poller for ping requests
|
||||||
|
*/
|
||||||
|
private internalRequestPing(): void {
|
||||||
|
if (this.poller !== null || this.pingStop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.poller = setTimeout(async () => {
|
||||||
|
this.poller = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.requestPing();
|
||||||
|
this.onPingSuccess(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.onPingError(error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next ping
|
||||||
|
this.internalRequestPing();
|
||||||
|
}, this.pollingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful ping response
|
||||||
|
*/
|
||||||
|
private onPingSuccess(result: TimeSyncMeasurement): void {
|
||||||
|
const measurement = new Measurement(result);
|
||||||
|
this.updateTimeOffset(measurement);
|
||||||
|
|
||||||
|
// Slow down polling after initial greedy phase
|
||||||
|
if (this.pings >= GreedyPingCount) {
|
||||||
|
this.pollingInterval = PollingIntervalLowProfile;
|
||||||
|
} else {
|
||||||
|
this.pings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ping error
|
||||||
|
*/
|
||||||
|
private onPingError(error: Error): void {
|
||||||
|
console.error("SyncPlay TimeSyncCore: ping error", error);
|
||||||
|
this.onUpdateCallback?.(error, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the time sync poller
|
||||||
|
*/
|
||||||
|
startPing(): void {
|
||||||
|
this.pingStop = false;
|
||||||
|
this.internalRequestPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the time sync poller
|
||||||
|
*/
|
||||||
|
stopPing(): void {
|
||||||
|
this.pingStop = true;
|
||||||
|
if (this.poller !== null) {
|
||||||
|
clearTimeout(this.poller);
|
||||||
|
this.poller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force an immediate update (reset to greedy mode)
|
||||||
|
*/
|
||||||
|
forceUpdate(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.pollingInterval = PollingIntervalGreedy;
|
||||||
|
this.pings = 0;
|
||||||
|
this.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop all accumulated measurements
|
||||||
|
*/
|
||||||
|
resetMeasurements(): void {
|
||||||
|
this.measurement = null;
|
||||||
|
this.measurements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.resetMeasurements();
|
||||||
|
this.onUpdateCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
providers/SyncPlay/index.ts
Normal file
25
providers/SyncPlay/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Module
|
||||||
|
*
|
||||||
|
* Synchronized playback for Jellyfin.
|
||||||
|
* Allows multiple users to watch content together in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SyncPlayController } from "./Controller";
|
||||||
|
// Helpers
|
||||||
|
export * from "./Helper";
|
||||||
|
// Core modules
|
||||||
|
export { SyncPlayManager } from "./Manager";
|
||||||
|
export { PlaybackCore } from "./PlaybackCore";
|
||||||
|
export { QueueCore } from "./QueueCore";
|
||||||
|
|
||||||
|
// Provider and hooks
|
||||||
|
export {
|
||||||
|
SyncPlayProvider,
|
||||||
|
useSyncPlay,
|
||||||
|
useSyncPlayController,
|
||||||
|
} from "./SyncPlayProvider";
|
||||||
|
export { TimeSyncCore } from "./TimeSyncCore";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from "./types";
|
||||||
139
providers/SyncPlay/types.ts
Normal file
139
providers/SyncPlay/types.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Types
|
||||||
|
*
|
||||||
|
* Re-exports Jellyfin SDK types and defines app-specific types.
|
||||||
|
* Following the pattern used in offline downloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Re-export SDK Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Group types
|
||||||
|
// Playback command types
|
||||||
|
// Queue types
|
||||||
|
// User access type
|
||||||
|
// Group update message types
|
||||||
|
export type {
|
||||||
|
GroupInfoDto,
|
||||||
|
GroupRepeatMode,
|
||||||
|
GroupShuffleMode,
|
||||||
|
GroupStateType,
|
||||||
|
GroupUpdate,
|
||||||
|
GroupUpdateType,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
PlayQueueUpdateReason,
|
||||||
|
SendCommand,
|
||||||
|
SendCommandType,
|
||||||
|
SyncPlayGroupJoinedUpdate,
|
||||||
|
SyncPlayGroupLeftUpdate,
|
||||||
|
SyncPlayPlayQueueUpdate,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
SyncPlayStateUpdate,
|
||||||
|
SyncPlayUserAccessType,
|
||||||
|
SyncPlayUserJoinedUpdate,
|
||||||
|
SyncPlayUserLeftUpdate,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const TicksPerMillisecond = 10000;
|
||||||
|
export const WaitForEventDefaultTimeout = 30000; // milliseconds
|
||||||
|
export const WaitForPlayerEventTimeout = 500; // milliseconds
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App-Specific Types (not in SDK)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time sync measurement for NTP-like synchronization
|
||||||
|
*/
|
||||||
|
export interface TimeSyncMeasurement {
|
||||||
|
requestSent: number;
|
||||||
|
requestReceived: number;
|
||||||
|
responseSent: number;
|
||||||
|
responseReceived: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player controls interface for integrating with MPV player
|
||||||
|
*/
|
||||||
|
export interface PlayerControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
seekTo: (positionMs: number) => void;
|
||||||
|
setSpeed: (speed: number) => void;
|
||||||
|
getSpeed: () => number;
|
||||||
|
getCurrentPosition: () => number;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
isBuffering: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSD action types for visual feedback
|
||||||
|
*/
|
||||||
|
export type SyncPlayOsdAction =
|
||||||
|
| "schedule-play"
|
||||||
|
| "unpause"
|
||||||
|
| "pause"
|
||||||
|
| "seek"
|
||||||
|
| "buffering"
|
||||||
|
| "wait-pause"
|
||||||
|
| "wait-unpause";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncPlay settings for sync correction algorithms
|
||||||
|
*/
|
||||||
|
export interface SyncPlaySettings {
|
||||||
|
// SpeedToSync settings
|
||||||
|
minDelaySpeedToSync: number;
|
||||||
|
maxDelaySpeedToSync: number;
|
||||||
|
speedToSyncDuration: number;
|
||||||
|
|
||||||
|
// SkipToSync settings
|
||||||
|
minDelaySkipToSync: number;
|
||||||
|
|
||||||
|
// Feature toggles
|
||||||
|
useSpeedToSync: boolean;
|
||||||
|
useSkipToSync: boolean;
|
||||||
|
enableSyncCorrection: boolean;
|
||||||
|
|
||||||
|
// Time sync
|
||||||
|
extraTimeOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = {
|
||||||
|
minDelaySpeedToSync: 60.0,
|
||||||
|
maxDelaySpeedToSync: 3000.0,
|
||||||
|
speedToSyncDuration: 1000.0,
|
||||||
|
minDelaySkipToSync: 400.0,
|
||||||
|
useSpeedToSync: true,
|
||||||
|
useSkipToSync: true,
|
||||||
|
enableSyncCorrection: false,
|
||||||
|
extraTimeOffset: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats for debugging/display
|
||||||
|
*/
|
||||||
|
export interface SyncPlayStats {
|
||||||
|
timeSyncDevice: string;
|
||||||
|
timeSyncOffset: string;
|
||||||
|
playbackDiff: string;
|
||||||
|
syncMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play options for starting playback
|
||||||
|
*/
|
||||||
|
export interface PlayOptions {
|
||||||
|
ids?: string[];
|
||||||
|
items?: BaseItemDto[];
|
||||||
|
startIndex?: number;
|
||||||
|
startPositionTicks?: number;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
87
providers/SyncPlay/useSyncPlayWebSocket.ts
Normal file
87
providers/SyncPlay/useSyncPlayWebSocket.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* useSyncPlayWebSocket
|
||||||
|
*
|
||||||
|
* Hook that connects the SyncPlay manager to WebSocket messages.
|
||||||
|
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
|
||||||
|
*
|
||||||
|
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
|
||||||
|
* rather than reading WebSocketProvider's `lastMessage` state. That state
|
||||||
|
* only holds the most recent message, so when the server emits bursts
|
||||||
|
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
|
||||||
|
* within a few ms), React's batching causes earlier messages to be
|
||||||
|
* overwritten before our effect can read them — most notably the
|
||||||
|
* GroupJoined message, which left the joining client thinking it hadn't
|
||||||
|
* joined while other members already saw it as a participant.
|
||||||
|
*
|
||||||
|
* Listening on the raw socket guarantees we see every frame in order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
import type { SyncPlayManager } from "./Manager";
|
||||||
|
import type { SendCommand } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to connect SyncPlay manager to WebSocket
|
||||||
|
*/
|
||||||
|
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
|
||||||
|
const { ws } = useWebSocketContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ws || !manager) return;
|
||||||
|
|
||||||
|
const handleMessage = (event: WebSocketMessageEvent) => {
|
||||||
|
let parsed: { MessageType?: string; Data?: unknown };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data as string);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to parse WebSocket message", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { MessageType, Data } = parsed;
|
||||||
|
|
||||||
|
// Only handle SyncPlay messages here; everything else is handled
|
||||||
|
// elsewhere via WebSocketProvider's lastMessage.
|
||||||
|
if (!MessageType?.startsWith("SyncPlay")) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay WebSocket [${MessageType}]:`,
|
||||||
|
JSON.stringify(Data).substring(0, 300),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (MessageType) {
|
||||||
|
case "SyncPlayCommand": {
|
||||||
|
const command = Data as SendCommand;
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
|
||||||
|
command.Command === "Seek"
|
||||||
|
? `position=${command.PositionTicks}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: it's normal for controls to be missing here during the
|
||||||
|
// join → navigate → load window. Manager stashes the command and
|
||||||
|
// replays it on attach.
|
||||||
|
manager.processCommand(command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SyncPlayGroupUpdate": {
|
||||||
|
const update = Data as { Type?: string; Data?: unknown };
|
||||||
|
console.debug("SyncPlay: group update -", update.Type);
|
||||||
|
manager.processGroupUpdate(update);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
ws.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, [ws, manager]);
|
||||||
|
}
|
||||||
@@ -998,6 +998,30 @@
|
|||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"syncplay": {
|
||||||
|
"title": "SyncPlay",
|
||||||
|
"my_group": "My Group",
|
||||||
|
"join_group": "Join Group",
|
||||||
|
"leave_group": "Leave Group",
|
||||||
|
"create_group": "Create Group",
|
||||||
|
"create_new_group": "Create New Group",
|
||||||
|
"available_groups": "Available Groups",
|
||||||
|
"group_id": "Group ID",
|
||||||
|
"leader": "Leader",
|
||||||
|
"members": "members",
|
||||||
|
"enabled": "SyncPlay enabled",
|
||||||
|
"disabled": "SyncPlay disabled",
|
||||||
|
"user_joined": "{{username}} joined the group",
|
||||||
|
"user_left": "{{username}} left the group",
|
||||||
|
"permission_required": "Permission required to use SyncPlay",
|
||||||
|
"group_does_not_exist": "Group does not exist",
|
||||||
|
"create_denied": "Permission denied to create group",
|
||||||
|
"join_denied": "Permission denied to join group",
|
||||||
|
"library_access_denied": "Library access denied",
|
||||||
|
"waiting_for_group": "Waiting for group...",
|
||||||
|
"joining_playback": "Joining group playback...",
|
||||||
|
"failed_to_start": "Failed to start SyncPlay group playback"
|
||||||
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Pair with TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Align the QR code within the frame",
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
|||||||
Reference in New Issue
Block a user