import { useQuery } from "@tanstack/react-query"; import React, { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { useSettings } from "@/utils/atoms/settings"; import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { BaseItemDto, PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; import { getDeviceId } from "@/utils/device"; type CurrentlyPlayingState = { url: string; item: BaseItemDto; }; interface PlaybackContextType { sessionData: PlaybackInfoResponse | null | undefined; currentlyPlaying: CurrentlyPlayingState | null; videoRef: React.MutableRefObject; isPlaying: boolean; isFullscreen: boolean; progressTicks: number | null; playVideo: () => void; pauseVideo: () => void; stopPlayback: () => void; presentFullscreenPlayer: () => void; dismissFullscreenPlayer: () => void; setIsFullscreen: (isFullscreen: boolean) => void; setIsPlaying: (isPlaying: boolean) => void; onProgress: (data: OnProgressData) => void; setCurrentlyPlayingState: ( currentlyPlaying: CurrentlyPlayingState | null ) => void; } const PlaybackContext = createContext(null); export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const videoRef = useRef(null); const [settings] = useSettings(); const [isPlaying, setIsPlaying] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [progressTicks, setProgressTicks] = useState(0); const [currentlyPlaying, setCurrentlyPlaying] = useState(null); // WS const [ws, setWs] = useState(null); const [isConnected, setIsConnected] = useState(false); const { data: sessionData } = useQuery({ queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api], queryFn: async () => { if (!currentlyPlaying?.item.Id) return null; const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({ itemId: currentlyPlaying?.item.Id, userId: user?.Id, }); return playbackData.data; }, enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id, }); const { data: deviceId } = useQuery({ queryKey: ["deviceId", api], queryFn: getDeviceId, }); const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => { if (state) { setCurrentlyPlaying(state); setIsPlaying(true); if (settings?.openFullScreenVideoPlayerByDefault) presentFullscreenPlayer(); } else { setCurrentlyPlaying(null); setIsFullscreen(false); setIsPlaying(false); } }; // Define control methods const playVideo = useCallback(() => { videoRef.current?.resume(); setIsPlaying(true); reportPlaybackProgress({ api, itemId: currentlyPlaying?.item.Id, positionTicks: progressTicks ? progressTicks : 0, sessionId: sessionData?.PlaySessionId, IsPaused: true, }); }, [ api, currentlyPlaying?.item.Id, sessionData?.PlaySessionId, progressTicks, ]); const pauseVideo = useCallback(() => { videoRef.current?.pause(); setIsPlaying(false); reportPlaybackProgress({ api, itemId: currentlyPlaying?.item.Id, positionTicks: progressTicks ? progressTicks : 0, sessionId: sessionData?.PlaySessionId, IsPaused: false, }); }, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]); const stopPlayback = useCallback(async () => { await reportPlaybackStopped({ api, itemId: currentlyPlaying?.item?.Id, sessionId: sessionData?.PlaySessionId, positionTicks: progressTicks ? progressTicks : 0, }); setCurrentlyPlayingState(null); }, [currentlyPlaying, sessionData, progressTicks]); const onProgress = useCallback( ({ currentTime }: OnProgressData) => { const ticks = currentTime * 10000000; setProgressTicks(ticks); reportPlaybackProgress({ api, itemId: currentlyPlaying?.item.Id, positionTicks: ticks, sessionId: sessionData?.PlaySessionId, IsPaused: !isPlaying, }); }, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying] ); const presentFullscreenPlayer = useCallback(() => { videoRef.current?.presentFullscreenPlayer(); setIsFullscreen(true); }, []); const dismissFullscreenPlayer = useCallback(() => { videoRef.current?.dismissFullscreenPlayer(); setIsFullscreen(false); }, []); useEffect(() => { if (!deviceId || !api) return; const url = `wss://${api?.basePath .replace("https://", "") .replace("http://", "")}/socket?api_key=${ api?.accessToken }&deviceId=${deviceId}`; console.log("WS", url); const newWebSocket = new WebSocket(url); let keepAliveInterval: NodeJS.Timeout | null = null; newWebSocket.onopen = () => { setIsConnected(true); // Start sending "KeepAlive" message every 30 seconds keepAliveInterval = setInterval(() => { if (newWebSocket.readyState === WebSocket.OPEN) { newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); console.log("KeepAlive message sent"); } }, 30000); }; newWebSocket.onerror = (e) => { console.error("WebSocket error:", e); setIsConnected(false); }; newWebSocket.onclose = (e) => { console.log("WebSocket connection closed:", e.reason); if (keepAliveInterval) { clearInterval(keepAliveInterval); } }; setWs(newWebSocket); return () => { if (keepAliveInterval) { clearInterval(keepAliveInterval); } newWebSocket.close(); }; }, [api, deviceId]); useEffect(() => { if (!ws) return; ws.onmessage = (e) => { const json = JSON.parse(e.data); const command = json?.Data?.Command; // On PlayPause if (command === "PlayPause") { console.log("Command ~ PlayPause"); if (isPlaying) pauseVideo(); else playVideo(); } else if (command === "Stop") { console.log("Command ~ Stop"); stopPlayback(); } }; }, [ws, stopPlayback, playVideo, pauseVideo]); return ( {children} ); }; export const usePlayback = () => { const context = useContext(PlaybackContext); if (!context) { throw new Error("usePlayback must be used within a PlaybackProvider"); } return context; };