Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Kim
ab42e8a576 Add PIP support for syncplay 2026-06-05 21:42:06 +10:00
Alex Kim
0e93cd5385 Improve code quality 2026-06-05 20:00:55 +10:00
Alex Kim
96b4121c1f Merge branch 'develop' into feature/sync-play 2026-06-05 17:12:36 +10:00
lance chant
f7033e7abb fix: player reporting when exiting and app splash load (#1662) 2026-06-05 08:14:45 +02:00
Alex
0d796d01b8 feat(mpv-ios): Fix controls not pressable after resuming from PIP (#1667)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-04 21:44:04 +10:00
Alex Kim
27c400a54a Merge branch 'develop' into feature/sync-play 2026-05-31 20:47:27 +10:00
Alex Kim
261f7cc0cd Merge branch 'develop' into feature/sync-play 2026-05-31 19:05:32 +10:00
Alex Kim
d06daef933 feat(sync-play): squash feature/sync-play 2026-05-31 19:04:14 +10:00
66 changed files with 4939 additions and 194 deletions

View File

@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ - **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ - **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -143,6 +143,14 @@ interface ModalOptions {
} }
``` ```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling ## Default Styling
The modal uses these default styles (can be overridden via options): The modal uses these default styles (can be overridden via options):

View File

@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -33,6 +36,7 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>

View File

@@ -31,6 +31,7 @@ import {
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
@@ -49,7 +50,8 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -77,6 +79,11 @@ export default function DirectPlayerPage() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); 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">( const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default", "default",
); );
@@ -128,6 +135,7 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -137,9 +145,23 @@ export default function DirectPlayerPage() {
offline: string; offline: string;
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
/** Whether playback was initiated by SyncPlay */
syncPlay?: string;
}>(); }>();
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
const openedViaSyncPlay = syncPlayStr === "true";
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true"; const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -415,8 +437,72 @@ export default function DirectPlayerPage() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
// SyncPlay: Connect player controls when video is ready
useEffect(() => {
if (!isVideoLoaded || !videoRef.current || offline) {
setPlayerControls(null);
return;
}
const controls: PlayerControls = {
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (positionMs: number) => {
const positionSec = positionMs / 1000;
console.log(
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
);
videoRef.current?.seekTo(positionSec);
},
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
getSpeed: () => currentPlaybackSpeed,
getCurrentPosition: () => progress.get(),
isPlaying: () => isPlaying,
isBuffering: () => isBuffering,
};
setPlayerControls(controls);
return () => {
setPlayerControls(null);
};
}, [
isVideoLoaded,
offline,
isPlaying,
isBuffering,
currentPlaybackSpeed,
progress,
setPlayerControls,
]);
// SyncPlay: Report buffering/ready state to server.
//
// CRITICAL: We must report `buffering` to the server *during* initial
// load (before `isVideoLoaded`), otherwise the server treats us as ready
// and proceeds without waiting for us. jellyfin-web reports this for
// free via the HTML5 video element's `waiting` event; for us, the
// initial load itself is the buffering window.
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const isLocallyReady = isVideoLoaded && !isBuffering;
// 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 () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
@@ -439,21 +525,15 @@ export default function DirectPlayerPage() {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({ await getPlaystateApi(api).reportPlaybackStopped({
itemId: item.Id, playbackStopInfo: {
mediaSourceId: mediaSourceId, ItemId: item.Id,
positionTicks: currentTimeInTicks, MediaSourceId: mediaSourceId,
playSessionId: stream.sessionId, PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
},
}); });
}, [ }, [api, item, mediaSourceId, stream, progress, offline]);
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping // Update URL with final playback position before stopping
@@ -471,9 +551,10 @@ export default function DirectPlayerPage() {
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => { return () => {
reportPlaybackStopped();
beforeRemoveListener(); beforeRemoveListener();
}; };
}, [navigation, stop]); }, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback((): const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo | PlaybackProgressInfo
@@ -650,10 +731,12 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks); const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming // Build source config - headers only needed for online streaming
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
const shouldAutoplay = !openedViaSyncPlay;
const source: MpvVideoSource = { const source: MpvVideoSource = {
url: stream.url, url: stream.url,
startPosition: startPos, startPosition: startPos,
autoplay: true, autoplay: shouldAutoplay,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences // Pass cache/buffer settings from user preferences
@@ -848,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); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -872,10 +990,21 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback((position: number) => { const seek = useCallback(
// MPV expects seconds, convert from ms (position: number) => {
videoRef.current?.seekTo?.(position / 1000); // Route through SyncPlay when active. `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 // TV audio track change handler
const handleAudioIndexChange = useCallback( const handleAudioIndexChange = useCallback(
@@ -1267,6 +1396,10 @@ export default function DirectPlayerPage() {
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange} onPictureInPictureChange={_onPictureInPictureChange}
syncPlayDelegated={isSyncPlayEnabled}
onPipPlayRequest={_onPipPlayRequest}
onPipPauseRequest={_onPipPauseRequest}
onPipSkipRequest={_onPipSkipRequest}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);

View File

@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -409,133 +410,136 @@ function Layout() {
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<DownloadProvider> <SyncPlayProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </SyncPlayProvider>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>

View File

@@ -1,3 +1,4 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,6 +3,7 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

14
augmentations/string.ts Normal file
View File

@@ -0,0 +1,14 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",

View File

View File

@@ -0,0 +1,203 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -30,6 +30,7 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -67,6 +68,14 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
// then performs the navigation uniformly for everyone, matching
// jellyfin-web's playbackManager intercept (Controller.play).
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom; const effectiveColors = colors || globalColorAtom;
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
// SyncPlay intercept: in a group, route playback through sthe server so
// every member gets the same PlayQueue: NewPlaylist update and navigates
// together. Skips local navigation and the Chromecast prompt entirely —
// SyncPlay + Chromecast isn't a supported combination yet, same as
// jellyfin-web.
if (isSyncPlayEnabled && syncPlayController && item.Id) {
try {
// Pass the full `item` (not just the ID) so the SyncPlay controller
// can run `translateItemsForPlayback` with full context — this is
// what jellyfin-web does, and it lets us expand Series / Season /
// BoxSet into real episode/track IDs before broadcasting the queue.
// Without expansion, receivers (jellyfin-web in particular) get
// container IDs they can't play and silently fail to open the
// player.
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
goToPlayer, goToPlayer,
isOffline, isOffline,
t, t,
isSyncPlayEnabled,
syncPlayController,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {

View File

@@ -0,0 +1,20 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -0,0 +1,28 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -0,0 +1,12 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -0,0 +1,63 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -0,0 +1,48 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>

View 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>
);
}

View 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",
},
});

View 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>
</>
);
}

View 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>
);
}

