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