From 80d63c021999d1c3bc242aa04b3a141023d03671 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Mon, 2 Jun 2025 13:16:15 +0200 Subject: [PATCH] feat: remotecontrol (#705) --- app/(auth)/(tabs)/(home)/sessions/index.tsx | 180 +++++++++++- app/(auth)/player/direct-player.tsx | 92 +++++- components/ItemContent.tsx | 8 + components/PlayInRemoteSession.tsx | 194 ++++++++++++ hooks/useSessions.ts | 24 ++ hooks/useWebsockets.ts | 308 ++++++++++++++++++-- providers/WebSocketProvider.tsx | 91 +++++- 7 files changed, 866 insertions(+), 31 deletions(-) create mode 100644 components/PlayInRemoteSession.tsx diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index 6c589f40..012207bc 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -18,12 +18,18 @@ import { HardwareAccelerationType, type SessionInfoDto, } from "@jellyfin/sdk/lib/generated-client"; +import { + GeneralCommandType, + PlaystateCommand, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; +import { get } from "lodash"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { TouchableOpacity, View } from "react-native"; export default function page() { const { sessions, isLoading } = useSessions({} as useSessionsProps); @@ -110,6 +116,77 @@ const SessionCard = ({ session }: SessionCardProps) => { }, }); + // Handle session controls + const [isControlLoading, setIsControlLoading] = useState< + Record + >({}); + + const handleSystemCommand = async (command: GeneralCommandType) => { + if (!api || !session.Id) return false; + + setIsControlLoading({ ...isControlLoading, [command]: true }); + + try { + getSessionApi(api).sendSystemCommand({ + sessionId: session.Id, + command, + }); + return true; + } catch (error) { + console.error(`Error sending ${command} command:`, error); + return false; + } finally { + setIsControlLoading({ ...isControlLoading, [command]: false }); + } + }; + + const handlePlaystateCommand = async (command: PlaystateCommand) => { + if (!api || !session.Id) return false; + + setIsControlLoading({ ...isControlLoading, [command]: true }); + + try { + getSessionApi(api).sendPlaystateCommand({ + sessionId: session.Id, + command, + }); + + return true; + } catch (error) { + console.error(`Error sending playstate ${command} command:`, error); + return false; + } finally { + setIsControlLoading({ ...isControlLoading, [command]: false }); + } + }; + + const handlePlayPause = async () => { + console.log("handlePlayPause"); + await handlePlaystateCommand(PlaystateCommand.PlayPause); + }; + + const handleStop = async () => { + await handlePlaystateCommand(PlaystateCommand.Stop); + }; + + const handlePrevious = async () => { + await handlePlaystateCommand(PlaystateCommand.PreviousTrack); + }; + + const handleNext = async () => { + await handlePlaystateCommand(PlaystateCommand.NextTrack); + }; + + const handleToggleMute = async () => { + await handleSystemCommand(GeneralCommandType.ToggleMute); + }; + const handleVolumeUp = async () => { + await handleSystemCommand(GeneralCommandType.VolumeUp); + }; + const handleVolumeDown = async () => { + await handleSystemCommand(GeneralCommandType.VolumeDown); + }; + useInterval(tick, 1000); return ( @@ -181,6 +258,107 @@ const SessionCard = ({ session }: SessionCardProps) => { }} /> + + {/* Session controls */} + + + + + + + {session.PlayState?.IsPaused ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 27a66f0f..1a247e08 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -60,6 +60,7 @@ export default function page() { const [showControls, _setShowControls] = useState(true); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [isPlaying, setIsPlaying] = useState(false); + const [isMuted, setIsMuted] = useState(false); const [isBuffering, setIsBuffering] = useState(true); const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isPipStarted, setIsPipStarted] = useState(false); @@ -67,6 +68,10 @@ export default function page() { const progress = useSharedValue(0); const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); + const VolumeManager = Platform.isTV + ? null + : require("react-native-volume-manager"); + let getDownloadedItem = null; if (!Platform.isTV) { getDownloadedItem = downloadProvider.useDownload(); @@ -219,7 +224,7 @@ export default function page() { setIsPlaying(!isPlaying); if (isPlaying) { await videoRef.current?.pause(); - reportPlaybackStopped(); + reportPlaybackProgress(); } else { videoRef.current?.play(); await getPlaystateApi(api!).reportPlaybackStart({ @@ -239,7 +244,15 @@ export default function page() { }); revalidateProgressCache(); - }, [api, item, mediaSourceId, stream]); + }, [ + api, + item, + mediaSourceId, + stream, + progress, + offline, + revalidateProgressCache, + ]); const stop = useCallback(() => { reportPlaybackStopped(); @@ -265,7 +278,7 @@ export default function page() { isPaused: !isPlaying, playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: stream.sessionId, - isMuted: false, + isMuted: isMuted, canSeek: true, repeatMode: RepeatMode.RepeatNone, playbackOrder: PlaybackOrder.Default, @@ -329,13 +342,84 @@ export default function page() { return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0; - }, [item]); + }, [item, offline]); + + const volumeUpCb = useCallback(async () => { + if (Platform.isTV) return; + + try { + const { volume: currentVolume } = await VolumeManager.getVolume(); + const newVolume = Math.min(currentVolume + 0.1, 1.0); + + await VolumeManager.setVolume(newVolume); + } catch (error) { + console.error("Error adjusting volume:", error); + } + }, []); + const [previousVolume, setPreviousVolume] = useState(null); + + const toggleMuteCb = useCallback(async () => { + if (Platform.isTV) return; + + try { + const { volume: currentVolume } = await VolumeManager.getVolume(); + const currentVolumePercent = currentVolume * 100; + + if (currentVolumePercent > 0) { + // Currently not muted, so mute + setPreviousVolume(currentVolumePercent); + await VolumeManager.setVolume(0); + setIsMuted(true); + } else { + // Currently muted, so restore previous volume + const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume + await VolumeManager.setVolume(volumeToRestore / 100); + setPreviousVolume(null); + setIsMuted(false); + } + } catch (error) { + console.error("Error toggling mute:", error); + } + }, [previousVolume]); + const volumeDownCb = useCallback(async () => { + if (Platform.isTV) return; + + try { + const { volume: currentVolume } = await VolumeManager.getVolume(); + const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10% + console.log( + "Volume Down", + Math.round(currentVolume * 100), + "→", + Math.round(newVolume * 100), + ); + await VolumeManager.setVolume(newVolume); + } catch (error) { + console.error("Error adjusting volume:", error); + } + }, []); + + const setVolumeCb = useCallback(async (newVolume: number) => { + if (Platform.isTV) return; + + try { + const clampedVolume = Math.max(0, Math.min(newVolume, 100)); + console.log("Setting volume to", clampedVolume); + await VolumeManager.setVolume(clampedVolume / 100); + } catch (error) { + console.error("Error setting volume:", error); + } + }, []); useWebSocket({ isPlaying: isPlaying, togglePlay: togglePlay, stopPlayback: stop, offline, + toggleMute: toggleMuteCb, + volumeUp: volumeUpCb, + volumeDown: volumeDownCb, + setVolume: setVolumeCb, }); const onPlaybackStateChanged = useCallback( diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 43e8ec45..33fd305a 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -17,6 +17,7 @@ import { useImageColors } from "@/hooks/useImageColors"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import type { @@ -34,6 +35,7 @@ import { ItemHeader } from "./ItemHeader"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; +import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; export type SelectedOptions = { @@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( const { orientation } = useOrientation(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const [user] = useAtom(userAtom); + useImageColors({ item }); const [loadingLogo, setLoadingLogo] = useState(true); @@ -97,6 +101,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( {!Platform.isTV && ( )} + {user?.Policy?.IsAdministrator && ( + + )} + diff --git a/components/PlayInRemoteSession.tsx b/components/PlayInRemoteSession.tsx new file mode 100644 index 00000000..5111068c --- /dev/null +++ b/components/PlayInRemoteSession.tsx @@ -0,0 +1,194 @@ +import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { Ionicons } from "@expo/vector-icons"; +import { + type BaseItemDto, + PlayCommand, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; +import { useAtomValue } from "jotai"; +import React, { useState } from "react"; +import { + FlatList, + Modal, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { Loader } from "./Loader"; +import { RoundButton } from "./RoundButton"; +import { Text } from "./common/Text"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + size?: "default" | "large"; +} + +export const PlayInRemoteSessionButton: React.FC = ({ + item, + ...props +}) => { + const [modalVisible, setModalVisible] = useState(false); + const api = useAtomValue(apiAtom); + const { sessions, isLoading } = useAllSessions({} as useSessionsProps); + const handlePlayInSession = async (sessionId: string) => { + if (!api || !item.Id) return; + + try { + console.log(`Playing ${item.Name} in session ${sessionId}`); + getSessionApi(api).play({ + sessionId, + itemIds: [item.Id], + playCommand: PlayCommand.PlayNow, + }); + + setModalVisible(false); + } catch (error) { + console.error("Error playing in remote session:", error); + } + }; + + return ( + + setModalVisible(true)} + size={props.size} + /> + + setModalVisible(false)} + > + + + + Select Session + setModalVisible(false)}> + + + + + + {isLoading ? ( + + + + ) : !sessions || sessions.length === 0 ? ( + + No active sessions found + + ) : ( + session.Id || "unknown"} + renderItem={({ item: session }) => ( + handlePlayInSession(session.Id || "")} + > + + + {session.DeviceName} + + + {session.UserName} • {session.Client} + + {session.NowPlayingItem && ( + + Now playing:{" "} + {session.NowPlayingItem.SeriesName + ? `${session.NowPlayingItem.SeriesName} :` + : ""} + {session.NowPlayingItem.Name} + + )} + + + + )} + contentContainerStyle={styles.listContent} + /> + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.7)", + }, + modalView: { + width: "90%", + maxHeight: "80%", + backgroundColor: "#1c1c1c", + borderRadius: 20, + overflow: "hidden", + display: "flex", + flexDirection: "column", + }, + modalHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#333", + }, + modalContent: { + flex: 1, + }, + modalTitle: { + fontSize: 18, + fontWeight: "600", + }, + loadingContainer: { + padding: 40, + alignItems: "center", + }, + noSessionsText: { + padding: 40, + textAlign: "center", + color: "#888", + }, + listContent: { + paddingVertical: 8, + }, + sessionItem: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: "#333", + }, + sessionInfo: { + flex: 1, + }, + sessionName: { + fontSize: 16, + fontWeight: "500", + marginBottom: 4, + }, + sessionDetails: { + fontSize: 13, + opacity: 0.7, + marginBottom: 2, + }, + nowPlaying: { + fontSize: 12, + opacity: 0.5, + fontStyle: "italic", + }, +}); diff --git a/hooks/useSessions.ts b/hooks/useSessions.ts index 6552f6ea..03996c88 100644 --- a/hooks/useSessions.ts +++ b/hooks/useSessions.ts @@ -44,3 +44,27 @@ export const useSessions = ({ return { sessions: data, isLoading }; }; + +export const useAllSessions = ({ + refetchInterval = 5 * 1000, + activeWithinSeconds = 360, +}: useSessionsProps) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data, isLoading } = useQuery({ + queryKey: ["allSessions"], + queryFn: async () => { + if (!api || !user || !user.Policy?.IsAdministrator) { + return []; + } + const response = await getSessionApi(api).getSessions({ + activeWithinSeconds: activeWithinSeconds, + }); + return response.data; + }, + refetchInterval: refetchInterval, + }); + + return { sessions: data, isLoading }; +}; diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 70c91b4f..4d7caa28 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -9,6 +9,38 @@ interface UseWebSocketProps { togglePlay: () => void; stopPlayback: () => void; offline: boolean; + + nextTrack?: () => void; + previousTrack?: () => void; + rewindPlayback?: () => void; + fastForwardPlayback?: () => void; + seekPlayback?: (positionTicks: number) => void; + volumeUp?: () => void; + volumeDown?: () => void; + toggleMute?: () => void; + toggleOsd?: () => void; + toggleFullscreen?: () => void; + goHome?: () => void; + goToSettings?: () => void; + setAudioStreamIndex?: (index: number) => void; + setSubtitleStreamIndex?: (index: number) => void; + + moveUp?: () => void; + moveDown?: () => void; + moveLeft?: () => void; + moveRight?: () => void; + select?: () => void; + pageUp?: () => void; + pageDown?: () => void; + setVolume?: (volume: number) => void; + setRepeatMode?: (mode: string) => void; + setShuffleMode?: (mode: string) => void; + togglePictureInPicture?: () => void; + takeScreenshot?: () => void; + sendString?: (text: string) => void; + sendKey?: (key: string) => void; + playMediaSource?: (itemIds: string[], startPositionTicks?: number) => void; + playTrailers?: (itemId: string) => void; } export const useWebSocket = ({ @@ -16,38 +48,270 @@ export const useWebSocket = ({ togglePlay, stopPlayback, offline, + nextTrack, + previousTrack, + rewindPlayback, + fastForwardPlayback, + seekPlayback, + volumeUp, + volumeDown, + toggleMute, + toggleOsd, + toggleFullscreen, + goHome, + goToSettings, + setAudioStreamIndex, + setSubtitleStreamIndex, + moveUp, + moveDown, + moveLeft, + moveRight, + select, + pageUp, + pageDown, + setVolume, + setRepeatMode, + setShuffleMode, + togglePictureInPicture, + takeScreenshot, + sendString, + sendKey, + playMediaSource, + playTrailers, }: UseWebSocketProps) => { const router = useRouter(); - const { ws } = useWebSocketContext(); + const { lastMessage } = useWebSocketContext(); const { t } = useTranslation(); + const { clearLastMessage } = useWebSocketContext(); useEffect(() => { - if (!ws) return; + if (!lastMessage) return; if (offline) return; - ws.onmessage = (e) => { - const json = JSON.parse(e.data); - const command = json?.Data?.Command; + const messageType = lastMessage.MessageType; + const command: string | undefined = + lastMessage?.Data?.Command || lastMessage?.Data?.Name; - console.log("[WS] ~ ", json); + const args = lastMessage?.Data?.Arguments as + | Record + | undefined; // Arguments are Dictionary - if (command === "PlayPause") { - console.log("Command ~ PlayPause"); + console.log("[WS] ~ ", lastMessage); + + if (command === "PlayPause") { + console.log("Command ~ PlayPause"); + togglePlay(); + } else if (command === "Stop") { + console.log("Command ~ Stop"); + stopPlayback(); + router.canGoBack() && router.back(); + } else if (command === "Pause") { + console.log("Command ~ Pause"); + if (isPlaying) { togglePlay(); - } else if (command === "Stop") { - console.log("Command ~ Stop"); - stopPlayback(); - router.canGoBack() && router.back(); - } else if (json?.Data?.Name === "DisplayMessage") { - console.log("Command ~ DisplayMessage"); - const title = json?.Data?.Arguments?.Header; - const body = json?.Data?.Arguments?.Text; - Alert.alert(t("player.message_from_server", { message: title }), body); } - }; + } else if (command === "Unpause") { + console.log("Command ~ Unpause"); + if (!isPlaying) { + togglePlay(); + } + } else if (command === "NextTrack") { + console.log("Command ~ NextTrack"); + nextTrack?.(); + } else if (command === "PreviousTrack") { + console.log("Command ~ PreviousTrack"); + previousTrack?.(); + } else if (command === "Rewind") { + console.log("Command ~ Rewind"); + rewindPlayback?.(); + } else if (command === "FastForward") { + console.log("Command ~ FastForward"); + fastForwardPlayback?.(); + } else if (command === "Seek") { + const positionStr = args?.SeekPositionTicks; + console.log("Command ~ Seek", { positionStr }); + if (positionStr) { + const position = Number.parseInt(positionStr, 10); + if (!Number.isNaN(position)) { + seekPlayback?.(position); + } + } + } else if (command === "Back") { + console.log("Command ~ Back"); + if (router.canGoBack()) { + router.back(); + } + } else if (command === "GoHome") { + console.log("Command ~ GoHome"); + goHome ? goHome() : router.push("/"); + } else if (command === "GoToSettings") { + console.log("Command ~ GoToSettings"); + goToSettings ? goToSettings() : router.push("/settings"); + } else if (command === "VolumeUp") { + console.log("Command ~ VolumeUp"); + volumeUp?.(); + } else if (command === "VolumeDown") { + console.log("Command ~ VolumeDown"); + volumeDown?.(); + } else if (command === "ToggleMute") { + console.log("Command ~ ToggleMute"); - return () => { - ws.onmessage = null; - }; - }, [ws, stopPlayback, togglePlay, isPlaying, router]); + toggleMute?.(); + } else if (command === "ToggleOsd") { + console.log("Command ~ ToggleOsd"); + toggleOsd?.(); + } else if (command === "ToggleFullscreen") { + console.log("Command ~ ToggleFullscreen"); + toggleFullscreen?.(); + } else if (command === "SetAudioStreamIndex") { + const indexStr = args?.Index; + console.log("Command ~ SetAudioStreamIndex", { indexStr }); + if (indexStr) { + const index = Number.parseInt(indexStr, 10); + if (!Number.isNaN(index)) { + setAudioStreamIndex?.(index); + } + } + } else if (command === "SetSubtitleStreamIndex") { + const indexStr = args?.Index; + console.log("Command ~ SetSubtitleStreamIndex", { indexStr }); + if (indexStr) { + const index = Number.parseInt(indexStr, 10); + if (!Number.isNaN(index)) { + setSubtitleStreamIndex?.(index); + } + } + } + // Neue Befehle hier implementieren + else if (command === "MoveUp") { + console.log("Command ~ MoveUp"); + moveUp?.(); + } else if (command === "MoveDown") { + console.log("Command ~ MoveDown"); + moveDown?.(); + } else if (command === "MoveLeft") { + console.log("Command ~ MoveLeft"); + moveLeft?.(); + } else if (command === "MoveRight") { + console.log("Command ~ MoveRight"); + moveRight?.(); + } else if (command === "Select") { + console.log("Command ~ Select"); + select?.(); + } else if (command === "PageUp") { + console.log("Command ~ PageUp"); + pageUp?.(); + } else if (command === "PageDown") { + console.log("Command ~ PageDown"); + pageDown?.(); + } else if (command === "SetVolume") { + const volumeStr = args?.Volume; + console.log("Command ~ SetVolume", { volumeStr }); + if (volumeStr) { + const volumeValue = Number.parseInt(volumeStr, 10); + if (!Number.isNaN(volumeValue)) { + setVolume?.(volumeValue); + } + } + } else if (command === "SetRepeatMode") { + const mode = args?.Mode; + console.log("Command ~ SetRepeatMode", { mode }); + if (mode) { + setRepeatMode?.(mode); + } + } else if (command === "SetShuffleMode") { + const mode = args?.Mode; + console.log("Command ~ SetShuffleMode", { mode }); + if (mode) { + setShuffleMode?.(mode); + } + } else if (command === "TogglePictureInPicture") { + console.log("Command ~ TogglePictureInPicture"); + togglePictureInPicture?.(); + } else if (command === "TakeScreenshot") { + console.log("Command ~ TakeScreenshot"); + takeScreenshot?.(); + } else if (command === "SendString") { + const text = args?.Text; + console.log("Command ~ SendString", { text }); + if (text) { + sendString?.(text); + } + } else if (command === "SendKey") { + const key = args?.Key; + console.log("Command ~ SendKey", { key }); + if (key) { + sendKey?.(key); + } + } else if (command === "PlayMediaSource") { + const itemIdsStr = args?.ItemIds; + const startPositionTicksStr = args?.StartPositionTicks; + console.log("Command ~ PlayMediaSource", { + itemIdsStr, + startPositionTicksStr, + }); + if (itemIdsStr) { + const itemIds = itemIdsStr.split(","); + let startPositionTicks: number | undefined = undefined; + if (startPositionTicksStr) { + const parsedTicks = Number.parseInt(startPositionTicksStr, 10); + if (!Number.isNaN(parsedTicks)) { + startPositionTicks = parsedTicks; + } + } + playMediaSource?.(itemIds, startPositionTicks); + } + } else if (command === "PlayTrailers") { + const itemId = args?.ItemId; + console.log("Command ~ PlayTrailers", { itemId }); + if (itemId) { + playTrailers?.(itemId); + } + } else if (command === "DisplayMessage") { + console.log("Command ~ DisplayMessage"); + const title = args?.Header; + const body = args?.Text; + Alert.alert(t("player.message_from_server", { message: title }), body); + } + clearLastMessage(); + }, [ + lastMessage, + offline, + isPlaying, + togglePlay, + stopPlayback, + router, + nextTrack, + previousTrack, + rewindPlayback, + fastForwardPlayback, + seekPlayback, + volumeUp, + volumeDown, + toggleMute, + toggleOsd, + toggleFullscreen, + goHome, + goToSettings, + setAudioStreamIndex, + setSubtitleStreamIndex, + moveUp, + moveDown, + moveLeft, + moveRight, + select, + pageUp, + pageDown, + setVolume, + setRepeatMode, + setShuffleMode, + togglePictureInPicture, + takeScreenshot, + sendString, + sendKey, + playMediaSource, + playTrailers, + t, + clearLastMessage, + ]); }; diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index c5857e9a..05e74f0d 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -1,5 +1,6 @@ import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; +import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import React, { createContext, @@ -12,6 +13,12 @@ import React, { } from "react"; import { AppState, type AppStateStatus } from "react-native"; +interface WebSocketMessage { + MessageType: string; + Data: any; + // Add other fields as needed +} + interface WebSocketProviderProps { children: ReactNode; } @@ -19,6 +26,9 @@ interface WebSocketProviderProps { interface WebSocketContextType { ws: WebSocket | null; isConnected: boolean; + lastMessage: WebSocketMessage | null; + sendMessage: (message: any) => void; + clearLastMessage: () => void; } const WebSocketContext = createContext(null); @@ -27,7 +37,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const api = useAtomValue(apiAtom); const [ws, setWs] = useState(null); const [isConnected, setIsConnected] = useState(false); - + const [lastMessage, setLastMessage] = useState(null); + const router = useRouter(); const deviceId = useMemo(() => { return getOrSetDeviceId(); }, []); @@ -48,6 +59,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { let keepAliveInterval: number | null = null; newWebSocket.onopen = () => { + console.log("WebSocket connection opened"); setIsConnected(true); keepAliveInterval = setInterval(() => { if (newWebSocket.readyState === WebSocket.OPEN) { @@ -56,9 +68,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, 30000); }; + let reconnectAttempts = 0; + const maxReconnectAttempts = 5; + const reconnectDelay = 10000; + newWebSocket.onerror = (e) => { console.error("WebSocket error:", e); setIsConnected(false); + + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + setTimeout(() => { + console.log(`WebSocket reconnect attempt ${reconnectAttempts}`); + connectWebSocket(); + }, reconnectDelay); + } else { + console.warn("Max WebSocket reconnect attempts reached."); + } }; newWebSocket.onclose = () => { @@ -67,7 +93,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { } setIsConnected(false); }; - + newWebSocket.onmessage = (e) => { + try { + const message = JSON.parse(e.data); + console.log("[WS] Received message:", message); + setLastMessage(message); // Store the last message in context + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }; setWs(newWebSocket); return () => { @@ -78,6 +112,41 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }; }, [api, deviceId]); + useEffect(() => { + if (!lastMessage) { + return; + } + if (lastMessage.MessageType === "Play") { + handlePlayCommand(lastMessage.Data); + } + }, [lastMessage, router]); + + const handlePlayCommand = useCallback( + (data: any) => { + if (!data || !data.ItemIds || !data.ItemIds.length) { + console.warn("[WS] Received Play command with no items"); + return; + } + + const itemId = data.ItemIds[0]; + console.log(`[WS] Handling Play command for item: ${itemId}`); + + router.push({ + pathname: "/(auth)/player/direct-player", + params: { + itemId: itemId, + playCommand: data.PlayCommand || "PlayNow", + audioIndex: data.AudioStreamIndex?.toString(), + subtitleIndex: data.SubtitleStreamIndex?.toString(), + mediaSourceId: data.MediaSourceId || "", + bitrateValue: "", + offline: "false", + }, + }); + }, + [router], + ); + useEffect(() => { const cleanup = connectWebSocket(); return cleanup; @@ -126,9 +195,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { ws?.close(); }; }, [ws, connectWebSocket]); - + const sendMessage = useCallback( + (message: any) => { + if (ws && isConnected) { + ws.send(JSON.stringify(message)); + } else { + console.warn("Cannot send message: WebSocket is not connected"); + } + }, + [ws, isConnected], + ); + const clearLastMessage = useCallback(() => { + setLastMessage(null); + }, []); return ( - + {children} );