mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 13:38:27 +01:00
Compare commits
15 Commits
v0.54.1
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab42e8a576 | ||
|
|
0e93cd5385 | ||
|
|
96b4121c1f | ||
|
|
f7033e7abb | ||
|
|
0d796d01b8 | ||
|
|
46d96d5965 | ||
|
|
7d16e7d5c7 | ||
|
|
ceb9b5a1ae | ||
|
|
1144ff5049 | ||
|
|
4d508a4315 | ||
|
|
915a4febbb | ||
|
|
88163eb531 | ||
|
|
27c400a54a | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 |
@@ -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)_
|
||||
|
||||
- **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";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
const SyncPlayButtonComponent = Platform.isTV
|
||||
? null
|
||||
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
@@ -33,6 +36,7 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
|
||||
@@ -40,6 +40,8 @@ const Layout = () => {
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarActiveTintColor: "#FFFFFF",
|
||||
tabBarInactiveTintColor: "#9CA3AF",
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||
@@ -49,7 +50,8 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -77,6 +79,11 @@ export default function DirectPlayerPage() {
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
|
||||
// Keep the global WebSocket open while in PiP so SyncPlay commands
|
||||
// (and any other server pushes) keep flowing while iOS treats the
|
||||
// app as backgrounded. See `WebSocketProvider.acquireKeepAlive`.
|
||||
useKeepWebSocketAlive(isPipMode);
|
||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||
"default",
|
||||
);
|
||||
@@ -128,6 +135,7 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
syncPlay: syncPlayStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -137,9 +145,23 @@ export default function DirectPlayerPage() {
|
||||
offline: string;
|
||||
/** Playback position in ticks. */
|
||||
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();
|
||||
|
||||
// SyncPlay integration
|
||||
const syncPlay = useSyncPlay();
|
||||
const {
|
||||
isEnabled: isSyncPlayEnabled,
|
||||
controller: syncPlayController,
|
||||
setPlayerControls,
|
||||
notifyBuffering,
|
||||
} = syncPlay;
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||
@@ -274,6 +296,11 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -316,6 +343,12 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -388,6 +421,7 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -403,8 +437,72 @@ export default function DirectPlayerPage() {
|
||||
reportPlaybackStart();
|
||||
}, [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;
|
||||
// notifyBuffering routes through the debouncer in PlaybackCore so
|
||||
// re-renders during a stall don't spam the server.
|
||||
notifyBuffering(!isLocallyReady);
|
||||
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
|
||||
// Route through SyncPlay when active
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
syncPlayController.playPause();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
@@ -427,21 +525,15 @@ export default function DirectPlayerPage() {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream.sessionId,
|
||||
await getPlaystateApi(api).reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
@@ -459,9 +551,10 @@ export default function DirectPlayerPage() {
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
reportPlaybackStopped();
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
}, [navigation, stop, reportPlaybackStopped]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(():
|
||||
| PlaybackProgressInfo
|
||||
@@ -638,10 +731,12 @@ export default function DirectPlayerPage() {
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// 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 = {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
autoplay: shouldAutoplay,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
@@ -836,6 +931,41 @@ export default function DirectPlayerPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
// PiP playback controls. When SyncPlay is active, the native side
|
||||
// is told to *delegate* these via `syncPlayDelegated`, so the OS
|
||||
// play/pause/skip buttons emit these events instead of poking MPV
|
||||
// directly. We route them through the SyncPlay controller so the
|
||||
// server broadcasts a command to every group member (including us).
|
||||
const _onPipPlayRequest = useCallback(() => {
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
console.log("SyncPlay: PiP play → controller.playPause()");
|
||||
syncPlayController.playPause();
|
||||
}
|
||||
}, [isSyncPlayEnabled, syncPlayController]);
|
||||
|
||||
const _onPipPauseRequest = useCallback(() => {
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
console.log("SyncPlay: PiP pause → controller.playPause()");
|
||||
syncPlayController.playPause();
|
||||
}
|
||||
}, [isSyncPlayEnabled, syncPlayController]);
|
||||
|
||||
const _onPipSkipRequest = useCallback(
|
||||
(e: {
|
||||
nativeEvent: { targetSeconds: number; intervalSeconds: number };
|
||||
}) => {
|
||||
if (!isSyncPlayEnabled || !syncPlayController) return;
|
||||
const { targetSeconds } = e.nativeEvent;
|
||||
// SyncPlay seek takes ticks (1 s = 10_000_000 ticks).
|
||||
const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000));
|
||||
console.log(
|
||||
`SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`,
|
||||
);
|
||||
syncPlayController.seek(ticks);
|
||||
},
|
||||
[isSyncPlayEnabled, syncPlayController],
|
||||
);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Add useEffect to handle mounting
|
||||
@@ -860,10 +990,21 @@ export default function DirectPlayerPage() {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
const seek = useCallback(
|
||||
(position: number) => {
|
||||
// Route through SyncPlay when active. `position` is in ms; the
|
||||
// controller takes ticks (1 ms = 10000 ticks).
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||
syncPlayController.seek(Math.round(position * 10000));
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
},
|
||||
[isSyncPlayEnabled, syncPlayController],
|
||||
);
|
||||
|
||||
// TV audio track change handler
|
||||
const handleAudioIndexChange = useCallback(
|
||||
@@ -1255,6 +1396,10 @@ export default function DirectPlayerPage() {
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onPictureInPictureChange={_onPictureInPictureChange}
|
||||
syncPlayDelegated={isSyncPlayEnabled}
|
||||
onPipPlayRequest={_onPipPlayRequest}
|
||||
onPipPauseRequest={_onPipPauseRequest}
|
||||
onPipSkipRequest={_onPipSkipRequest}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
|
||||
254
app/_layout.tsx
254
app/_layout.tsx
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -409,133 +410,136 @@ function Layout() {
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<SyncPlayProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
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
|
||||
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
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</SyncPlayProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||
const [triggerSize, setTriggerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setTriggerSize((prev) =>
|
||||
prev && prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height },
|
||||
);
|
||||
};
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
// height depends on the parent while the parent depends on the Host — a
|
||||
// circular dependency that collapses to 0 for any selector nested more than
|
||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||
// fills a concrete box.
|
||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||
return (
|
||||
<View style={triggerSize ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
<View>
|
||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -67,6 +68,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
|
||||
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
|
||||
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
|
||||
// then performs the navigation uniformly for everyone, matching
|
||||
// jellyfin-web's playbackManager intercept (Controller.play).
|
||||
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
||||
useSyncPlay();
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
const effectiveColors = colors || globalColorAtom;
|
||||
|
||||
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
// SyncPlay intercept: in a group, route playback through sthe server so
|
||||
// every member gets the same PlayQueue: NewPlaylist update and navigates
|
||||
// together. Skips local navigation and the Chromecast prompt entirely —
|
||||
// SyncPlay + Chromecast isn't a supported combination yet, same as
|
||||
// jellyfin-web.
|
||||
if (isSyncPlayEnabled && syncPlayController && item.Id) {
|
||||
try {
|
||||
// Pass the full `item` (not just the ID) so the SyncPlay controller
|
||||
// can run `translateItemsForPlayback` with full context — this is
|
||||
// what jellyfin-web does, and it lets us expand Series / Season /
|
||||
// BoxSet into real episode/track IDs before broadcasting the queue.
|
||||
// Without expansion, receivers (jellyfin-web in particular) get
|
||||
// container IDs they can't play and silently fail to open the
|
||||
// player.
|
||||
await syncPlayController.play({
|
||||
items: [item],
|
||||
ids: [item.Id],
|
||||
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay: failed to start group playback", error);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("syncplay.failed_to_start", {
|
||||
defaultValue: "Failed to start SyncPlay group playback",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
isSyncPlayEnabled,
|
||||
syncPlayController,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeArea = useControlsSafeAreaInsets();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
@@ -79,7 +81,17 @@ function ChapterListComponent({
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
marginLeft: safeArea.left,
|
||||
marginRight: safeArea.right,
|
||||
paddingBottom: safeArea.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* SyncPlayActionIcon
|
||||
*
|
||||
* In-button SyncPlay status indicator — drops into the player's
|
||||
* play/pause button slot and replaces the normal play/pause/loader
|
||||
* graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's
|
||||
* `#syncPlayIcon` element (see `showIcon()` in
|
||||
* `jellyfin-web/src/controllers/playback/video/index.js`).
|
||||
*
|
||||
* Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch):
|
||||
*
|
||||
* action primary secondary pulse spin
|
||||
* --------------- ------------- ----------------- ---------- ----
|
||||
* schedule-play sync play (centered) infinite yes
|
||||
* unpause play-circle — one-shot no
|
||||
* pause pause-circle — one-shot no
|
||||
* seek refresh — infinite no
|
||||
* buffering clock — infinite no
|
||||
* wait-pause clock pause (shifted) infinite no
|
||||
* wait-unpause clock play (shifted) infinite no
|
||||
*
|
||||
* Material → Ionicons mapping used here:
|
||||
* sync → sync, schedule → time-outline, update → refresh-outline,
|
||||
* play_arrow → play, pause → pause,
|
||||
* play_circle_outline → play-circle-outline,
|
||||
* pause_circle_outline → pause-circle-outline.
|
||||
*
|
||||
* When no SyncPlay action is active the component renders `fallback`
|
||||
* so callers can keep the normal play/pause/loader graphic.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import type { SyncPlayOsdAction } from "@/providers/SyncPlay";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
|
||||
// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color)
|
||||
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||
|
||||
type IoniconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
type SecondaryPosition = "centered" | "shifted";
|
||||
|
||||
interface SecondaryIcon {
|
||||
icon: IoniconName;
|
||||
position: SecondaryPosition;
|
||||
}
|
||||
|
||||
interface OsdConfig {
|
||||
/** Primary icon — fills the available size. */
|
||||
icon: IoniconName;
|
||||
/** Optional smaller overlay (~42% size). */
|
||||
secondary?: SecondaryIcon;
|
||||
/** Wrapper-level scale animation. */
|
||||
pulse: "infinite" | "oneshot";
|
||||
/** Rotate the primary icon continuously (secondary stays still). */
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG: Record<SyncPlayOsdAction, OsdConfig> = {
|
||||
"schedule-play": {
|
||||
icon: "sync",
|
||||
secondary: { icon: "play", position: "centered" },
|
||||
pulse: "infinite",
|
||||
spin: true,
|
||||
},
|
||||
unpause: { icon: "play-circle-outline", pulse: "oneshot" },
|
||||
pause: { icon: "pause-circle-outline", pulse: "oneshot" },
|
||||
seek: { icon: "refresh-outline", pulse: "infinite" },
|
||||
buffering: { icon: "time-outline", pulse: "infinite" },
|
||||
"wait-pause": {
|
||||
icon: "time-outline",
|
||||
secondary: { icon: "pause", position: "shifted" },
|
||||
pulse: "infinite",
|
||||
},
|
||||
"wait-unpause": {
|
||||
icon: "time-outline",
|
||||
secondary: { icon: "play", position: "shifted" },
|
||||
pulse: "infinite",
|
||||
},
|
||||
};
|
||||
|
||||
interface SyncPlayActionIconProps {
|
||||
size: number;
|
||||
color?: string;
|
||||
/** Rendered when no SyncPlay action is active. */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function SyncPlayActionIcon({
|
||||
size,
|
||||
color = SYNC_PLAY_COLOR,
|
||||
fallback = null,
|
||||
}: SyncPlayActionIconProps) {
|
||||
const { osdAction } = useSyncPlay();
|
||||
|
||||
const rotation = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(rotation);
|
||||
cancelAnimation(scale);
|
||||
rotation.value = 0;
|
||||
scale.value = 1;
|
||||
|
||||
if (!osdAction) return;
|
||||
|
||||
const config = CONFIG[osdAction];
|
||||
|
||||
if (config.spin) {
|
||||
rotation.value = withRepeat(
|
||||
withTiming(360, { duration: 1200, easing: Easing.linear }),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.pulse === "infinite") {
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, {
|
||||
duration: 700,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
}),
|
||||
withTiming(0.95, {
|
||||
duration: 700,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
}),
|
||||
),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// one-shot: single scale flash; the provider clears the action
|
||||
// ~1500ms later (transient OSD) so the icon then unmounts.
|
||||
scale.value = withSequence(
|
||||
withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }),
|
||||
withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }),
|
||||
);
|
||||
}
|
||||
}, [osdAction, rotation, scale]);
|
||||
|
||||
const pulseStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const spinStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
if (!osdAction) return <>{fallback}</>;
|
||||
|
||||
const config = CONFIG[osdAction];
|
||||
const secondarySize = Math.round(size * 0.42);
|
||||
|
||||
// centered: geometric middle of the primary (e.g. play arrow inside
|
||||
// the spinning `sync` ring for schedule-play).
|
||||
// shifted: bottom-right corner (e.g. play/pause badge on the clock
|
||||
// for wait-unpause / wait-pause).
|
||||
const secondaryPosStyle =
|
||||
config.secondary?.position === "centered"
|
||||
? {
|
||||
top: (size - secondarySize) / 2,
|
||||
left: (size - secondarySize) / 2,
|
||||
}
|
||||
: { bottom: 0, right: 0 };
|
||||
|
||||
return (
|
||||
<Animated.View style={pulseStyle}>
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
|
||||
<Ionicons name={config.icon} size={size} color={color} />
|
||||
</Animated.View>
|
||||
|
||||
{config.secondary && (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={[styles.secondary, secondaryPosStyle]}
|
||||
>
|
||||
<Ionicons
|
||||
name={config.secondary.icon}
|
||||
size={secondarySize}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
secondary: {
|
||||
position: "absolute",
|
||||
},
|
||||
});
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
8
components/syncplay/index.ts
Normal file
8
components/syncplay/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* SyncPlay UI Components
|
||||
*/
|
||||
|
||||
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
|
||||
export { SyncPlayButton } from "./SyncPlayButton";
|
||||
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
@@ -75,9 +75,6 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
bottom:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: Math.max(insets.bottom - 17, 0),
|
||||
},
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center ml-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
@@ -42,15 +43,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
@@ -121,15 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
<SyncPlayActionIcon
|
||||
size={ICON_SIZES.CENTER}
|
||||
fallback={
|
||||
!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
});
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -5,12 +5,11 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { orientation, lockOrientation } = useOrientation();
|
||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -16,8 +16,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
|
||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/**
|
||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||
* returns zero insets so controls can sit flush against the screen edges.
|
||||
*/
|
||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let blob: Blob;
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
try {
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
22
hooks/useKeepWebSocketAlive.ts
Normal file
22
hooks/useKeepWebSocketAlive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
/**
|
||||
* While `active` is true, hold a keep-alive token on the global
|
||||
* WebSocket so it is NOT closed when the app moves to
|
||||
* background/inactive. Releases automatically when `active` flips
|
||||
* false or the component unmounts.
|
||||
*
|
||||
* Used by the video player while in Picture-in-Picture so SyncPlay
|
||||
* commands (and any other server pushes) keep flowing while the OS
|
||||
* thinks the app is backgrounded.
|
||||
*/
|
||||
export function useKeepWebSocketAlive(active: boolean): void {
|
||||
const { acquireKeepAlive } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const release = acquireKeepAlive();
|
||||
return release;
|
||||
}, [active, acquireKeepAlive]);
|
||||
}
|
||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derive prev/next from the current item's real position in the adjacent
|
||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||
* episode it can still return the current item as the first/last entry — so
|
||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||
*/
|
||||
const currentIndex = useMemo(
|
||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||
[adjacentItems, item],
|
||||
);
|
||||
|
||||
/** A neighbour is only navigable if it has an actual media file (not a
|
||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex <= 0) return null;
|
||||
const candidate = adjacentItems[currentIndex - 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/** The next item in the series */
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex < 0) return null;
|
||||
const candidate = adjacentItems[currentIndex + 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
||||
playTrailers,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const { lastMessage } = useWebSocketContext();
|
||||
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||
const { t } = useTranslation();
|
||||
const { clearLastMessage } = useWebSocketContext();
|
||||
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) return;
|
||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
||||
| Record<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") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
|
||||
@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
|
||||
// No-op on Android - media session integration would require MediaSessionCompat
|
||||
}
|
||||
|
||||
// When true, PiP play/pause/skip controls emit JS events
|
||||
// instead of driving MPV directly, so the host app can route
|
||||
// through SyncPlay (server -> group broadcast -> all clients).
|
||||
Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean ->
|
||||
view.syncPlayDelegated = delegated
|
||||
}
|
||||
|
||||
// Async function to play video
|
||||
AsyncFunction("play") { view: MpvPlayerView ->
|
||||
view.play()
|
||||
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||
// (play / pause / skip) emit these events instead of driving MPV
|
||||
// directly, so JS can route the action through the SyncPlay
|
||||
// controller (server -> group broadcast -> all clients). Default
|
||||
// behavior (non-SyncPlay) is unchanged.
|
||||
val onPipPlayRequest by EventDispatcher()
|
||||
val onPipPauseRequest by EventDispatcher()
|
||||
val onPipSkipRequest by EventDispatcher()
|
||||
|
||||
var syncPlayDelegated: Boolean = false
|
||||
|
||||
private var textureView: TextureView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
if (syncPlayDelegated) {
|
||||
onPipPlayRequest(mapOf<String, Any>())
|
||||
return
|
||||
}
|
||||
play()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (syncPlayDelegated) {
|
||||
onPipPauseRequest(mapOf<String, Any>())
|
||||
return
|
||||
}
|
||||
pause()
|
||||
}
|
||||
|
||||
override fun onSeekBy(seconds: Double) {
|
||||
if (syncPlayDelegated) {
|
||||
val target = (cachedPosition + seconds).coerceAtLeast(0.0)
|
||||
onPipSkipRequest(
|
||||
mapOf(
|
||||
"targetSeconds" to target,
|
||||
"intervalSeconds" to seconds
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
seekBy(seconds)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
// When true, PiP play/pause/skip controls emit JS events instead
|
||||
// of driving MPV directly, so the host app can route through
|
||||
// SyncPlay (server -> group broadcast -> all clients).
|
||||
Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in
|
||||
view.syncPlayDelegated = delegated
|
||||
}
|
||||
|
||||
// Async function to play video
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||
// (play / pause / skip) emit these events instead of driving MPV
|
||||
// directly, so JS can route the action through the SyncPlay
|
||||
// controller (server → group broadcast → all clients). Default
|
||||
// behavior (non-SyncPlay) is unchanged.
|
||||
let onPipPlayRequest = EventDispatcher()
|
||||
let onPipPauseRequest = EventDispatcher()
|
||||
let onPipSkipRequest = EventDispatcher()
|
||||
|
||||
var syncPlayDelegated: Bool = false
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
@@ -81,7 +92,6 @@ class MpvPlayerView: ExpoView {
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
configureAudioSession()
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -141,21 +151,26 @@ class MpvPlayerView: ExpoView {
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func configureAudioSession() {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
policy: .longFormAudio,
|
||||
options: []
|
||||
)
|
||||
try audioSession.setActive(true)
|
||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
}
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||
private func tearDownAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
@@ -270,6 +285,7 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
configureAudioSession()
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -440,6 +456,7 @@ class MpvPlayerView: ExpoView {
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
tearDownAudioSession()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@@ -519,9 +536,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||
nowPlayingManager.activateAudioSession()
|
||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
@@ -633,6 +648,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -651,6 +669,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
@@ -660,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
print("PiP play requested")
|
||||
if syncPlayDelegated {
|
||||
// Let JS route through SyncPlay. We deliberately do NOT touch
|
||||
// MPV here; the WS command coming back will drive playback.
|
||||
onPipPlayRequest([:])
|
||||
return
|
||||
}
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -667,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
print("PiP pause requested")
|
||||
if syncPlayDelegated {
|
||||
onPipPauseRequest([:])
|
||||
return
|
||||
}
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
@@ -676,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
let seconds = CMTimeGetSeconds(interval)
|
||||
print("PiP skip by interval: \(seconds)")
|
||||
let target = max(0, cachedPosition + seconds)
|
||||
if syncPlayDelegated {
|
||||
// `targetSeconds` lets JS convert to ticks and call
|
||||
// syncPlayController.seek(). `intervalSeconds` is also sent
|
||||
// for telemetry / debug.
|
||||
onPipSkipRequest([
|
||||
"targetSeconds": target,
|
||||
"intervalSeconds": seconds
|
||||
])
|
||||
return
|
||||
}
|
||||
seekTo(position: target)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emitted when the user taps a PiP playback control while the view
|
||||
* was rendered with `syncPlayDelegated`. The host app should route
|
||||
* the action through the SyncPlay controller instead of acting
|
||||
* locally.
|
||||
*/
|
||||
export type OnPipPlayRequestPayload = Record<string, never>;
|
||||
export type OnPipPauseRequestPayload = Record<string, never>;
|
||||
export type OnPipSkipRequestPayload = {
|
||||
/** Absolute target position the user wants to seek to, in seconds. */
|
||||
targetSeconds: number;
|
||||
/** Skip interval requested by the OS (signed seconds). Debug only. */
|
||||
intervalSeconds: number;
|
||||
};
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
|
||||
onPictureInPictureChange?: (event: {
|
||||
nativeEvent: OnPictureInPictureChangePayload;
|
||||
}) => void;
|
||||
/**
|
||||
* When true, PiP play/pause/skip controls emit the corresponding
|
||||
* `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest`
|
||||
* events instead of driving MPV directly. Used to route PiP control
|
||||
* actions through SyncPlay.
|
||||
*/
|
||||
syncPlayDelegated?: boolean;
|
||||
onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void;
|
||||
onPipPauseRequest?: (event: {
|
||||
nativeEvent: OnPipPauseRequestPayload;
|
||||
}) => void;
|
||||
onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
|
||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||
let cachedDb: DownloadsDatabase | null = null;
|
||||
let cacheVersion = 0;
|
||||
|
||||
// Performance optimization: Cache the flattened items array
|
||||
let cachedItems: DownloadedItem[] | null = null;
|
||||
let itemsCacheVersion = -1;
|
||||
|
||||
// Performance optimization: Index for O(1) item lookups by ID
|
||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||
let indexCacheVersion = -1;
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
// Return cached database if available
|
||||
if (cachedDb !== null) {
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
// Parse from storage and cache the result
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||
return cachedDb;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
|
||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||
cachedDb = emptyDb;
|
||||
return emptyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
// Update the cache with the new database
|
||||
cachedDb = db;
|
||||
// Invalidate derived caches (items array and index)
|
||||
cachedItems = null;
|
||||
itemIndex = null;
|
||||
cacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
// Return cached items if available and up-to-date
|
||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
// Build the items array from the database
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedItems = items;
|
||||
itemsCacheVersion = cacheVersion;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* Build or refresh the item index for O(1) lookups
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
function ensureItemIndex(): void {
|
||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||
return; // Index is up-to-date
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
// Build new index from all items
|
||||
itemIndex = new Map<string, DownloadedItem>();
|
||||
const items = getAllDownloadedItems();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.item.Id) {
|
||||
itemIndex.set(item.item.Id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
indexCacheVersion = cacheVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
ensureItemIndex();
|
||||
return itemIndex!.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
// saveDownloadsDatabase already invalidates caches
|
||||
}
|
||||
|
||||
@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
// Dismiss splash screen with cached data immediately,
|
||||
// fetch fresh user data in the background
|
||||
setInitialLoaded(true);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Background fetch failed — app already rendered with cached data
|
||||
console.warn("Background user fetch failed, using cached data:", e);
|
||||
}
|
||||
} else {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
165
providers/SyncPlay/Controller.ts
Normal file
165
providers/SyncPlay/Controller.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* SyncPlay Controller — public playback API exposed to consumers.
|
||||
*
|
||||
* Methods are fire-and-forget by design: SyncPlay HTTP responses don't
|
||||
* carry useful info (the real state arrives via WebSocket broadcast).
|
||||
* Wrap calls in try/catch so transient network errors don't reach the UI.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type { SyncPlayManager } from "./Manager";
|
||||
import {
|
||||
getItemsForPlayback,
|
||||
type TranslateOptions,
|
||||
translateItemsForPlayback,
|
||||
} from "./transport/queueTranslation";
|
||||
|
||||
export interface PlayOptions extends TranslateOptions {
|
||||
items?: BaseItemDto[];
|
||||
ids?: string[];
|
||||
startIndex?: number;
|
||||
startPositionTicks?: number;
|
||||
}
|
||||
|
||||
export class Controller {
|
||||
private manager!: SyncPlayManager;
|
||||
|
||||
init(manager: SyncPlayManager): void {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/** Toggle play/pause for the whole group. */
|
||||
playPause(): void {
|
||||
if (this.manager.isPlaying()) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.unpause();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume the group's playback. */
|
||||
unpause(): void {
|
||||
this.manager.markPendingPlaybackCommand("Unpause");
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.unpause failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause the group's playback. */
|
||||
pause(): void {
|
||||
this.manager.markPendingPlaybackCommand("Pause");
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.pause failed", error);
|
||||
}
|
||||
// Pause locally too so the user sees instant feedback.
|
||||
this.manager.getPlayerWrapper().localPause();
|
||||
}
|
||||
|
||||
/** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
|
||||
seek(positionTicks: number): void {
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
|
||||
seekRequestDto: { PositionTicks: positionTicks },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.seek failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback in the group. Expands containers (Series, Season,
|
||||
* BoxSet, Playlist, single Episode w/ autoplay) into the real
|
||||
* playable queue before broadcasting.
|
||||
*
|
||||
* Resolves once the SetNewQueue request completes; the server then
|
||||
* broadcasts a PlayQueue update and Play command to every member.
|
||||
*/
|
||||
async play(options: PlayOptions): Promise<void> {
|
||||
const api = this.manager.getApiClient();
|
||||
|
||||
const sendPlayRequest = async (items: BaseItemDto[]) => {
|
||||
const queue = items
|
||||
.map((item) => item.Id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
await getSyncPlayApi(api).syncPlaySetNewQueue({
|
||||
playRequestDto: {
|
||||
PlayingQueue: queue,
|
||||
PlayingItemPosition: options.startIndex ?? 0,
|
||||
StartPositionTicks: options.startPositionTicks ?? 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const sourceItems = options.items
|
||||
? options.items
|
||||
: await getItemsForPlayback(api, options.ids ?? []);
|
||||
const items = await translateItemsForPlayback(api, sourceItems, options);
|
||||
await sendPlayRequest(items);
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.play failed", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the group's playback. */
|
||||
stop(): void {
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.stop failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump to the next item in the group's queue. */
|
||||
nextItem(): void {
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
|
||||
nextItemRequestDto: {
|
||||
PlaylistItemId: this.manager
|
||||
.getQueueCore()
|
||||
.getCurrentPlaylistItemId(),
|
||||
} as unknown as Parameters<
|
||||
ReturnType<typeof getSyncPlayApi>["syncPlayNextItem"]
|
||||
>[0]["nextItemRequestDto"],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.nextItem failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump to the previous item in the group's queue. */
|
||||
previousItem(): void {
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
|
||||
previousItemRequestDto: {
|
||||
PlaylistItemId: this.manager
|
||||
.getQueueCore()
|
||||
.getCurrentPlaylistItemId(),
|
||||
} as unknown as Parameters<
|
||||
ReturnType<typeof getSyncPlayApi>["syncPlayPreviousItem"]
|
||||
>[0]["previousItemRequestDto"],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.previousItem failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump to a specific item in the queue by playlist item id. */
|
||||
setCurrentPlaylistItem(playlistItemId: string): void {
|
||||
try {
|
||||
getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
|
||||
setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Controller;
|
||||
93
providers/SyncPlay/EventEmitter.ts
Normal file
93
providers/SyncPlay/EventEmitter.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Per-instance event emitter — replaces jellyfin-web's global `Events.trigger`
|
||||
* bus. Listeners that throw are caught and logged so one bad listener can't
|
||||
* break the rest.
|
||||
*/
|
||||
|
||||
import { WaitForEventDefaultTimeout } from "./constants";
|
||||
|
||||
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(
|
||||
`SyncPlay EventEmitter: handler for "${event}" threw`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeAllListeners(event?: string): void {
|
||||
if (event) {
|
||||
this.listeners.delete(event);
|
||||
} else {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve on the next emission of `event`, or reject after `timeoutMs`
|
||||
* (or any event in `rejectEventTypes`). Cleans up every listener.
|
||||
*/
|
||||
export function waitForEventOnce(
|
||||
emitter: EventEmitter,
|
||||
event: string,
|
||||
timeoutMs: number = WaitForEventDefaultTimeout,
|
||||
rejectEventTypes?: string[],
|
||||
): Promise<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearAll = () => {
|
||||
emitter.off(event, handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (Array.isArray(rejectEventTypes)) {
|
||||
for (const eventName of rejectEventTypes) {
|
||||
emitter.off(eventName, rejectCallback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handler = (...args: unknown[]) => {
|
||||
clearAll();
|
||||
resolve(args);
|
||||
};
|
||||
|
||||
const rejectCallback = (...args: unknown[]) => {
|
||||
clearAll();
|
||||
reject(args[0] ?? new Error("rejected"));
|
||||
};
|
||||
|
||||
if (timeoutMs) {
|
||||
timer = setTimeout(() => {
|
||||
clearAll();
|
||||
reject(new Error("Timed out."));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
emitter.on(event, handler);
|
||||
|
||||
if (Array.isArray(rejectEventTypes)) {
|
||||
for (const eventName of rejectEventTypes) {
|
||||
emitter.on(eventName, rejectCallback);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
383
providers/SyncPlay/Manager.ts
Normal file
383
providers/SyncPlay/Manager.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* SyncPlayManager — central orchestrator for a SyncPlay session.
|
||||
*
|
||||
* Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the
|
||||
* PlayerWrapper, and routes WebSocket events between them.
|
||||
*
|
||||
* Lifecycle:
|
||||
* constructor → init() → (joinGroup → group-state-change "Idle"+) →
|
||||
* group-state-change "Playing" → group-state-change "Paused" → ...
|
||||
* → (leaveGroup) → destroy()
|
||||
*
|
||||
* Events emitted (provider listens):
|
||||
* - `group-info-update` `(GroupInfoDto | null)`
|
||||
* - `group-state-change` `(state: string, oldState: string)`
|
||||
* - `enabled` `(isEnabled: boolean)`
|
||||
* - `play-state-change` `(isFollowing: boolean)`
|
||||
* - `playbackstart` / `playbackerror` — from PlayerWrapper hooks
|
||||
* - `osd` `(action: SyncPlayOsdAction)`
|
||||
* - `toast` `(messageKey: string)`
|
||||
*
|
||||
* The manager exposes a per-instance `EventEmitter` rather than upstream
|
||||
* `Events.on(manager, ...)` — replaces the global Events bus pattern.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { Controller } from "./Controller";
|
||||
import { PlaybackCore } from "./cores/PlaybackCore";
|
||||
import { QueueCore } from "./cores/QueueCore";
|
||||
import { TimeSync } from "./cores/TimeSync";
|
||||
import { EventEmitter } from "./EventEmitter";
|
||||
import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker";
|
||||
import { PlayerWrapper } from "./player/PlayerWrapper";
|
||||
import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach";
|
||||
import type {
|
||||
GroupInfoDto,
|
||||
GroupUpdate,
|
||||
PlayerControls,
|
||||
PlayQueueUpdate,
|
||||
SendCommand,
|
||||
} from "./types";
|
||||
|
||||
/** Raw WebSocket message data shapes (already unwrapped by the hook). */
|
||||
|
||||
export class SyncPlayManager extends EventEmitter {
|
||||
private apiClient: Api;
|
||||
private playerWrapper: PlayerWrapper;
|
||||
private timeSync: TimeSync;
|
||||
private playbackCore: PlaybackCore;
|
||||
private queueCore: QueueCore;
|
||||
private pendingPlaybackTracker: PendingPlaybackTracker;
|
||||
private controller: Controller;
|
||||
|
||||
/** Current group info. `null` when not in a group. */
|
||||
private groupInfo: GroupInfoDto | null = null;
|
||||
/** Is SyncPlay actively enabled (i.e., we're in a group)? */
|
||||
private syncPlayEnabledAtPlayer = false;
|
||||
/** Are we mirroring the group's commands locally? */
|
||||
private followingGroupPlayback = true;
|
||||
|
||||
constructor(api: Api) {
|
||||
super();
|
||||
this.apiClient = api;
|
||||
this.playerWrapper = new PlayerWrapper();
|
||||
this.timeSync = new TimeSync(api);
|
||||
this.playbackCore = new PlaybackCore();
|
||||
this.queueCore = new QueueCore();
|
||||
this.pendingPlaybackTracker = new PendingPlaybackTracker();
|
||||
this.controller = new Controller();
|
||||
}
|
||||
|
||||
/** Wire up cores. Called once after construction. */
|
||||
init(): void {
|
||||
this.playbackCore.init(this);
|
||||
this.queueCore.init(this);
|
||||
this.controller.init(this);
|
||||
|
||||
// Forward PlaybackCore OSD events to provider listeners.
|
||||
this.playbackCore.on("osd", (...args) => {
|
||||
this.emit("osd", ...args);
|
||||
});
|
||||
|
||||
// Bridge optimistic pending Pause/Unpause → React state.
|
||||
this.pendingPlaybackTracker.setChangeHandler((cmd) => {
|
||||
this.emit("pending-playback-change", cmd);
|
||||
});
|
||||
|
||||
this.timeSync.startPing();
|
||||
}
|
||||
|
||||
/** Public controller for callers. */
|
||||
getController(): Controller {
|
||||
return this.controller;
|
||||
}
|
||||
|
||||
/** Called by SyncPlayProvider when the user switches Jellyfin servers. */
|
||||
updateApiClient(api: Api): void {
|
||||
this.apiClient = api;
|
||||
this.timeSync.updateApiClient(api);
|
||||
}
|
||||
|
||||
getApiClient(): Api {
|
||||
return this.apiClient;
|
||||
}
|
||||
|
||||
getPlayerWrapper(): PlayerWrapper {
|
||||
return this.playerWrapper;
|
||||
}
|
||||
|
||||
getTimeSync(): TimeSync {
|
||||
return this.timeSync;
|
||||
}
|
||||
|
||||
getPlaybackCore(): PlaybackCore {
|
||||
return this.playbackCore;
|
||||
}
|
||||
|
||||
getQueueCore(): QueueCore {
|
||||
return this.queueCore;
|
||||
}
|
||||
|
||||
getPendingPlaybackTracker(): PendingPlaybackTracker {
|
||||
return this.pendingPlaybackTracker;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// WebSocket message handlers (called by useSyncPlayWebSocket)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle a `SyncPlayGroupUpdate` WebSocket message.
|
||||
*
|
||||
* Cast: the SDK's `GroupUpdate.Type` union is narrower than what the
|
||||
* server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`,
|
||||
* `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source
|
||||
* of truth here.
|
||||
*/
|
||||
processGroupUpdate(rawUpdate: GroupUpdate): void {
|
||||
if (!rawUpdate) {
|
||||
console.warn("SyncPlay processGroupUpdate: empty update");
|
||||
return;
|
||||
}
|
||||
const update = rawUpdate as unknown as {
|
||||
Type: string;
|
||||
Data: unknown;
|
||||
};
|
||||
|
||||
switch (update.Type) {
|
||||
case "PlayQueue":
|
||||
this.queueCore.updatePlayQueue(
|
||||
this.apiClient,
|
||||
update.Data as unknown as PlayQueueUpdate,
|
||||
);
|
||||
break;
|
||||
|
||||
case "UserJoined":
|
||||
case "UserLeft":
|
||||
// Group membership notifications — current group will follow
|
||||
// via GroupUpdate, but emit a toast for friendliness.
|
||||
this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data);
|
||||
break;
|
||||
|
||||
case "GroupJoined": {
|
||||
this.groupInfo = update.Data as GroupInfoDto;
|
||||
this.enableSyncPlay(this.groupInfo);
|
||||
this.emit("group-update", this.groupInfo);
|
||||
this.emit("toast", "MessageSyncPlayGroupJoined");
|
||||
break;
|
||||
}
|
||||
|
||||
case "GroupLeft":
|
||||
case "NotInGroup":
|
||||
case "SyncPlayIsDisabled": {
|
||||
const previousState = this.groupInfo?.State;
|
||||
this.groupInfo = null;
|
||||
this.disableSyncPlay();
|
||||
this.emit("group-update", null);
|
||||
if (update.Type === "GroupLeft") {
|
||||
this.emit("toast", "MessageSyncPlayGroupLeft");
|
||||
}
|
||||
if (previousState) {
|
||||
this.emit("group-state-change", "Idle", previousState);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "GroupUpdate": {
|
||||
const previousState = this.groupInfo?.State;
|
||||
this.groupInfo = update.Data as GroupInfoDto;
|
||||
this.emit("group-update", this.groupInfo);
|
||||
const newState = this.groupInfo.State;
|
||||
if (newState && newState !== previousState) {
|
||||
this.emit("group-state-change", newState, previousState ?? "Idle");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "StateUpdate": {
|
||||
const stateData = update.Data as {
|
||||
State?: string;
|
||||
PreviousState?: string;
|
||||
Reason?: string;
|
||||
};
|
||||
const newState = stateData.State ?? "Idle";
|
||||
const previousState = stateData.PreviousState ?? "Idle";
|
||||
const reason = stateData.Reason;
|
||||
if (this.groupInfo) {
|
||||
this.groupInfo.State = newState as GroupInfoDto["State"];
|
||||
this.emit("group-update", this.groupInfo);
|
||||
}
|
||||
this.emit("group-state-change", newState, previousState, reason);
|
||||
// Server signals "Playing" or "Paused" → clear any in-flight
|
||||
// optimistic tap state.
|
||||
if (newState === "Playing" || newState === "Paused") {
|
||||
this.pendingPlaybackTracker.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "CreateGroupDenied":
|
||||
this.emit("toast", "MessageSyncPlayCreateGroupDenied");
|
||||
break;
|
||||
case "JoinGroupDenied":
|
||||
this.emit("toast", "MessageSyncPlayJoinGroupDenied");
|
||||
break;
|
||||
case "LibraryAccessDenied":
|
||||
this.emit("toast", "MessageSyncPlayLibraryAccessDenied");
|
||||
break;
|
||||
case "GroupDoesNotExist":
|
||||
this.emit("toast", "MessageSyncPlayGroupDoesNotExist");
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("SyncPlay processGroupUpdate: unknown type", update.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a `SyncPlayCommand` WebSocket message. */
|
||||
processCommand(command: SendCommand): void {
|
||||
if (!command) {
|
||||
console.warn("SyncPlay processCommand: empty command");
|
||||
return;
|
||||
}
|
||||
this.playbackCore.applyCommand(command);
|
||||
// Server told us the new playing state — clear optimistic UI.
|
||||
if (command.Command === "Unpause" || command.Command === "Pause") {
|
||||
this.pendingPlaybackTracker.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Enable / disable SyncPlay
|
||||
// ===========================================================================
|
||||
|
||||
private enableSyncPlay(_group: GroupInfoDto): void {
|
||||
if (this.syncPlayEnabledAtPlayer) return;
|
||||
this.syncPlayEnabledAtPlayer = true;
|
||||
this.followingGroupPlayback = true;
|
||||
this.timeSync.forceUpdate();
|
||||
this.emit("enabled", true);
|
||||
this.emit("play-state-change", true);
|
||||
}
|
||||
|
||||
private disableSyncPlay(): void {
|
||||
if (!this.syncPlayEnabledAtPlayer) return;
|
||||
this.syncPlayEnabledAtPlayer = false;
|
||||
this.followingGroupPlayback = false;
|
||||
this.playbackCore.clearScheduledCommand();
|
||||
this.queueCore.clear();
|
||||
this.pendingPlaybackTracker.clear();
|
||||
this.emit("enabled", false);
|
||||
this.emit("play-state-change", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume following group playback after the user temporarily took
|
||||
* local control (e.g. scrubbed the seek bar).
|
||||
*/
|
||||
async followGroupPlayback(_api: Api): Promise<void> {
|
||||
this.followingGroupPlayback = true;
|
||||
this.emit("play-state-change", true);
|
||||
}
|
||||
|
||||
/** Stop following group playback (e.g., user takes local control). */
|
||||
haltGroupPlayback(_api: Api): void {
|
||||
this.followingGroupPlayback = false;
|
||||
this.emit("play-state-change", false);
|
||||
}
|
||||
|
||||
isFollowingGroupPlayback(): boolean {
|
||||
return this.followingGroupPlayback;
|
||||
}
|
||||
|
||||
isSyncPlayEnabled(): boolean {
|
||||
return this.syncPlayEnabledAtPlayer;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Player attach + provider bridges
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Bind the RN player controls.
|
||||
* Called from the player screen's `useEffect`. Triggers a reconcile
|
||||
* if a group is active and the player is late-arriving.
|
||||
*/
|
||||
setPlayerControls(controls: PlayerControls | null): void {
|
||||
this.playerWrapper.bindToControls(controls);
|
||||
if (controls && this.syncPlayEnabledAtPlayer) {
|
||||
const lastCommand = this.playbackCore.getLastCommand();
|
||||
reconcileToGroupOnAttach(controls, lastCommand, (local) =>
|
||||
this.timeSync.localDateToRemote(local),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Player-side notify hook: media is ready to play. */
|
||||
notifyReady(): void {
|
||||
this.emit("playbackstart");
|
||||
if (this.syncPlayEnabledAtPlayer) {
|
||||
this.playbackCore.onReady(this.apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
/** Player-side notify hook: buffering state changed. */
|
||||
notifyBuffering(isBuffering: boolean): void {
|
||||
if (!this.syncPlayEnabledAtPlayer) return;
|
||||
if (isBuffering) {
|
||||
this.playbackCore.onBuffering(this.apiClient);
|
||||
} else {
|
||||
this.playbackCore.onReady(this.apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
/** Player-side notify hook: local playback started. */
|
||||
notifyPlaybackStart(): void {
|
||||
this.emit("playbackstart");
|
||||
if (this.syncPlayEnabledAtPlayer) {
|
||||
this.playbackCore.onPlaybackStart(this.apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Pending playback (optimistic UI for play/pause taps)
|
||||
// ===========================================================================
|
||||
|
||||
/** Called by Controller before sending an Unpause/Pause request. */
|
||||
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
|
||||
this.pendingPlaybackTracker.mark(command);
|
||||
}
|
||||
|
||||
/** Is the group currently playing? Used by Controller.playPause. */
|
||||
isPlaying(): boolean {
|
||||
const pending = this.pendingPlaybackTracker.get();
|
||||
if (pending === "Unpause") return true;
|
||||
if (pending === "Pause") return false;
|
||||
return this.groupInfo?.State === "Playing";
|
||||
}
|
||||
|
||||
/** Group info for consumers. */
|
||||
getGroupInfo(): GroupInfoDto | null {
|
||||
return this.groupInfo;
|
||||
}
|
||||
|
||||
/** Last playback command (for QueueCore.startPlayback resumption). */
|
||||
getLastPlaybackCommand(): SendCommand | null {
|
||||
return this.playbackCore.getLastCommand();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Teardown
|
||||
// ===========================================================================
|
||||
|
||||
destroy(): void {
|
||||
this.timeSync.destroy();
|
||||
this.playbackCore.destroy();
|
||||
this.queueCore.destroy();
|
||||
this.playerWrapper.bindToControls(null);
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export default SyncPlayManager;
|
||||
527
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
527
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* SyncPlayProvider — React glue around `SyncPlayManager`.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Manager lifecycle (construct on api change, destroy on unmount)
|
||||
* - React mirrors of manager state (`isEnabled`, `groupInfo`,
|
||||
* `pendingPlaybackCommand`) so components re-render
|
||||
* - Navigation handlers wired into `PlayerWrapper.localPlay` /
|
||||
* `localSetCurrentPlaylistItem` — these are what jellyfin-web does
|
||||
* synchronously via `playbackManager.play`; on RN they navigate
|
||||
* to the player screen instead
|
||||
* - AppState foreground re-join (we may miss broadcasts while
|
||||
* suspended)
|
||||
*
|
||||
* External API surface (`useSyncPlay`) is stable; components don't
|
||||
* change when the internals do.
|
||||
*/
|
||||
|
||||
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 { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||
import i18n from "@/i18n";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
import type { Controller as SyncPlayController } from "./Controller";
|
||||
import { SyncPlayManager } from "./Manager";
|
||||
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
|
||||
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
|
||||
|
||||
interface SyncPlayContextValue {
|
||||
isEnabled: boolean;
|
||||
groupInfo: GroupInfoDto | null;
|
||||
canJoinGroups: boolean;
|
||||
canCreateGroups: boolean;
|
||||
|
||||
joinGroup: (groupId: string) => Promise<void>;
|
||||
createGroup: (groupName?: string) => Promise<void>;
|
||||
leaveGroup: () => Promise<void>;
|
||||
getGroups: () => Promise<GroupInfoDto[]>;
|
||||
|
||||
controller: SyncPlayController | null;
|
||||
|
||||
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||
notifyReady: () => void;
|
||||
notifyBuffering: (isBuffering: boolean) => void;
|
||||
notifyPlaybackStart: () => void;
|
||||
|
||||
pendingPlaybackCommand: "Unpause" | "Pause" | null;
|
||||
/**
|
||||
* Current SyncPlay OSD overlay state. Drives the animated icon over the
|
||||
* video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden.
|
||||
*/
|
||||
osdAction: SyncPlayOsdAction | null;
|
||||
}
|
||||
|
||||
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
|
||||
|
||||
interface SyncPlayProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const router = useAppRouter();
|
||||
const { isConnected: isWsConnected } = useWebSocketContext();
|
||||
|
||||
const [manager, setManager] = useState<SyncPlayManager | null>(null);
|
||||
const isNavigatingToPlayerRef = useRef(false);
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
|
||||
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
||||
"Unpause" | "Pause" | null
|
||||
>(null);
|
||||
|
||||
// While in a SyncPlay group, hold a keep-alive token on the global
|
||||
// WebSocket so backgrounding the app does NOT cleanly close the
|
||||
// socket. A clean close is interpreted by the Jellyfin server as
|
||||
// leaving the group and is broadcast to every other member as
|
||||
// "<user> has left the group". Keeping the socket open across a
|
||||
// short suspend lets us stay in the group while quickly switching
|
||||
// apps; if the OS eventually tears the TCP connection down anyway,
|
||||
// the app-foreground rejoin effect below will pull us back in.
|
||||
useKeepWebSocketAlive(isEnabled);
|
||||
|
||||
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||
const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/**
|
||||
* Set the OSD overlay action.
|
||||
*
|
||||
* `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the
|
||||
* Unpause / Pause / Seek command-confirmation flashes. Persistent actions
|
||||
* (schedule-play, buffering, wait-*) stay until cleared by a state
|
||||
* transition or a subsequent call with `null`.
|
||||
*/
|
||||
const showOsd = useCallback(
|
||||
(action: SyncPlayOsdAction | null, transient = false) => {
|
||||
if (osdTimeoutRef.current) {
|
||||
clearTimeout(osdTimeoutRef.current);
|
||||
osdTimeoutRef.current = null;
|
||||
}
|
||||
setOsdAction(action);
|
||||
if (transient && action !== null) {
|
||||
osdTimeoutRef.current = setTimeout(() => {
|
||||
osdTimeoutRef.current = null;
|
||||
setOsdAction((cur) => (cur === action ? null : cur));
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Pending play/pause tap → optimistic schedule-play overlay (unless another
|
||||
// overlay reason has already taken precedence).
|
||||
useEffect(() => {
|
||||
if (pendingPlaybackCommand) {
|
||||
setOsdAction((cur) => cur ?? "schedule-play");
|
||||
} else {
|
||||
setOsdAction((cur) => (cur === "schedule-play" ? null : cur));
|
||||
}
|
||||
}, [pendingPlaybackCommand]);
|
||||
|
||||
// Clear the OSD auto-expire timeout on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (osdTimeoutRef.current) {
|
||||
clearTimeout(osdTimeoutRef.current);
|
||||
osdTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const canJoinGroups = useMemo(() => {
|
||||
const access = user?.Policy?.SyncPlayAccess;
|
||||
return access !== "None" && access !== undefined;
|
||||
}, [user?.Policy?.SyncPlayAccess]);
|
||||
|
||||
const canCreateGroups = useMemo(
|
||||
() => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
|
||||
[user?.Policy?.SyncPlayAccess],
|
||||
);
|
||||
|
||||
// Latch: `true` once we've fired the per-attach `playbackstart` event.
|
||||
const playbackStartFiredRef = useRef(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const mgr = new SyncPlayManager(api);
|
||||
mgr.init();
|
||||
setManager(mgr);
|
||||
|
||||
const playerWrapper = mgr.getPlayerWrapper();
|
||||
|
||||
// localPlay → navigate to direct-player with syncPlay=true
|
||||
playerWrapper.setLocalPlayHandler((options) => {
|
||||
const itemId = options.ids[0];
|
||||
if (!itemId) {
|
||||
console.warn("SyncPlay: localPlay called with no ids");
|
||||
return;
|
||||
}
|
||||
if (isNavigatingToPlayerRef.current) {
|
||||
console.debug("SyncPlay: already navigating to player");
|
||||
return;
|
||||
}
|
||||
isNavigatingToPlayerRef.current = true;
|
||||
|
||||
toast(i18n.t("syncplay.joining_playback"));
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId,
|
||||
playbackPosition: String(options.startPositionTicks ?? 0),
|
||||
syncPlay: "true",
|
||||
}).toString();
|
||||
router.push(`/player/direct-player?${queryParams}`);
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigatingToPlayerRef.current = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// localSetCurrentPlaylistItem → navigate to the new playlist item
|
||||
playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
|
||||
if (!playlistItemId) return;
|
||||
const queueCore = mgr.getQueueCore();
|
||||
const target = queueCore
|
||||
.getPlaylist()
|
||||
.find((i) => i.PlaylistItemId === playlistItemId);
|
||||
const itemId = target?.Id;
|
||||
if (!itemId) {
|
||||
console.warn(
|
||||
"SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
|
||||
playlistItemId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isNavigatingToPlayerRef.current) return;
|
||||
isNavigatingToPlayerRef.current = true;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId,
|
||||
playbackPosition: String(queueCore.getStartPositionTicks()),
|
||||
syncPlay: "true",
|
||||
}).toString();
|
||||
router.push(`/player/direct-player?${queryParams}`);
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigatingToPlayerRef.current = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
mgr.on("enabled", (...args: unknown[]) => {
|
||||
const enabled = args[0] as boolean;
|
||||
setIsEnabled(enabled);
|
||||
if (!enabled) setGroupInfo(null);
|
||||
});
|
||||
|
||||
mgr.on("group-update", (...args: unknown[]) => {
|
||||
setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
|
||||
});
|
||||
|
||||
mgr.on("pending-playback-change", (...args: unknown[]) => {
|
||||
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
||||
});
|
||||
|
||||
// group-state-change → on "Waiting", pause locally so we don't drift
|
||||
// ahead of the group while the server is reconciling buffering/seek
|
||||
// state. Position resync is *only* done from the explicit Pause /
|
||||
// Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand`
|
||||
// → `scheduleUnpause` etc.) — those commands carry the canonical
|
||||
// `PositionTicks` for the action's `When`. The old code here also
|
||||
// called `wrapper.localSeek(lastCommand.PositionTicks)`, but
|
||||
// `lastCommand` is the *previous* Pause/Unpause and can be many
|
||||
// seconds stale, so it rewound the user every time someone else
|
||||
// buffered. Don't put a seek back here.
|
||||
mgr.on("group-state-change", (...args: unknown[]) => {
|
||||
const state = args[0] as string | undefined;
|
||||
const reason = args[2] as string | undefined;
|
||||
const wrapper = mgr.getPlayerWrapper();
|
||||
if (!wrapper.isPlaybackActive()) return;
|
||||
if (state === "Waiting") {
|
||||
wrapper.localPause();
|
||||
}
|
||||
|
||||
// Drive the persistent OSD overlay from (state, reason).
|
||||
// Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping.
|
||||
if (state === "Waiting") {
|
||||
if (reason === "Buffer") showOsd("buffering");
|
||||
else if (reason === "Unpause") showOsd("wait-unpause");
|
||||
else if (reason === "Pause") showOsd("wait-pause");
|
||||
else if (reason === "Seek") showOsd("seek");
|
||||
} else if (state === "Playing" || state === "Paused") {
|
||||
// Stable state — clear any persistent overlay; transient flashes
|
||||
// come from the `osd` event below and self-expire.
|
||||
setOsdAction((cur) => {
|
||||
if (
|
||||
cur === "schedule-play" ||
|
||||
cur === "buffering" ||
|
||||
cur === "wait-pause" ||
|
||||
cur === "wait-unpause"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return cur;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s.
|
||||
mgr.on("osd", (...args: unknown[]) => {
|
||||
const action = args[0] as SyncPlayOsdAction;
|
||||
const transient =
|
||||
action === "unpause" || action === "pause" || action === "seek";
|
||||
showOsd(action, transient);
|
||||
});
|
||||
|
||||
mgr.on("toast", (...args: unknown[]) => {
|
||||
const key = args[0] as string;
|
||||
const arg = args[1] as string | undefined;
|
||||
const message = arg
|
||||
? i18n.t(`syncplay.toasts.${key}`, { user: arg })
|
||||
: i18n.t(`syncplay.toasts.${key}`);
|
||||
toast(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mgr.destroy();
|
||||
setManager(null);
|
||||
};
|
||||
}, [api, router]);
|
||||
|
||||
// Initial join race: once `enabled` flips true, snapshot the current group.
|
||||
useEffect(() => {
|
||||
if (isEnabled && manager) {
|
||||
setGroupInfo(manager.getGroupInfo());
|
||||
}
|
||||
}, [isEnabled, manager]);
|
||||
|
||||
// Wire WebSocket messages → manager
|
||||
useSyncPlayWebSocket(manager);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
||||
if (!api) return [];
|
||||
try {
|
||||
const response = await getSyncPlayApi(api).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 {
|
||||
await getSyncPlayApi(api).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 {
|
||||
await getSyncPlayApi(api).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 {
|
||||
await getSyncPlayApi(api).syncPlayLeaveGroup();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay: failed to leave group", error);
|
||||
throw error;
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastGroupIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
|
||||
}, [groupInfo?.GroupId]);
|
||||
|
||||
// Track whether the WebSocket got torn down while the app was
|
||||
// backgrounded. If it survived (keep-alive worked), the server
|
||||
// still has us in the group and we must NOT call JoinGroup again —
|
||||
// doing so would trigger a redundant "X joined the group" broadcast
|
||||
// to every other member every time we briefly leave the app.
|
||||
const wsClosedWhileBackgroundedRef = useRef(false);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
useEffect(() => {
|
||||
if (!isWsConnected && appStateRef.current !== "active") {
|
||||
wsClosedWhileBackgroundedRef.current = true;
|
||||
}
|
||||
}, [isWsConnected]);
|
||||
|
||||
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;
|
||||
|
||||
// Happy path: keep-alive held the socket open across the
|
||||
// suspend. Server still considers us a member — nothing to do.
|
||||
if (!wsClosedWhileBackgroundedRef.current) {
|
||||
console.log(
|
||||
"SyncPlay: app foregrounded with WS still alive, skipping rejoin",
|
||||
);
|
||||
return;
|
||||
}
|
||||
wsClosedWhileBackgroundedRef.current = false;
|
||||
|
||||
// Small delay so the WebSocket has a moment to reconnect.
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
`SyncPlay: app foregrounded after WS drop, 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 attach bridges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const setPlayerControls = useCallback(
|
||||
(controls: PlayerControls | null) => {
|
||||
// Reset the playbackstart latch on each new attach.
|
||||
playbackStartFiredRef.current = false;
|
||||
manager?.setPlayerControls(controls);
|
||||
},
|
||||
[manager],
|
||||
);
|
||||
|
||||
const notifyReady = useCallback(() => {
|
||||
manager?.notifyReady();
|
||||
}, [manager]);
|
||||
|
||||
const notifyBuffering = useCallback(
|
||||
(isBuffering: boolean) => {
|
||||
manager?.notifyBuffering(isBuffering);
|
||||
if (!isBuffering && !playbackStartFiredRef.current) {
|
||||
playbackStartFiredRef.current = true;
|
||||
manager?.notifyPlaybackStart();
|
||||
}
|
||||
},
|
||||
[manager],
|
||||
);
|
||||
|
||||
const notifyPlaybackStart = useCallback(() => {
|
||||
manager?.notifyPlaybackStart();
|
||||
}, [manager]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context value
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const contextValue: SyncPlayContextValue = useMemo(
|
||||
() => ({
|
||||
isEnabled,
|
||||
groupInfo,
|
||||
canJoinGroups,
|
||||
canCreateGroups,
|
||||
joinGroup,
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
controller: manager?.getController() ?? null,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
notifyPlaybackStart,
|
||||
pendingPlaybackCommand,
|
||||
osdAction,
|
||||
}),
|
||||
[
|
||||
isEnabled,
|
||||
groupInfo,
|
||||
canJoinGroups,
|
||||
canCreateGroups,
|
||||
joinGroup,
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
manager,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
notifyPlaybackStart,
|
||||
pendingPlaybackCommand,
|
||||
osdAction,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SyncPlayContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SyncPlayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSyncPlay(): SyncPlayContextValue {
|
||||
const context = useContext(SyncPlayContext);
|
||||
if (!context) {
|
||||
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
23
providers/SyncPlay/constants.ts
Normal file
23
providers/SyncPlay/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Constants — shared timing/threshold values used across SyncPlay files.
|
||||
* Kept separate from `types.ts` because these are implementation tuning
|
||||
* values, not the public protocol/types surface.
|
||||
*/
|
||||
|
||||
import { TicksPerMillisecond } from "./types";
|
||||
|
||||
export { TicksPerMillisecond };
|
||||
|
||||
/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */
|
||||
export const WaitForEventDefaultTimeout = 30000;
|
||||
|
||||
/** Short-lived timeout for player events (matches jellyfin-web). */
|
||||
export const WaitForPlayerEventTimeout = 500;
|
||||
|
||||
export function ticksToMs(ticks: number): number {
|
||||
return ticks / TicksPerMillisecond;
|
||||
}
|
||||
|
||||
export function msToTicks(ms: number): number {
|
||||
return Math.round(ms * TicksPerMillisecond);
|
||||
}
|
||||
378
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
378
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire
|
||||
* at the precise group-wide moment and keeps the player drift-corrected.
|
||||
*
|
||||
* Design choices that diverge from jellyfin-web:
|
||||
* - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable
|
||||
* across platforms (mpv/VLC/expo-video each behave differently for
|
||||
* fractional speeds). We always seek to catch up.
|
||||
* - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection
|
||||
* used HTML element identity; on RN we don't have a stable handle
|
||||
* and the false-positive rate would be much higher than the value.
|
||||
* - **No syncMethod / showSyncIcon**. We don't surface the sync
|
||||
* technique to the UI.
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import {
|
||||
TicksPerMillisecond,
|
||||
ticksToMs,
|
||||
WaitForPlayerEventTimeout,
|
||||
} from "../constants";
|
||||
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||
import type { SyncPlayManager } from "../Manager";
|
||||
import { type SendCommand, SYNC_PLAY_TUNING } from "../types";
|
||||
|
||||
export class PlaybackCore extends EventEmitter {
|
||||
private manager!: SyncPlayManager;
|
||||
private lastCommand: SendCommand | null = null;
|
||||
private scheduledCommand: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
init(manager: SyncPlayManager): void {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/** Local "playback started" hook — fires the initial Ready request. */
|
||||
onPlaybackStart(apiClient: Api): void {
|
||||
try {
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
const positionMs = playerWrapper.currentTime();
|
||||
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
const playlistItemId =
|
||||
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||
|
||||
getSyncPlayApi(apiClient).syncPlayReady({
|
||||
readyRequestDto: {
|
||||
When: now.toISOString(),
|
||||
PositionTicks: positionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: playlistItemId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay onPlaybackStart:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Local pause → tell the server. */
|
||||
onPause(apiClient: Api): void {
|
||||
try {
|
||||
getSyncPlayApi(apiClient).syncPlayPause();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay onPause:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Local unpause → tell the server. */
|
||||
onUnpause(apiClient: Api): void {
|
||||
try {
|
||||
getSyncPlayApi(apiClient).syncPlayUnpause();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay onUnpause:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Local "ready" hook — server uses this to know we've finished buffering. */
|
||||
onReady(apiClient: Api): void {
|
||||
this.sendBufferingRequest(apiClient, false);
|
||||
}
|
||||
|
||||
/** Local "buffering" hook — server uses this to (optionally) pause the group. */
|
||||
onBuffering(apiClient: Api): void {
|
||||
this.sendBufferingRequest(apiClient, true);
|
||||
}
|
||||
|
||||
/** Send a Ready or Buffering request. */
|
||||
sendBufferingRequest(apiClient: Api, isBuffering: boolean): void {
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
const positionMs = playerWrapper.currentTime();
|
||||
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
const playlistItemId =
|
||||
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||
|
||||
try {
|
||||
if (isBuffering) {
|
||||
getSyncPlayApi(apiClient).syncPlayBuffering({
|
||||
bufferRequestDto: {
|
||||
When: now.toISOString(),
|
||||
PositionTicks: positionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: playlistItemId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
getSyncPlayApi(apiClient).syncPlayReady({
|
||||
readyRequestDto: {
|
||||
When: now.toISOString(),
|
||||
PositionTicks: positionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: playlistItemId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SyncPlay sendBufferingRequest:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a group command (Unpause, Pause, Stop, Seek). Times the
|
||||
* execution to fire at the group-wide instant the server selected.
|
||||
*/
|
||||
applyCommand(command: SendCommand): void {
|
||||
(command as unknown as { EmittedAt: Date }).EmittedAt = new Date(
|
||||
command.EmittedAt as unknown as string,
|
||||
);
|
||||
(command as unknown as { When: Date }).When = new Date(
|
||||
command.When as unknown as string,
|
||||
);
|
||||
|
||||
if (
|
||||
this.lastCommand &&
|
||||
((
|
||||
this.lastCommand as unknown as { EmittedAt: Date }
|
||||
).EmittedAt.getTime() >
|
||||
(command as unknown as { EmittedAt: Date }).EmittedAt.getTime() ||
|
||||
(this.lastCommand as unknown as { When: Date }).When.getTime() >
|
||||
(command as unknown as { When: Date }).When.getTime())
|
||||
) {
|
||||
// NOTE: strict `>` (not `>=`) on EmittedAt — Jellyfin's server timestamps
|
||||
// commands at sub-ms precision but JS `Date` truncates to ms, so two
|
||||
// commands emitted within the same millisecond would otherwise be
|
||||
// rejected as "outdated" and silently dropped. This produced an
|
||||
// unbreakable pause/unpause loop where every fresh command was
|
||||
// discarded. Matches jellyfin-web's check in
|
||||
// `web/src/plugins/syncPlay/core/Manager.js`.
|
||||
console.debug(
|
||||
"SyncPlay applyCommand: dropping outdated command",
|
||||
command,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCommand = command;
|
||||
if (!this.manager.isFollowingGroupPlayback()) {
|
||||
console.debug(
|
||||
"SyncPlay applyCommand: dropping command (not following playback)",
|
||||
command,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
if (!playerWrapper.isPlaybackActive()) {
|
||||
console.debug(
|
||||
"SyncPlay applyCommand: dropping command (playback not active)",
|
||||
command,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const enqueuedAt = new Date();
|
||||
const remoteEnqueuedAt = this.manager
|
||||
.getTimeSync()
|
||||
.localDateToRemote(enqueuedAt);
|
||||
const localCommandWhen = this.manager
|
||||
.getTimeSync()
|
||||
.remoteDateToLocal(command.When as unknown as Date);
|
||||
|
||||
switch (command.Command) {
|
||||
case "Unpause":
|
||||
this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0);
|
||||
this.emit("osd", "unpause");
|
||||
break;
|
||||
case "Pause":
|
||||
this.schedulePause(localCommandWhen, command.PositionTicks ?? 0);
|
||||
this.emit("osd", "pause");
|
||||
break;
|
||||
case "Stop":
|
||||
this.scheduleStop(localCommandWhen);
|
||||
break;
|
||||
case "Seek":
|
||||
this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0);
|
||||
this.emit("osd", "seek");
|
||||
break;
|
||||
default:
|
||||
console.warn("SyncPlay applyCommand: unknown command", command);
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
(command as unknown as { When: Date }).When.getTime() <
|
||||
remoteEnqueuedAt.getTime()
|
||||
) {
|
||||
console.debug(
|
||||
"SyncPlay applyCommand: command was scheduled for the past",
|
||||
command,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */
|
||||
scheduleUnpause(when: Date, positionTicks: number): void {
|
||||
this.clearScheduledCommand();
|
||||
const now = Date.now();
|
||||
const playAtTime = when.getTime();
|
||||
const currentPositionMs = this.manager.getPlayerWrapper().currentTime();
|
||||
const currentPositionTicks = Math.round(
|
||||
currentPositionMs * TicksPerMillisecond,
|
||||
);
|
||||
|
||||
if (playAtTime > now) {
|
||||
// Future: seek now, then play at the right moment.
|
||||
this.localSeek(positionTicks);
|
||||
this.scheduledCommand = setTimeout(() => {
|
||||
this.localUnpause();
|
||||
// After playback resumes, the player position will need a
|
||||
// small bump to land on the group target. waitForPlayerEvent
|
||||
// is best-effort.
|
||||
waitForEventOnce(
|
||||
this.manager,
|
||||
"unpause",
|
||||
WaitForPlayerEventTimeout,
|
||||
).catch(() => undefined);
|
||||
}, playAtTime - now);
|
||||
this.emit("osd", "wait-unpause");
|
||||
} else {
|
||||
// Past: catch up now.
|
||||
const targetMs = ticksToMs(positionTicks);
|
||||
const delayMs = now - playAtTime;
|
||||
this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond));
|
||||
this.localUnpause();
|
||||
void currentPositionTicks;
|
||||
}
|
||||
}
|
||||
|
||||
schedulePause(when: Date, positionTicks: number): void {
|
||||
this.clearScheduledCommand();
|
||||
const now = Date.now();
|
||||
const pauseAtTime = when.getTime();
|
||||
|
||||
const callback = () => {
|
||||
this.localUnpause();
|
||||
this.localSeek(positionTicks);
|
||||
this.localPause();
|
||||
};
|
||||
|
||||
if (pauseAtTime > now) {
|
||||
this.scheduledCommand = setTimeout(callback, pauseAtTime - now);
|
||||
this.emit("osd", "wait-pause");
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStop(when: Date): void {
|
||||
this.clearScheduledCommand();
|
||||
const now = Date.now();
|
||||
const stopAtTime = when.getTime();
|
||||
if (stopAtTime > now) {
|
||||
this.scheduledCommand = setTimeout(() => {
|
||||
this.localStop();
|
||||
}, stopAtTime - now);
|
||||
} else {
|
||||
this.localStop();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleSeek(when: Date, positionTicks: number): void {
|
||||
this.applyCommand({
|
||||
...this.lastCommand!,
|
||||
Command: "Pause",
|
||||
PositionTicks: positionTicks,
|
||||
When: when as unknown as string,
|
||||
EmittedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
clearScheduledCommand(): void {
|
||||
if (this.scheduledCommand) {
|
||||
clearTimeout(this.scheduledCommand);
|
||||
this.scheduledCommand = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- local player ops ------------------------------------------------------
|
||||
|
||||
localUnpause(): void {
|
||||
this.manager.getPlayerWrapper().localUnpause();
|
||||
}
|
||||
|
||||
localPause(): void {
|
||||
this.manager.getPlayerWrapper().localPause();
|
||||
}
|
||||
|
||||
localSeek(positionTicks: number): void {
|
||||
this.manager.getPlayerWrapper().localSeek(positionTicks);
|
||||
}
|
||||
|
||||
localStop(): void {
|
||||
this.manager.getPlayerWrapper().localStop();
|
||||
}
|
||||
|
||||
// -- queries ---------------------------------------------------------------
|
||||
|
||||
getLastCommand(): SendCommand | null {
|
||||
return this.lastCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate where the group should be in ticks, given a known
|
||||
* starting position and the time the position was valid at.
|
||||
*/
|
||||
estimateCurrentTicks(positionTicks: number, when: Date): number {
|
||||
const lastCommand = this.lastCommand;
|
||||
if (!lastCommand) return positionTicks;
|
||||
const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||
const elapsedMs = remoteNow.getTime() - when.getTime();
|
||||
if (lastCommand.Command === "Unpause") {
|
||||
return positionTicks + elapsedMs * TicksPerMillisecond;
|
||||
}
|
||||
return positionTicks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift correction tick — called on every player time update. Skips
|
||||
* to the group's expected position if drift exceeds the threshold.
|
||||
* SpeedToSync is intentionally not implemented (see file header).
|
||||
*/
|
||||
syncPlaybackTime(): void {
|
||||
const lastCommand = this.lastCommand;
|
||||
if (lastCommand?.Command !== "Unpause") return;
|
||||
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
if (!playerWrapper.isPlaying()) return;
|
||||
|
||||
const currentMs = playerWrapper.currentTime();
|
||||
const expectedTicks = this.estimateCurrentTicks(
|
||||
lastCommand.PositionTicks ?? 0,
|
||||
lastCommand.When as unknown as Date,
|
||||
);
|
||||
const expectedMs = ticksToMs(expectedTicks);
|
||||
const driftMs = Math.abs(currentMs - expectedMs);
|
||||
|
||||
if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) {
|
||||
console.log(
|
||||
`SyncPlay syncPlaybackTime: drift ${driftMs.toFixed(
|
||||
0,
|
||||
)}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`,
|
||||
);
|
||||
this.localSeek(expectedTicks);
|
||||
}
|
||||
}
|
||||
|
||||
// -- teardown --------------------------------------------------------------
|
||||
|
||||
destroy(): void {
|
||||
this.clearScheduledCommand();
|
||||
this.lastCommand = null;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaybackCore;
|
||||
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* SyncPlay QueueCore — tracks the group's playlist.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem,
|
||||
* NextItem, PreviousItem, RemoveItems, etc.)
|
||||
* - Resolve the server's flat list of ItemIds into full `BaseItemDto`s
|
||||
* (with PlaylistItemId glued on for SyncPlay requests)
|
||||
* - Expose `currentPlaylistItemId` — required by every SyncPlay
|
||||
* request (Ready, Buffering, Seek) so the server can ignore stale
|
||||
* ones from before the playlist moved
|
||||
* - On NewPlaylist, ask the server we're ready by sending a Buffering
|
||||
* request after the local player emits `playbackstart`
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants";
|
||||
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||
import type { SyncPlayManager } from "../Manager";
|
||||
import {
|
||||
getItemsForPlayback,
|
||||
translateItemsForPlayback,
|
||||
} from "../transport/queueTranslation";
|
||||
import type {
|
||||
PlayQueueUpdate,
|
||||
PlayQueueUpdateReason,
|
||||
SyncPlayQueueItem,
|
||||
} from "../types";
|
||||
|
||||
export class QueueCore extends EventEmitter {
|
||||
private manager!: SyncPlayManager;
|
||||
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
|
||||
/** Playable items with `PlaylistItemId` glued on. */
|
||||
private playlist: BaseItemDto[] = [];
|
||||
|
||||
init(manager: SyncPlayManager): void {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/** Handle a PlayQueue group update from the server. */
|
||||
updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void {
|
||||
(newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date(
|
||||
newPlayQueue.LastUpdate as unknown as string,
|
||||
);
|
||||
|
||||
if (
|
||||
(newPlayQueue.LastUpdate as unknown as Date).getTime() <=
|
||||
this.getLastUpdateTime()
|
||||
) {
|
||||
console.debug("SyncPlay updatePlayQueue: ignoring old update");
|
||||
return;
|
||||
}
|
||||
|
||||
this.onPlayQueueUpdate(apiClient, newPlayQueue)
|
||||
.then(() => {
|
||||
if (
|
||||
(newPlayQueue.LastUpdate as unknown as Date).getTime() <
|
||||
this.getLastUpdateTime()
|
||||
) {
|
||||
console.warn("SyncPlay updatePlayQueue: trying to apply old update");
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = newPlayQueue.Reason as PlayQueueUpdateReason;
|
||||
switch (reason) {
|
||||
case "NewPlaylist": {
|
||||
if (!this.manager.isFollowingGroupPlayback()) {
|
||||
this.manager.followGroupPlayback(apiClient).then(() => {
|
||||
this.startPlayback(apiClient);
|
||||
});
|
||||
} else {
|
||||
this.startPlayback(apiClient);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "SetCurrentItem":
|
||||
case "NextItem":
|
||||
case "PreviousItem": {
|
||||
const playlistItemId = this.getCurrentPlaylistItemId();
|
||||
this.setCurrentPlaylistItem(apiClient, playlistItemId);
|
||||
break;
|
||||
}
|
||||
case "RemoveItems":
|
||||
case "MoveItem":
|
||||
case "Queue":
|
||||
case "QueueNext":
|
||||
case "RepeatMode":
|
||||
case "ShuffleMode":
|
||||
// Video-focused: we don't expose repeat/shuffle/queue mutation
|
||||
// controls in the RN UI yet, so these reasons just update our
|
||||
// local snapshot (already done by onPlayQueueUpdate) without
|
||||
// triggering any local action.
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
"SyncPlay updatePlayQueue: unknown reason",
|
||||
newPlayQueue.Reason,
|
||||
);
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("SyncPlay updatePlayQueue:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a play-queue update to local state. */
|
||||
async onPlayQueueUpdate(
|
||||
apiClient: Api,
|
||||
playQueueUpdate: PlayQueueUpdate,
|
||||
): Promise<void> {
|
||||
const itemIds = (playQueueUpdate.Playlist ?? [])
|
||||
.map((queueItem: SyncPlayQueueItem) => queueItem.ItemId)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
|
||||
if (!itemIds.length) {
|
||||
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||
this.playlist = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const fetched = await getItemsForPlayback(apiClient, itemIds);
|
||||
const items = await translateItemsForPlayback(apiClient, fetched, {
|
||||
ids: itemIds,
|
||||
});
|
||||
|
||||
if (
|
||||
this.lastPlayQueueUpdate &&
|
||||
(playQueueUpdate.LastUpdate as unknown as Date).getTime() <=
|
||||
this.getLastUpdateTime()
|
||||
) {
|
||||
throw new Error("Trying to apply old update");
|
||||
}
|
||||
|
||||
// Glue PlaylistItemId from the server's playlist entries onto each
|
||||
// resolved item. The server-assigned IDs are what every SyncPlay
|
||||
// request needs to identify the queue slot.
|
||||
const playlistItems = playQueueUpdate.Playlist ?? [];
|
||||
for (let i = 0; i < items.length && i < playlistItems.length; i++) {
|
||||
items[i].PlaylistItemId = playlistItems[i].PlaylistItemId;
|
||||
}
|
||||
|
||||
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||
this.playlist = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Ready request once the local player begins playback. The
|
||||
* server uses this to wait until every member is buffered before
|
||||
* issuing the next Unpause.
|
||||
*
|
||||
* On timeout (player never starts), halt group playback so the rest
|
||||
* of the group can proceed without us.
|
||||
*/
|
||||
scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void {
|
||||
waitForEventOnce(
|
||||
this.manager,
|
||||
"playbackstart",
|
||||
WaitForEventDefaultTimeout,
|
||||
["playbackerror"],
|
||||
)
|
||||
.then(() => {
|
||||
console.debug(
|
||||
"SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server",
|
||||
);
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
playerWrapper.localPause();
|
||||
|
||||
const currentPosition = playerWrapper.currentTime();
|
||||
const currentPositionTicks = Math.round(
|
||||
currentPosition * TicksPerMillisecond,
|
||||
);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||
|
||||
try {
|
||||
getSyncPlayApi(apiClient).syncPlayReady({
|
||||
readyRequestDto: {
|
||||
When: now.toISOString(),
|
||||
PositionTicks: currentPositionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay syncPlayReady failed", error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Timed out waiting for 'playbackstart' event!",
|
||||
origin,
|
||||
error,
|
||||
);
|
||||
if (!this.manager.isSyncPlayEnabled()) {
|
||||
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||
}
|
||||
this.manager.haltGroupPlayback(apiClient);
|
||||
});
|
||||
}
|
||||
|
||||
/** Start local playback by navigating to the player screen for the current item. */
|
||||
startPlayback(apiClient: Api): void {
|
||||
if (!this.manager.isFollowingGroupPlayback()) {
|
||||
console.debug("SyncPlay startPlayback: ignoring, not following playback");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPlaylistEmpty()) {
|
||||
console.debug("SyncPlay startPlayback: empty playlist");
|
||||
return;
|
||||
}
|
||||
|
||||
// Estimate where to start playback from. Prefer the last playback
|
||||
// command if newer than the queue update (playback ticks change
|
||||
// more often than queue position).
|
||||
const playbackCommand = this.manager.getLastPlaybackCommand();
|
||||
let startPositionTicks = 0;
|
||||
|
||||
if (
|
||||
playbackCommand &&
|
||||
(
|
||||
playbackCommand as unknown as { EmittedAt: Date }
|
||||
).EmittedAt?.getTime() >= this.getLastUpdateTime()
|
||||
) {
|
||||
startPositionTicks = this.manager
|
||||
.getPlaybackCore()
|
||||
.estimateCurrentTicks(
|
||||
playbackCommand.PositionTicks ?? 0,
|
||||
(playbackCommand as unknown as { When: Date }).When,
|
||||
);
|
||||
} else {
|
||||
startPositionTicks = this.manager
|
||||
.getPlaybackCore()
|
||||
.estimateCurrentTicks(
|
||||
this.getStartPositionTicks(),
|
||||
(this.getLastUpdate() ?? new Date()) as Date,
|
||||
);
|
||||
}
|
||||
|
||||
const serverId = apiClient.deviceInfo?.id ?? "";
|
||||
|
||||
this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback");
|
||||
|
||||
this.manager
|
||||
.getPlayerWrapper()
|
||||
.localPlay({
|
||||
ids: this.getPlaylistAsItemIds(),
|
||||
startPositionTicks,
|
||||
startIndex: this.getCurrentPlaylistIndex(),
|
||||
serverId,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("SyncPlay startPlayback: localPlay failed", error);
|
||||
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate to a specific item in the queue. */
|
||||
setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void {
|
||||
if (!this.manager.isFollowingGroupPlayback()) {
|
||||
console.debug(
|
||||
"SyncPlay setCurrentPlaylistItem: ignoring, not following playback",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleReadyRequestOnPlaybackStart(
|
||||
apiClient,
|
||||
"setCurrentPlaylistItem",
|
||||
);
|
||||
|
||||
this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId);
|
||||
}
|
||||
|
||||
// -- getters ---------------------------------------------------------------
|
||||
|
||||
getCurrentPlaylistIndex(): number {
|
||||
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
|
||||
}
|
||||
|
||||
getCurrentPlaylistItemId(): string | null {
|
||||
if (!this.lastPlayQueueUpdate) return null;
|
||||
const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1;
|
||||
if (index === -1) return null;
|
||||
return this.playlist[index]?.PlaylistItemId ?? null;
|
||||
}
|
||||
|
||||
getPlaylist(): BaseItemDto[] {
|
||||
return this.playlist.slice(0);
|
||||
}
|
||||
|
||||
isPlaylistEmpty(): boolean {
|
||||
return this.playlist.length === 0;
|
||||
}
|
||||
|
||||
getLastUpdate(): Date | null {
|
||||
if (!this.lastPlayQueueUpdate) return null;
|
||||
return this.lastPlayQueueUpdate.LastUpdate as unknown as Date;
|
||||
}
|
||||
|
||||
getLastUpdateTime(): number {
|
||||
if (!this.lastPlayQueueUpdate) return 0;
|
||||
return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime();
|
||||
}
|
||||
|
||||
getStartPositionTicks(): number {
|
||||
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
|
||||
}
|
||||
|
||||
getPlaylistAsItemIds(): (string | undefined)[] {
|
||||
if (!this.lastPlayQueueUpdate) return [];
|
||||
return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId);
|
||||
}
|
||||
|
||||
// -- teardown --------------------------------------------------------------
|
||||
|
||||
/** Clear cached playlist. Called on group disable so a re-join starts clean. */
|
||||
clear(): void {
|
||||
this.lastPlayQueueUpdate = null;
|
||||
this.playlist = [];
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export default QueueCore;
|
||||
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* TimeSync — NTP-style time synchronisation with the Jellyfin server.
|
||||
*
|
||||
* Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
|
||||
* TimeSyncCore}.js` — three classes that exist on web because the
|
||||
* abstract layer supports syncing against other group members, not just
|
||||
* the server. RN only syncs against the server, so it's one class.
|
||||
*
|
||||
* Algorithm: repeatedly time a round-trip request to `getUtcTime`,
|
||||
* compute `offset = ((requestReceived - requestSent) + (responseSent -
|
||||
* responseReceived)) / 2`, keep the minimum-delay measurement out of
|
||||
* the last 8. This is the standard NTP outlier-rejection trick — the
|
||||
* measurement with the shortest delay is the most accurate because
|
||||
* less network jitter could have skewed the timestamps.
|
||||
*
|
||||
* Polling: greedy mode at 1s intervals for the first 3 pings to warm
|
||||
* up the offset, then low-profile at 60s intervals for steady-state.
|
||||
* `forceUpdate()` resets to greedy mode (called on group join).
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { EventEmitter } from "../EventEmitter";
|
||||
|
||||
const NumberOfTrackedMeasurements = 8;
|
||||
const PollingIntervalGreedy = 1000; // ms
|
||||
const PollingIntervalLowProfile = 60000; // ms
|
||||
const GreedyPingCount = 3;
|
||||
|
||||
class Measurement {
|
||||
requestSent: number;
|
||||
requestReceived: number;
|
||||
responseSent: number;
|
||||
responseReceived: number;
|
||||
|
||||
constructor(
|
||||
requestSent: Date,
|
||||
requestReceived: Date,
|
||||
responseSent: Date,
|
||||
responseReceived: Date,
|
||||
) {
|
||||
this.requestSent = requestSent.getTime();
|
||||
this.requestReceived = requestReceived.getTime();
|
||||
this.responseSent = responseSent.getTime();
|
||||
this.responseReceived = responseReceived.getTime();
|
||||
}
|
||||
|
||||
/** Time offset (ms): positive means server clock is ahead of ours. */
|
||||
getOffset(): number {
|
||||
return (
|
||||
(this.requestReceived -
|
||||
this.requestSent +
|
||||
(this.responseSent - this.responseReceived)) /
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/** Round-trip delay (ms), excluding server processing. */
|
||||
getDelay(): number {
|
||||
return (
|
||||
this.responseReceived -
|
||||
this.requestSent -
|
||||
(this.responseSent - this.requestReceived)
|
||||
);
|
||||
}
|
||||
|
||||
/** One-way ping (ms). */
|
||||
getPing(): number {
|
||||
return this.getDelay() / 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the offset between this client's clock and the Jellyfin server's
|
||||
* clock, and exposes conversions between local and remote Dates.
|
||||
*
|
||||
* Listeners:
|
||||
* - `"update"` (timeOffset: number, ping: number) — fires on every
|
||||
* successful ping. Errors are logged but not emitted; consumers
|
||||
* should treat absence of updates as transient.
|
||||
*/
|
||||
export class TimeSync extends EventEmitter {
|
||||
private api: Api;
|
||||
private pingStop = true;
|
||||
private pollingInterval = PollingIntervalGreedy;
|
||||
private poller: ReturnType<typeof setTimeout> | null = null;
|
||||
private pings = 0;
|
||||
private measurement: Measurement | null = null;
|
||||
private measurements: Measurement[] = [];
|
||||
|
||||
constructor(api: Api) {
|
||||
super();
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/** Called when the user switches Jellyfin servers. */
|
||||
updateApiClient(api: Api): void {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/** Whether we've completed at least one successful measurement. */
|
||||
isReady(): boolean {
|
||||
return !!this.measurement;
|
||||
}
|
||||
|
||||
/** Current best-estimate time offset (ms). */
|
||||
getTimeOffset(): number {
|
||||
return this.measurement ? this.measurement.getOffset() : 0;
|
||||
}
|
||||
|
||||
/** Current best-estimate one-way ping (ms). */
|
||||
getPing(): number {
|
||||
return this.measurement ? this.measurement.getPing() : 0;
|
||||
}
|
||||
|
||||
/** Convert a server-time Date to local time. */
|
||||
remoteDateToLocal(remote: Date): Date {
|
||||
return new Date(remote.getTime() - this.getTimeOffset());
|
||||
}
|
||||
|
||||
/** Convert a local Date to server time. */
|
||||
localDateToRemote(local: Date): Date {
|
||||
return new Date(local.getTime() + this.getTimeOffset());
|
||||
}
|
||||
|
||||
/** Start polling. Idempotent. */
|
||||
startPing(): void {
|
||||
this.pingStop = false;
|
||||
this.scheduleNextPing();
|
||||
}
|
||||
|
||||
/** Stop polling. Idempotent. */
|
||||
stopPing(): void {
|
||||
this.pingStop = true;
|
||||
if (this.poller) {
|
||||
clearTimeout(this.poller);
|
||||
this.poller = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset to greedy polling and force a fresh measurement immediately. */
|
||||
forceUpdate(): void {
|
||||
this.stopPing();
|
||||
this.pollingInterval = PollingIntervalGreedy;
|
||||
this.pings = 0;
|
||||
this.startPing();
|
||||
}
|
||||
|
||||
/** Drop all measurements. Used on group leave. */
|
||||
resetMeasurements(): void {
|
||||
this.measurement = null;
|
||||
this.measurements = [];
|
||||
}
|
||||
|
||||
/** Full teardown on provider unmount. */
|
||||
destroy(): void {
|
||||
this.stopPing();
|
||||
this.resetMeasurements();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
private scheduleNextPing(): void {
|
||||
if (this.poller || this.pingStop) return;
|
||||
this.poller = setTimeout(() => {
|
||||
this.poller = null;
|
||||
this.requestPing()
|
||||
.then((result) => this.onPingResponse(result))
|
||||
.catch((error) => {
|
||||
console.error("SyncPlay TimeSync: ping failed", error);
|
||||
})
|
||||
.finally(() => this.scheduleNextPing());
|
||||
}, this.pollingInterval);
|
||||
}
|
||||
|
||||
private async requestPing() {
|
||||
const requestSent = new Date();
|
||||
const response = await getTimeSyncApi(this.api).getUtcTime();
|
||||
const responseReceived = new Date();
|
||||
const data = response.data;
|
||||
const requestReceived = new Date(data.RequestReceptionTime as string);
|
||||
const responseSent = new Date(data.ResponseTransmissionTime as string);
|
||||
return { requestSent, requestReceived, responseSent, responseReceived };
|
||||
}
|
||||
|
||||
private onPingResponse(result: {
|
||||
requestSent: Date;
|
||||
requestReceived: Date;
|
||||
responseSent: Date;
|
||||
responseReceived: Date;
|
||||
}): void {
|
||||
const measurement = new Measurement(
|
||||
result.requestSent,
|
||||
result.requestReceived,
|
||||
result.responseSent,
|
||||
result.responseReceived,
|
||||
);
|
||||
|
||||
this.measurements.push(measurement);
|
||||
if (this.measurements.length > NumberOfTrackedMeasurements) {
|
||||
this.measurements.shift();
|
||||
}
|
||||
|
||||
// Outlier rejection: pick the measurement with the shortest delay.
|
||||
const sorted = [...this.measurements].sort(
|
||||
(a, b) => a.getDelay() - b.getDelay(),
|
||||
);
|
||||
this.measurement = sorted[0];
|
||||
|
||||
// Throttle once we've warmed up.
|
||||
if (this.pings >= GreedyPingCount) {
|
||||
this.pollingInterval = PollingIntervalLowProfile;
|
||||
} else {
|
||||
this.pings++;
|
||||
}
|
||||
|
||||
this.emit("update", this.getTimeOffset(), this.getPing());
|
||||
}
|
||||
}
|
||||
|
||||
export default TimeSync;
|
||||
13
providers/SyncPlay/index.ts
Normal file
13
providers/SyncPlay/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* SyncPlay — public exports.
|
||||
*
|
||||
* Only what external consumers (components, hooks, screens) need.
|
||||
* Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper,
|
||||
* queueTranslation, EventEmitter, etc.) stay package-private.
|
||||
*/
|
||||
|
||||
export { Controller as SyncPlayController } from "./Controller";
|
||||
export { msToTicks, ticksToMs } from "./constants";
|
||||
export { SyncPlayManager } from "./Manager";
|
||||
export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider";
|
||||
export * from "./types";
|
||||
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request
|
||||
* that we've sent to the server but haven't seen echoed back via
|
||||
* `SyncPlayCommand`.
|
||||
*
|
||||
* Drives three things:
|
||||
* 1. Drop duplicate rapid taps
|
||||
* 2. Provide an optimistic-UI hint for the in-flight state
|
||||
* 3. Override "current play state" when deciding pause-vs-unpause
|
||||
* for the next tap
|
||||
*
|
||||
* Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast
|
||||
* doesn't freeze the UI forever.
|
||||
*/
|
||||
|
||||
import { SYNC_PLAY_TUNING } from "../types";
|
||||
|
||||
export class PendingPlaybackTracker {
|
||||
private command: "Unpause" | "Pause" | null = null;
|
||||
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null;
|
||||
|
||||
setChangeHandler(
|
||||
handler: ((cmd: "Unpause" | "Pause" | null) => void) | null,
|
||||
): void {
|
||||
this.onChange = handler;
|
||||
}
|
||||
|
||||
get(): "Unpause" | "Pause" | null {
|
||||
return this.command;
|
||||
}
|
||||
|
||||
mark(command: "Unpause" | "Pause"): void {
|
||||
this.command = command;
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
console.debug(
|
||||
"SyncPlay PendingPlaybackTracker: timed out waiting for broadcast",
|
||||
command,
|
||||
);
|
||||
this.command = null;
|
||||
this.timeout = null;
|
||||
this.onChange?.(null);
|
||||
}, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs);
|
||||
this.onChange?.(command);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
if (this.command !== null) {
|
||||
this.command = null;
|
||||
this.onChange?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API
|
||||
* and our millisecond-based `PlayerControls`. Methods that have no RN
|
||||
* analog (queue mutation hooks) delegate to provider-supplied handlers
|
||||
* which navigate to the player screen.
|
||||
*/
|
||||
|
||||
import { TicksPerMillisecond } from "../constants";
|
||||
import type { PlayerControls } from "../types";
|
||||
|
||||
/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */
|
||||
export interface LocalPlayOptions {
|
||||
ids: (string | undefined)[];
|
||||
startPositionTicks: number;
|
||||
startIndex: number;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export class PlayerWrapper {
|
||||
private controls: PlayerControls | null = null;
|
||||
private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null;
|
||||
private setCurrentItemHandler:
|
||||
| ((playlistItemId: string | null) => void)
|
||||
| null = null;
|
||||
|
||||
/** Attach / detach the underlying player. */
|
||||
bindToControls(controls: PlayerControls | null): void {
|
||||
this.controls = controls;
|
||||
}
|
||||
|
||||
/** Provider wires this to navigate to the player screen. */
|
||||
setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) {
|
||||
this.localPlayHandler = handler;
|
||||
}
|
||||
|
||||
/** Provider wires this to navigate to a different queue item. */
|
||||
setLocalSetCurrentItemHandler(
|
||||
handler: ((playlistItemId: string | null) => void) | null,
|
||||
) {
|
||||
this.setCurrentItemHandler = handler;
|
||||
}
|
||||
|
||||
localUnpause(): void {
|
||||
this.controls?.play();
|
||||
}
|
||||
|
||||
localPause(): void {
|
||||
this.controls?.pause();
|
||||
}
|
||||
|
||||
/** Upstream takes ticks; RN's `seekTo` takes ms. */
|
||||
localSeek(positionTicks: number): void {
|
||||
this.controls?.seekTo(positionTicks / TicksPerMillisecond);
|
||||
}
|
||||
|
||||
/** RN: pause instead of teardown — leaving the player screen is the navigator's job. */
|
||||
localStop(): void {
|
||||
this.controls?.pause();
|
||||
}
|
||||
|
||||
/** Position in ms. */
|
||||
currentTime(): number {
|
||||
return this.controls?.getCurrentPosition() ?? 0;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this.controls?.isPlaying() ?? false;
|
||||
}
|
||||
|
||||
isPlaybackActive(): boolean {
|
||||
return this.controls !== null;
|
||||
}
|
||||
|
||||
/** RN never runs as a remote-managed player. */
|
||||
isRemote(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
localPlay(options: LocalPlayOptions): Promise<void> {
|
||||
this.localPlayHandler?.(options);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
localSetCurrentPlaylistItem(playlistItemId: string | null): void {
|
||||
this.setCurrentItemHandler?.(playlistItemId);
|
||||
}
|
||||
}
|
||||
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback
|
||||
* with three RN-only guards. Web gets these for free from HTML `waiting`/
|
||||
* `canplay`; our `PlayerControls` exposes state (not events) and the React
|
||||
* effect that polls it can fire many times per second.
|
||||
*
|
||||
* - **dedup**: drop redundant calls when state hasn't changed
|
||||
* - **debounce buffering→true**: only escalate after the threshold;
|
||||
* going back to ready cancels the pending escalation
|
||||
* - **coalesce inflight**: serialize concurrent sends
|
||||
*
|
||||
* Returns `{ notify, dispose }`.
|
||||
*/
|
||||
|
||||
import { SYNC_PLAY_TUNING } from "../types";
|
||||
|
||||
export function createBufferingDebouncer(
|
||||
send: (isBuffering: boolean) => Promise<void>,
|
||||
) {
|
||||
let lastSent: boolean | null = null;
|
||||
let inflight: Promise<void> | null = null;
|
||||
let pendingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = async (isBuffering: boolean) => {
|
||||
if (lastSent === isBuffering) return;
|
||||
if (inflight) {
|
||||
try {
|
||||
await inflight;
|
||||
} catch {
|
||||
// ignore — used only for ordering
|
||||
}
|
||||
if (lastSent === isBuffering) return;
|
||||
}
|
||||
lastSent = isBuffering;
|
||||
inflight = send(isBuffering).finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
};
|
||||
|
||||
return {
|
||||
notify(isBuffering: boolean): void {
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
pendingTimeout = null;
|
||||
}
|
||||
if (!isBuffering) {
|
||||
// Ready always fires immediately.
|
||||
void flush(false);
|
||||
return;
|
||||
}
|
||||
pendingTimeout = setTimeout(() => {
|
||||
pendingTimeout = null;
|
||||
void flush(true);
|
||||
}, SYNC_PLAY_TUNING.minBufferingThresholdMs);
|
||||
},
|
||||
dispose(): void {
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
pendingTimeout = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* reconcileToGroupOnAttach — estimate the group's current position from
|
||||
* the last play/pause broadcast and seek the freshly-attached player
|
||||
* there if drift exceeds the threshold.
|
||||
*
|
||||
* Web's player binds at group-join, so this race doesn't exist there.
|
||||
* On RN the player mounts in a separate route after the join, so
|
||||
* commands arrive before controls attach. Without this, the player
|
||||
* resumes from its local position and is silently behind the group.
|
||||
*/
|
||||
|
||||
import { TicksPerMillisecond } from "../constants";
|
||||
import {
|
||||
type PlayerControls,
|
||||
type SendCommand,
|
||||
SYNC_PLAY_TUNING,
|
||||
} from "../types";
|
||||
|
||||
export function reconcileToGroupOnAttach(
|
||||
controls: PlayerControls,
|
||||
lastCommand: SendCommand | null,
|
||||
localToRemote: (local: Date) => Date,
|
||||
): void {
|
||||
if (
|
||||
!lastCommand ||
|
||||
(lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") ||
|
||||
!lastCommand.When ||
|
||||
lastCommand.PositionTicks == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const commandWhen = new Date(lastCommand.When);
|
||||
let targetTicks = lastCommand.PositionTicks;
|
||||
if (lastCommand.Command === "Unpause") {
|
||||
const remoteNow = localToRemote(new Date());
|
||||
targetTicks +=
|
||||
(remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond;
|
||||
}
|
||||
const targetMs = Math.max(0, targetTicks / TicksPerMillisecond);
|
||||
const currentMs = controls.getCurrentPosition();
|
||||
if (
|
||||
Math.abs(currentMs - targetMs) >
|
||||
SYNC_PLAY_TUNING.positionReconcileThresholdMs
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* queueTranslation — expand container items into a real playable queue.
|
||||
*
|
||||
* The server takes the queue we send via `syncPlaySetNewQueue` and
|
||||
* rebroadcasts it verbatim to every group member. Sending a container
|
||||
* ID (Series, Season, BoxSet, Playlist) means every receiver fails to
|
||||
* open the player because they can't directly play a container. We must
|
||||
* expand to real playable item IDs before sending the queue.
|
||||
*
|
||||
* Video-focused: music (MusicArtist/MusicGenre) and photo branches are
|
||||
* intentionally omitted. Live TV (Program), Episode auto-advance, and
|
||||
* folder expansion are preserved because they're the common video flows.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export interface TranslateOptions {
|
||||
ids?: string[];
|
||||
shuffle?: boolean;
|
||||
queryOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
|
||||
|
||||
async function getCurrentUser(api: Api) {
|
||||
const user = (await getUserApi(api).getCurrentUser()).data;
|
||||
if (!user?.Id) {
|
||||
throw new Error("SyncPlay queueTranslation: no authenticated user");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
function fetchFolderChildren(
|
||||
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.
|
||||
*
|
||||
* - single ID → `getItem` (cheap, no Items wrapper)
|
||||
* - multi ID → `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.
|
||||
*
|
||||
* - Program → channel items
|
||||
* - Playlist → playlist children
|
||||
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
|
||||
* - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes
|
||||
* - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off)
|
||||
*
|
||||
* Preserves the caller's `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];
|
||||
|
||||
if (firstItem.Type === "Program" && firstItem.ChannelId) {
|
||||
return getItemsForPlayback(api, [firstItem.ChannelId]);
|
||||
}
|
||||
|
||||
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.IsFolder) {
|
||||
// Series, Season, BoxSet, MusicAlbum, etc.
|
||||
const sortBy = options.shuffle
|
||||
? ["Random"]
|
||||
: firstItem.Type === "BoxSet"
|
||||
? ["SortName"]
|
||||
: undefined;
|
||||
return fetchFolderChildren(api, userId, {
|
||||
parentId: firstItem.Id,
|
||||
mediaTypes: ["Audio", "Video"],
|
||||
sortBy,
|
||||
...(options.queryOptions ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (firstItem.Type === "Episode" && workingItems.length === 1) {
|
||||
// Single-episode auto-next: load all remaining episodes in the
|
||||
// series, starting at this one. Gated on the user preference so we
|
||||
// don't surprise users who disabled autoplay.
|
||||
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
|
||||
return workingItems;
|
||||
}
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: firstItem.SeriesId,
|
||||
userId,
|
||||
isMissing: false,
|
||||
fields: PLAYBACK_FIELDS as unknown as never,
|
||||
// SDK omits `isVirtualUnaired` from typed request; server honours
|
||||
// it. Cast keeps wire payload identical to jellyfin-web.
|
||||
...({ isVirtualUnaired: false } as Record<string, unknown>),
|
||||
} as Parameters<ReturnType<typeof getTvShowsApi>["getEpisodes"]>[0]);
|
||||
const all = res.data.Items ?? [];
|
||||
// Drop everything before firstItem; keep firstItem and everything
|
||||
// after. Empty list if firstItem isn't in the series (shouldn't
|
||||
// happen, but matches upstream's behaviour).
|
||||
let foundItem = false;
|
||||
return all.filter((e) => {
|
||||
if (foundItem) return true;
|
||||
if (e.Id === firstItem.Id) {
|
||||
foundItem = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Movies, Audio, single Episode w/ autoplay off, etc.
|
||||
return workingItems;
|
||||
}
|
||||
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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 { GroupUpdate, 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": {
|
||||
// SDK's `GroupUpdate` type is a discriminated union with a
|
||||
// narrower `Type` enum than the wire format. Cast through
|
||||
// unknown so upstream `Manager.processGroupUpdate` can switch
|
||||
// on the real string.
|
||||
const update = Data as unknown as GroupUpdate;
|
||||
console.debug(
|
||||
"SyncPlay: group update -",
|
||||
(update as { Type?: string }).Type,
|
||||
);
|
||||
manager.processGroupUpdate(update);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
ws.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [ws, manager]);
|
||||
}
|
||||
88
providers/SyncPlay/types.ts
Normal file
88
providers/SyncPlay/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* SyncPlay — public types and tuning constants.
|
||||
*
|
||||
* Re-exports the SDK types we use, defines the small RN-specific
|
||||
* extensions (PlayerControls, OSD actions), and centralises the magic
|
||||
* numbers that govern sync behaviour.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
// SDK type re-exports — kept narrow on purpose, only what callers
|
||||
// actually reach for.
|
||||
export type {
|
||||
GroupInfoDto,
|
||||
GroupQueueMode,
|
||||
GroupRepeatMode,
|
||||
GroupShuffleMode,
|
||||
GroupStateType,
|
||||
GroupUpdate,
|
||||
PlayQueueUpdate,
|
||||
PlayQueueUpdateReason,
|
||||
SendCommand,
|
||||
SendCommandType,
|
||||
SyncPlayQueueItem,
|
||||
SyncPlayUserAccessType,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
/** Jellyfin's tick unit. 1ms = 10000 ticks. */
|
||||
export const TicksPerMillisecond = 10000;
|
||||
|
||||
/**
|
||||
* Player controls SyncPlay drives. The provider wires this up against
|
||||
* the active RN player (mpv / VLC / expo-video).
|
||||
*/
|
||||
export interface PlayerControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
/** Seek to absolute position in milliseconds. */
|
||||
seekTo: (positionMs: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
getSpeed: () => number;
|
||||
/** Current position in milliseconds. */
|
||||
getCurrentPosition: () => number;
|
||||
isPlaying: () => boolean;
|
||||
isBuffering: () => boolean;
|
||||
}
|
||||
|
||||
/** OSD action types — drive optional player-overlay feedback. */
|
||||
export type SyncPlayOsdAction =
|
||||
/** transient — 1.5s pulse, the unpause command fired locally */
|
||||
| "unpause"
|
||||
/** transient — 1.5s pulse, the pause command fired locally */
|
||||
| "pause"
|
||||
/** transient — 1.5s pulse, a seek command applied locally */
|
||||
| "seek"
|
||||
/** persistent — group is about to play (Waiting+Unpause / pending Unpause) */
|
||||
| "schedule-play"
|
||||
/** persistent — another client is buffering (Waiting+Buffer) */
|
||||
| "buffering"
|
||||
/** persistent — group transitioning to pause (Waiting+Pause) */
|
||||
| "wait-pause"
|
||||
/** persistent — group transitioning to unpause; sibling of schedule-play */
|
||||
| "wait-unpause";
|
||||
|
||||
/**
|
||||
* Tuning constants. These mirror jellyfin-web's defaults; tweak with
|
||||
* care — they affect perceived sync quality across all clients.
|
||||
*/
|
||||
export const SYNC_PLAY_TUNING = {
|
||||
/** Drift threshold (ms) above which we hard-seek to catch up. */
|
||||
minDelaySkipToSync: 400,
|
||||
/** Drift beyond this (ms) is always corrected by seeking. */
|
||||
maxDelaySync: 3000,
|
||||
/** Don't escalate buffering to the group for blips shorter than this (ms). */
|
||||
minBufferingThresholdMs: 3000,
|
||||
/** Player-attach drift (ms) above which we reconcile to group position. */
|
||||
positionReconcileThresholdMs: 500,
|
||||
/** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */
|
||||
pendingPlaybackTimeoutMs: 1500,
|
||||
} as const;
|
||||
|
||||
/** Options accepted by `Controller.play`. */
|
||||
export interface PlayOptions {
|
||||
ids?: string[];
|
||||
items?: BaseItemDto[];
|
||||
startIndex?: number;
|
||||
startPositionTicks?: number;
|
||||
}
|
||||
@@ -44,6 +44,15 @@ interface WebSocketContextType {
|
||||
lastMessage: WebSocketMessage | null;
|
||||
sendMessage: (message: any) => void;
|
||||
clearLastMessage: () => void;
|
||||
/**
|
||||
* Acquire a keep-alive token. While at least one token is held the
|
||||
* WebSocket will NOT be closed on AppState background/inactive. Used
|
||||
* by the video player while in Picture-in-Picture so SyncPlay (and
|
||||
* any other server-pushed events) keep flowing. Returns a release
|
||||
* function — call it (or rely on the React effect cleanup) when the
|
||||
* keep-alive is no longer needed.
|
||||
*/
|
||||
acquireKeepAlive: () => () => void;
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
// Ref-counted keep-alive: while > 0 we skip the AppState→background
|
||||
// close so the socket survives PiP / brief OS suspensions. iOS keeps
|
||||
// the audio session (and therefore networking) alive while PiP is
|
||||
// active, so the WS can continue to receive SyncPlay commands.
|
||||
const keepAliveCountRef = useRef(0);
|
||||
|
||||
const acquireKeepAlive = useCallback((): (() => void) => {
|
||||
keepAliveCountRef.current += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
keepAliveCountRef.current = Math.max(0, keepAliveCountRef.current - 1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
@@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (state: AppStateStatus) => {
|
||||
if (state === "background" || state === "inactive") {
|
||||
if (keepAliveCountRef.current > 0) {
|
||||
console.log(
|
||||
`App backgrounded but WS keep-alive held (${keepAliveCountRef.current}); leaving socket open`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("App moving to background, closing WebSocket...");
|
||||
ws?.close();
|
||||
} else if (state === "active") {
|
||||
// Only reconnect if we actually lost the socket (we may have
|
||||
// skipped the close above because of a keep-alive token).
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
console.log("App coming to foreground, reconnecting WebSocket...");
|
||||
connectWebSocket();
|
||||
}
|
||||
@@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, []);
|
||||
return (
|
||||
<WebSocketContext.Provider
|
||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||
value={{
|
||||
ws,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
sendMessage,
|
||||
clearLastMessage,
|
||||
acquireKeepAlive,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
|
||||
@@ -1003,6 +1003,30 @@
|
||||
"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": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
|
||||
Reference in New Issue
Block a user