From bd24f591997794811002ed37463dddc8617a16dd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 8 Dec 2024 13:59:16 +0100 Subject: [PATCH] fix: websockets now work globally with vlc and transcoded player does not disconnect and reconnect every time you open and close the player --- app/(auth)/player/direct-player.tsx | 112 +++++++------- app/(auth)/player/transcoding-player.tsx | 87 +++++------ app/_layout.tsx | 91 +++++------ components/video-player/controls/Controls.tsx | 4 +- hooks/useWebsockets.ts | 93 ++--------- providers/PlaySettingsProvider.tsx | 40 +++-- providers/WebSocketProvider.tsx | 144 ++++++++++++++++++ 7 files changed, 317 insertions(+), 254 deletions(-) create mode 100644 providers/WebSocketProvider.tsx diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d534809c..370150a7 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -180,69 +180,58 @@ export default function page() { staleTime: 0, }); - const togglePlay = useCallback( - async (ms: number) => { - if (!api) return; + const togglePlay = useCallback(async () => { + if (!api) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - if (isPlaying) { - await videoRef.current?.pause(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (isPlaying) { + await videoRef.current?.pause(); - if (!offline && stream) { - await getPlaystateApi(api).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: msToTicks(ms), - isPaused: true, - playMethod: stream.url?.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream.sessionId, - }); - } - - console.log("Actually marked as paused"); - } else { - videoRef.current?.play(); - if (!offline && stream) { - await getPlaystateApi(api).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: msToTicks(ms), - isPaused: false, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream.sessionId, - }); - } + if (!offline && stream) { + await getPlaystateApi(api).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + positionTicks: msToTicks(progress.value), + isPaused: true, + playMethod: stream.url?.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream.sessionId, + }); } - }, - [ - isPlaying, - api, - item, - stream, - videoRef, - audioIndex, - subtitleIndex, - mediaSourceId, - offline, - ] - ); - const play = useCallback(() => { - videoRef.current?.play(); - reportPlaybackStart(); - }, [videoRef]); - - const pause = useCallback(() => { - videoRef.current?.pause(); - }, [videoRef]); + console.log("Actually marked as paused"); + } else { + videoRef.current?.play(); + if (!offline && stream) { + await getPlaystateApi(api).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + positionTicks: msToTicks(progress.value), + isPaused: false, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream.sessionId, + }); + } + } + }, [ + isPlaying, + api, + item, + stream, + videoRef, + audioIndex, + subtitleIndex, + mediaSourceId, + offline, + progress.value, + ]); const reportPlaybackStopped = useCallback(async () => { if (offline) return; @@ -298,6 +287,8 @@ export default function page() { if (!item?.Id || !stream) return; + console.log("onProgress ~", currentTimeInTicks, isPlaying); + await getPlaystateApi(api!).onPlaybackProgress({ itemId: item.Id, audioStreamIndex: audioIndex ? audioIndex : undefined, @@ -317,8 +308,7 @@ export default function page() { useWebSocket({ isPlaying: isPlaying, - pauseVideo: pause, - playVideo: play, + togglePlay: togglePlay, stopPlayback: stop, offline, }); diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx index 157d3077..f9441a13 100644 --- a/app/(auth)/player/transcoding-player.tsx +++ b/app/(auth)/player/transcoding-player.tsx @@ -169,51 +169,44 @@ const Player = () => { const poster = usePoster(item, api); const videoSource = useVideoSource(item, api, poster, stream?.url); - const togglePlay = useCallback( - async (ticks: number) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - if (isPlaying) { - videoRef.current?.pause(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), - isPaused: true, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream?.sessionId, - }); - } else { - videoRef.current?.resume(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), - isPaused: false, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream?.sessionId, - }); - } - }, - [ - isPlaying, - api, - item, - videoRef, - settings, - stream, - audioIndex, - subtitleIndex, - mediaSourceId, - ] - ); + const togglePlay = useCallback(async () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (isPlaying) { + videoRef.current?.pause(); + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + positionTicks: Math.floor(progress.value), + isPaused: true, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, + }); + } else { + videoRef.current?.resume(); + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + positionTicks: Math.floor(progress.value), + isPaused: false, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, + }); + } + }, [ + isPlaying, + api, + item, + videoRef, + settings, + stream, + audioIndex, + subtitleIndex, + mediaSourceId, + ]); const play = useCallback(() => { videoRef.current?.resume(); @@ -307,9 +300,9 @@ const Player = () => { useWebSocket({ isPlaying: isPlaying, - pauseVideo: pause, - playVideo: play, + togglePlay: togglePlay, stopPlayback: stop, + offline: false, }); const [selectedTextTrack, setSelectedTextTrack] = useState< diff --git a/app/_layout.tsx b/app/_layout.tsx index a9c71662..a7054434 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,10 +6,11 @@ import { } from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; +import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Settings, useSettings } from "@/utils/atoms/settings"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; -import {LogProvider, writeToLog} from "@/utils/log"; +import { LogProvider, writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; @@ -304,57 +305,59 @@ function Layout() { } return ( - + - - - - + + + + diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index c74c9c09..d57e4931 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -66,7 +66,7 @@ interface Props { ignoreSafeAreas?: boolean; setIgnoreSafeAreas: React.Dispatch>; enableTrickplay?: boolean; - togglePlay: (ticks: number) => void; + togglePlay: () => void; setShowControls: (shown: boolean) => void; offline?: boolean; isVideoLoaded?: boolean; @@ -538,7 +538,7 @@ export const Controls: React.FC = ({ { - togglePlay(progress.value); + togglePlay(); }} > {!isBuffering ? ( diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 473106d1..75199b31 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -1,91 +1,27 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Alert } from "react-native"; -import { Router, useRouter } from "expo-router"; -import { Api } from "@jellyfin/sdk"; -import { useAtomValue } from "jotai"; -import { - apiAtom, - getOrSetDeviceId, - userAtom, -} from "@/providers/JellyfinProvider"; -import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useWebSocketContext } from "@/providers/WebSocketProvider"; interface UseWebSocketProps { isPlaying: boolean; - pauseVideo: () => void; - playVideo: () => void; + togglePlay: () => void; stopPlayback: () => void; - offline?: boolean; + offline: boolean; } export const useWebSocket = ({ isPlaying, - pauseVideo, - playVideo, + togglePlay, stopPlayback, - offline = false, + offline, }: UseWebSocketProps) => { const router = useRouter(); - const user = useAtomValue(userAtom); - const api = useAtomValue(apiAtom); - const [ws, setWs] = useState(null); - const [isConnected, setIsConnected] = useState(false); - - const { data: deviceId } = useQuery({ - queryKey: ["deviceId"], - queryFn: async () => { - return await getOrSetDeviceId(); - }, - staleTime: Infinity, - }); + const { ws } = useWebSocketContext(); useEffect(() => { - if (offline || !deviceId || !api?.accessToken) return; - - const protocol = api?.basePath.includes("https") ? "wss" : "ws"; - - const url = `${protocol}://${api?.basePath - .replace("https://", "") - .replace("http://", "")}/socket?api_key=${ - api?.accessToken - }&deviceId=${deviceId}`; - - const newWebSocket = new WebSocket(url); - - let keepAliveInterval: NodeJS.Timeout | null = null; - - newWebSocket.onopen = () => { - setIsConnected(true); - keepAliveInterval = setInterval(() => { - if (newWebSocket.readyState === WebSocket.OPEN) { - newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); - } - }, 30000); - }; - - newWebSocket.onerror = (e) => { - console.error("WebSocket error:", e); - setIsConnected(false); - }; - - newWebSocket.onclose = (e) => { - if (keepAliveInterval) { - clearInterval(keepAliveInterval); - } - }; - - setWs(newWebSocket); - - return () => { - if (keepAliveInterval) { - clearInterval(keepAliveInterval); - } - newWebSocket.close(); - }; - }, [api, deviceId, user, offline]); - - useEffect(() => { - if (offline || !ws) return; + if (!ws) return; + if (offline) return; ws.onmessage = (e) => { const json = JSON.parse(e.data); @@ -95,8 +31,7 @@ export const useWebSocket = ({ if (command === "PlayPause") { console.log("Command ~ PlayPause"); - if (isPlaying) pauseVideo(); - else playVideo(); + togglePlay(); } else if (command === "Stop") { console.log("Command ~ Stop"); stopPlayback(); @@ -108,7 +43,9 @@ export const useWebSocket = ({ Alert.alert("Message from server: " + title, body); } }; - }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]); - return { isConnected }; + return () => { + ws.onmessage = null; + }; + }, [ws, stopPlayback, togglePlay, isPlaying, router]); }; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index ef5284d1..50f780cd 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -1,13 +1,10 @@ import { Bitrate } from "@/components/BitrateSelector"; import { settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import ios from "@/utils/profiles/ios"; import native from "@/utils/profiles/native"; -import old from "@/utils/profiles/old"; import { BaseItemDto, MediaSourceInfo, - PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtomValue } from "jotai"; @@ -19,7 +16,6 @@ import React, { useState, } from "react"; import { apiAtom, userAtom } from "./JellyfinProvider"; -import iosFmp4 from "@/utils/profiles/iosFmp4"; export type PlaybackType = { item?: BaseItemDto | null; @@ -124,25 +120,25 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ [api, user, settings, playSettings] ); - useEffect(() => { - const postCaps = async () => { - if (!api) return; - await getSessionApi(api).postFullCapabilities({ - clientCapabilitiesDto: { - AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", - DeviceProfile: native as any, - IconUrl: - "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png", - PlayableMediaTypes: ["Audio", "Video"], - SupportedCommands: ["Play"], - SupportsMediaControl: true, - SupportsPersistentIdentifier: true, - }, - }); - }; + // useEffect(() => { + // const postCaps = async () => { + // if (!api) return; + // await getSessionApi(api).postFullCapabilities({ + // clientCapabilitiesDto: { + // AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", + // DeviceProfile: native as any, + // IconUrl: + // "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png", + // PlayableMediaTypes: ["Audio", "Video"], + // SupportedCommands: ["Play"], + // SupportsMediaControl: true, + // SupportsPersistentIdentifier: true, + // }, + // }); + // }; - postCaps(); - }, [settings, api]); + // postCaps(); + // }, [settings, api]); return ( (null); + +export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { + const user = useAtomValue(userAtom); + const api = useAtomValue(apiAtom); + const [ws, setWs] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + const deviceId = useMemo(() => { + return getOrSetDeviceId(); + }, []); + + const connectWebSocket = useCallback(() => { + if (!deviceId || !api?.accessToken) return; + + const protocol = api.basePath.includes("https") ? "wss" : "ws"; + const url = `${protocol}://${api.basePath + .replace("https://", "") + .replace("http://", "")}/socket?api_key=${ + api.accessToken + }&deviceId=${deviceId}`; + + const newWebSocket = new WebSocket(url); + let keepAliveInterval: NodeJS.Timeout | null = null; + + newWebSocket.onopen = () => { + setIsConnected(true); + keepAliveInterval = setInterval(() => { + if (newWebSocket.readyState === WebSocket.OPEN) { + newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); + } + }, 30000); + }; + + newWebSocket.onerror = (e) => { + console.error("WebSocket error:", e); + setIsConnected(false); + }; + + newWebSocket.onclose = () => { + if (keepAliveInterval) clearInterval(keepAliveInterval); + setIsConnected(false); + }; + + setWs(newWebSocket); + + return () => { + if (keepAliveInterval) clearInterval(keepAliveInterval); + newWebSocket.close(); + }; + }, [api, deviceId]); + + useEffect(() => { + const cleanup = connectWebSocket(); + return cleanup; + }, [connectWebSocket]); + + useEffect(() => { + if (!deviceId || !api || !api?.accessToken) return; + + const init = async () => { + await getSessionApi(api).postFullCapabilities({ + clientCapabilitiesDto: { + AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", + IconUrl: + "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png", + PlayableMediaTypes: ["Audio", "Video"], + SupportedCommands: ["Play"], + SupportsMediaControl: true, + SupportsPersistentIdentifier: true, + }, + }); + }; + + init(); + }, [api, deviceId]); + + useEffect(() => { + const handleAppStateChange = (state: AppStateStatus) => { + if (state === "background" || state === "inactive") { + console.log("App moving to background, closing WebSocket..."); + ws?.close(); + } else if (state === "active") { + console.log("App coming to foreground, reconnecting WebSocket..."); + connectWebSocket(); + } + }; + + const subscription = AppState.addEventListener( + "change", + handleAppStateChange + ); + + return () => { + subscription.remove(); + ws?.close(); + }; + }, [ws, connectWebSocket]); + + return ( + + {children} + + ); +}; + +export const useWebSocketContext = (): WebSocketContextType => { + const context = useContext(WebSocketContext); + if (!context) { + throw new Error( + "useWebSocketContext must be used within a WebSocketProvider" + ); + } + return context; +};