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() { - - - - - - - - - - - - + {!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",