View File

@@ -0,0 +1,8 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
export { SyncPlayButton } from "./SyncPlayButton";
export { SyncPlaySpinner } from "./SyncPlaySpinner";

View File

@@ -3,6 +3,7 @@ import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
@@ -121,15 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
{!isBuffering ? ( <SyncPlayActionIcon
<Ionicons size={ICON_SIZES.CENTER}
name={isPlaying ? "pause" : "play"} fallback={
size={ICON_SIZES.CENTER} !isBuffering ? (
color='white' <Ionicons
/> name={isPlaying ? "pause" : "play"}
) : ( size={ICON_SIZES.CENTER}
<Loader size={"large"} /> color='white'
)} />
) : (
<Loader size={"large"} />
)
}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

39
constants/Languages.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -0,0 +1,35 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

120
hooks/useImageColors.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

View 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]);
}

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {
@@ -80,9 +81,9 @@ export const useWebSocket = ({
playTrailers, playTrailers,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { lastMessage } = useWebSocketContext(); const { lastMessage, clearLastMessage } = useWebSocketContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { clearLastMessage } = useWebSocketContext(); const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
useEffect(() => { useEffect(() => {
if (!lastMessage) return; if (!lastMessage) return;
@@ -96,6 +97,25 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
const isSyncPlayCommand =
lastMessage.MessageType === "SyncPlayCommand" ||
lastMessage.MessageType === "SyncPlayGroupUpdate";
const isPlaybackCommand = [
"PlayPause",
"Pause",
"Unpause",
"Stop",
"Seek",
"NextTrack",
"PreviousTrack",
].includes(command ?? "");
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
return;
}
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
togglePlay(); togglePlay();

View File

@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
// No-op on Android - media session integration would require MediaSessionCompat // 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 // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
} }
// Defines events that the view can send to JavaScript // 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")
} }
} }
} }

View File

@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange 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 textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlayerView(textureView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
if (syncPlayDelegated) {
onPipPlayRequest(mapOf<String, Any>())
return
}
play() play()
} }
override fun onPause() { override fun onPause() {
if (syncPlayDelegated) {
onPipPauseRequest(mapOf<String, Any>())
return
}
pause() pause()
} }
override fun onSeekBy(seconds: Double) { 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) seekBy(seconds)
} }

View File

@@ -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 // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // 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")
} }
} }
} }

View File

@@ -61,6 +61,17 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = 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 currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -637,6 +648,9 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)") print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts // Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) 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) { func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -655,6 +669,9 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill { if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill 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) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
@@ -664,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPlay(_ controller: PiPController) { func pipControllerPlay(_ controller: PiPController) {
print("PiP play requested") 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 intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -671,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPause(_ controller: PiPController) { func pipControllerPause(_ controller: PiPController) {
print("PiP pause requested") print("PiP pause requested")
if syncPlayDelegated {
onPipPauseRequest([:])
return
}
intendedPlayState = false intendedPlayState = false
renderer?.pausePlayback() renderer?.pausePlayback()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
@@ -680,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
let seconds = CMTimeGetSeconds(interval) let seconds = CMTimeGetSeconds(interval)
print("PiP skip by interval: \(seconds)") print("PiP skip by interval: \(seconds)")
let target = max(0, cachedPosition + 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) seekTo(position: target)
} }

View File

@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
isActive: boolean; 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 = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
onPictureInPictureChange?: (event: { onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload; nativeEvent: OnPictureInPictureChangePayload;
}) => void; }) => 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 { export interface MpvPlayerViewRef {

View File

@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
const response = await getUserApi(apiInstance).getCurrentUser(); // Dismiss splash screen with cached data immediately,
setUser(response.data); // fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved try {
if (storedUser?.Id && storedUser?.Name) { const response = await getUserApi(apiInstance).getCurrentUser();
const existingCredential = await getAccountCredential( setUser(response.data);
serverUrl,
storedUser.Id, // Migrate current session to secure storage if not already saved
); if (storedUser?.Id && storedUser?.Name) {
if (!existingCredential) { const existingCredential = await getAccountCredential(
await saveAccountCredential({
serverUrl, serverUrl,
serverName: "", storedUser.Id,
token, );
userId: storedUser.Id, if (!existingCredential) {
username: storedUser.Name, await saveAccountCredential({
savedAt: Date.now(), serverUrl,
securityType: "none", serverName: "",
primaryImageTag: response.data.PrimaryImageTag ?? undefined, token,
}); userId: storedUser.Id,
} else if ( username: storedUser.Name,
response.data.PrimaryImageTag !== savedAt: Date.now(),
existingCredential.primaryImageTag securityType: "none",
) { primaryImageTag: response.data.PrimaryImageTag ?? undefined,
// Update image tag if it has changed });
addAccountToServer(serverUrl, existingCredential.serverName, { } else if (
userId: existingCredential.userId, response.data.PrimaryImageTag !==
username: existingCredential.username, existingCredential.primaryImageTag
securityType: existingCredential.securityType, ) {
savedAt: existingCredential.savedAt, // Update image tag if it has changed
primaryImageTag: response.data.PrimaryImageTag ?? undefined, 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) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };

View 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;

View 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);
}
}
});
}

View 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;

View 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;
}

View 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);
}

View 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;

View 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;

View 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;

View 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";

View 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);
}
}
}

View 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);
}
}

View 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;
}
},
};
}

View 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,
);
}
}

View 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;
}

View 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]);
}

View 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;
}

View File

@@ -44,6 +44,15 @@ interface WebSocketContextType {
lastMessage: WebSocketMessage | null; lastMessage: WebSocketMessage | null;
sendMessage: (message: any) => void; sendMessage: (message: any) => void;
clearLastMessage: () => 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); const WebSocketContext = createContext<WebSocketContextType | null>(null);
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>( const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
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(() => { const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken || !isNetworkConnected) { if (!deviceId || !api?.accessToken || !isNetworkConnected) {
@@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
useEffect(() => { useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => { const handleAppStateChange = (state: AppStateStatus) => {
if (state === "background" || state === "inactive") { 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..."); console.log("App moving to background, closing WebSocket...");
ws?.close(); ws?.close();
} else if (state === "active") { } 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..."); console.log("App coming to foreground, reconnecting WebSocket...");
connectWebSocket(); connectWebSocket();
} }
@@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []); }, []);
return ( return (
<WebSocketContext.Provider <WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }} value={{
ws,
isConnected,
lastMessage,
sendMessage,
clearLastMessage,
acquireKeepAlive,
}}
> >
{children} {children}
</WebSocketContext.Provider> </WebSocketContext.Provider>

View File

@@ -108,7 +108,7 @@
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads", "downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline.", "downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
@@ -384,6 +384,7 @@
"plugins": { "plugins": {
"plugins_title": "Plugins", "plugins_title": "Plugins",
"jellyseerr": { "jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL", "server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Seerr URL", "server_url_placeholder": "Seerr URL",
@@ -1002,6 +1003,30 @@
"all": "All media (default)" "all": "All media (default)"
} }
}, },
"syncplay": {
"title": "SyncPlay",
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_group": "Create Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"group_id": "Group ID",
"leader": "Leader",
"members": "members",
"enabled": "SyncPlay enabled",
"disabled": "SyncPlay disabled",
"user_joined": "{{username}} joined the group",
"user_left": "{{username}} left the group",
"permission_required": "Permission required to use SyncPlay",
"group_does_not_exist": "Group does not exist",
"create_denied": "Permission denied to create group",
"join_denied": "Permission denied to join group",
"library_access_denied": "Library access denied",
"waiting_for_group": "Waiting for group...",
"joining_playback": "Joining group playback...",
"failed_to_start": "Failed to start SyncPlay group playback"
},
"companion_login": { "companion_login": {
"title": "Pair with TV", "title": "Pair with TV",
"align_qr": "Align the QR code within the frame", "align_qr": "Align the QR code within the frame",

18
utils/bToMb.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Convert bits to megabits or gigabits
*
* Return nice looking string
* If under 1000Mb, return XXXMB, else return X.XGB
*/
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
if (!bits) return "0MB";
const megabits = bits / 1000000;
if (megabits < 1000) {
return `${Math.round(megabits)}MB`;
}
const gigabits = megabits / 1000;
return `${gigabits.toFixed(1)}GB`;
}

View File

@@ -0,0 +1,47 @@
import {
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client";
/**
* Converts a ColletionType to a BaseItemKind (also called ItemType)
*
* CollectionTypes
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
readonly Books: "books";
readonly Photos: "photos";
readonly Livetv: "livetv";
readonly Playlists: "playlists";
readonly Folders: "folders";
*/
export const colletionTypeToItemType = (
collectionType?: CollectionType | null,
): BaseItemKind | undefined => {
if (!collectionType) return undefined;
switch (collectionType) {
case CollectionType.Movies:
return BaseItemKind.Movie;
case CollectionType.Tvshows:
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
case CollectionType.Folders:
return BaseItemKind.Folder;
case CollectionType.Photos:
return BaseItemKind.Photo;
case CollectionType.Trailers:
return BaseItemKind.Trailer;
}
return undefined;
};

View File

@@ -0,0 +1,56 @@
import axios from "axios";
export interface SubtitleTrack {
index: number;
name: string;
uri: string;
language: string;
default: boolean;
forced: boolean;
autoSelect: boolean;
}
export async function parseM3U8ForSubtitles(
url: string,
): Promise<SubtitleTrack[]> {
try {
const response = await axios.get(url, { responseType: "text" });
const lines = response.data.split(/\r?\n/);
const subtitleTracks: SubtitleTrack[] = [];
let index = 0;
lines.forEach((line: string) => {
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
const attributes = parseAttributes(line);
const track: SubtitleTrack = {
index: index++,
name: attributes.NAME || "",
uri: attributes.URI || "",
language: attributes.LANGUAGE || "",
default: attributes.DEFAULT === "YES",
forced: attributes.FORCED === "YES",
autoSelect: attributes.AUTOSELECT === "YES",
};
subtitleTracks.push(track);
}
});
return subtitleTracks;
} catch (error) {
console.error("Failed to fetch or parse the M3U8 file:", error);
throw error;
}
}
function parseAttributes(line: string): { [key: string]: string } {
const attributes: { [key: string]: string } = {};
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
for (const match of line.matchAll(regex)) {
const key = match[1];
const value = match[2] ?? match[3]; // quoted or unquoted
attributes[key] = value;
}
return attributes;
}

View File

@@ -0,0 +1,56 @@
import type { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios";
import type { Settings } from "../../atoms/settings";
import { generateDeviceProfile } from "../../profiles/native";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
deviceProfile: Settings["deviceProfile"];
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing parameters for marking item as not played");
}
try {
const d = api.axiosInstance.post(
`${api.basePath}/Sessions/Capabilities/Full`,
{
playableMediaTypes: ["Audio", "Video"],
supportedCommands: [
"PlayState",
"Play",
"ToggleFullscreen",
"DisplayMessage",
"Mute",
"Unmute",
"SetVolume",
"ToggleMute",
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: generateDeviceProfile(),
},
{
headers: getAuthHeaders(api),
},
);
return d;
} catch (_error) {
throw new Error("Failed to mark as not played");
}
};

View File

@@ -0,0 +1,44 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getAuthHeaders } from "../jellyfin";
interface NextUpParams {
itemId?: string | null;
userId?: string | null;
api?: Api | null;
}
/**
* Fetches the next up episodes for a series or all series for a user.
*
* @param params - The parameters for fetching next up episodes
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
*/
export const nextUp = async ({
itemId,
userId,
api,
}: NextUpParams): Promise<BaseItemDto[]> => {
if (!userId || !api) {
console.error("Invalid parameters for nextUp: missing userId or api");
return [];
}
try {
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
`${api.basePath}/Shows/NextUp`,
{
params: {
SeriesId: itemId || undefined,
UserId: userId,
Fields: "MediaSourceCount",
},
headers: getAuthHeaders(api),
},
);
return response.data.Items;
} catch (_error) {
return [];
}
};

View File

@@ -0,0 +1,34 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
/**
* Retrieves an item by its ID from the API.
*
* @param api - The Jellyfin API instance.
* @param itemId - The ID of the item to retrieve.
* @returns The item object or undefined if no item matches the ID.
*/
export const getItemById = async (
api?: Api | null | undefined,
itemId?: string | null | undefined,
): Promise<BaseItemDto | undefined> => {
if (!api || !itemId) {
return undefined;
}
try {
const itemData = await getUserLibraryApi(api).getItem({ itemId });
const item = itemData.data;
if (!item) {
console.error("No items found with the specified ID:", itemId);
return undefined;
}
return item;
} catch (error) {
console.error("Failed to retrieve the item:", error);
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
}
};

View File

@@ -72,6 +72,21 @@ export const readFromLog = (): LogEntry[] => {
return logs ? JSON.parse(logs) : []; return logs ? JSON.parse(logs) : [];
}; };
export const clearLogs = () => {
storage.remove("logs");
};
export const dumpDownloadDiagnostics = (extra: any = {}) => {
const diagnostics = {
timestamp: new Date().toISOString(),
processes: extra?.processes || [],
nativeTasks: extra?.nativeTasks || [],
focusedProcess: extra?.focusedProcess || null,
};
writeDebugLog("Download diagnostics", diagnostics);
return diagnostics;
};
export function useLog() { export function useLog() {
const context = useContext(LogContext); const context = useContext(LogContext);
if (context === null) { if (context === null) {

5
utils/secondsToTicks.ts Normal file
View File

@@ -0,0 +1,5 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

View File

@@ -203,6 +203,27 @@ export async function hasAccountCredential(
return stored !== null; return stored !== null;
} }
/**
* Delete all credentials for all accounts on all servers.
*/
export async function clearAllCredentials(): Promise<void> {
const previousServers = getPreviousServers();
for (const server of previousServers) {
for (const account of server.accounts) {
const key = credentialKey(server.address, account.userId);
await SecureStore.deleteItemAsync(key);
}
}
// Clear all accounts from servers
const clearedServers = previousServers.map((server) => ({
...server,
accounts: [],
}));
storage.set("previousServers", JSON.stringify(clearedServers));
}
/** /**
* Add or update an account in a server's accounts list. * Add or update an account in a server's accounts list.
*/ */