From d6f02bd970827b0dc09164ead647aaeead8ddc7a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 6 Oct 2024 16:33:29 +0200 Subject: [PATCH] wip --- app/(auth)/play-video.tsx | 147 +++++++++++++----- app/_layout.tsx | 114 +++++++------- components/ItemContent.tsx | 24 +-- components/PlayButton.tsx | 8 +- components/video-player/Controls.tsx | 35 +++-- hooks/useWebsockets.ts | 112 +++++++++++++ providers/PlaySettingsProvider.tsx | 36 ++++- utils/jellyfin/media/getStreamUrl.ts | 11 ++ .../playstate/reportPlaybackProgress.ts | 54 ++++--- 9 files changed, 394 insertions(+), 147 deletions(-) create mode 100644 hooks/useWebsockets.ts diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx index cf7b29f2..d6bb866c 100644 --- a/app/(auth)/play-video.tsx +++ b/app/(auth)/play-video.tsx @@ -1,5 +1,6 @@ import { Controls } from "@/components/video-player/Controls"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useWebSocket } from "@/hooks/useWebsockets"; +import { apiAtom } from "@/providers/JellyfinProvider"; import { PlaybackType, usePlaySettings, @@ -7,14 +8,13 @@ import { import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import { secondsToTicks } from "@/utils/secondsToTicks"; import { Api } from "@jellyfin/sdk"; +import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import * as Haptics from "expo-haptics"; -import { useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; -import { useAtom } from "jotai"; +import { useAtomValue } from "jotai"; import React, { useCallback, useEffect, @@ -22,27 +22,23 @@ import React, { useRef, useState, } from "react"; -import { Dimensions, Pressable, StatusBar, View } from "react-native"; +import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Video, { OnProgressData, VideoRef } from "react-native-video"; +import * as NavigationBar from "expo-navigation-bar"; export default function page() { - const { playSettings, setPlaySettings, playUrl, reportStopPlayback } = - usePlaySettings(); - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); + const { playSettings, playUrl } = usePlaySettings(); + const api = useAtomValue(apiAtom); const [settings] = useSettings(); - const router = useRouter(); const videoRef = useRef(null); const poster = usePoster(playSettings, api); const videoSource = useVideoSource(playSettings, api, poster, playUrl); - const windowDimensions = Dimensions.get("window"); const screenDimensions = Dimensions.get("screen"); const [showControls, setShowControls] = useState(true); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); - const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isBuffering, setIsBuffering] = useState(true); const [orientation, setOrientation] = useState( @@ -57,59 +53,127 @@ export default function page() { return null; const togglePlay = useCallback( - (ticks: number) => { + async (ticks: number) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - console.log("togglePlay", ticks); if (isPlaying) { setIsPlaying(false); videoRef.current?.pause(); - reportPlaybackProgress({ - api, - itemId: playSettings?.item?.Id, - positionTicks: ticks, - sessionId: undefined, - IsPaused: true, + await getPlaystateApi(api).onPlaybackProgress({ + itemId: playSettings.item?.Id!, + audioStreamIndex: playSettings.audioIndex + ? playSettings.audioIndex + : undefined, + subtitleStreamIndex: playSettings.subtitleIndex + ? playSettings.subtitleIndex + : undefined, + mediaSourceId: playSettings.mediaSource?.Id!, + positionTicks: Math.round(ticks), + isPaused: true, + playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", }); } else { setIsPlaying(true); videoRef.current?.resume(); - reportPlaybackProgress({ - api, - itemId: playSettings?.item?.Id, - positionTicks: ticks, - sessionId: undefined, - IsPaused: false, + await getPlaystateApi(api).onPlaybackProgress({ + itemId: playSettings.item?.Id!, + audioStreamIndex: playSettings.audioIndex + ? playSettings.audioIndex + : undefined, + subtitleStreamIndex: playSettings.subtitleIndex + ? playSettings.subtitleIndex + : undefined, + mediaSourceId: playSettings.mediaSource?.Id!, + positionTicks: Math.round(ticks), + isPaused: false, + playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", }); } }, - [isPlaying, api, playSettings?.item?.Id, videoRef] + [isPlaying, api, playSettings?.item?.Id, videoRef, settings] ); const play = useCallback(() => { setIsPlaying(true); videoRef.current?.resume(); + reportPlaybackStart(); }, [videoRef]); + const pause = useCallback(() => { + setIsPlaying(false); + videoRef.current?.pause(); + }, [videoRef]); + + const stop = useCallback(() => { + setIsPlaying(false); + videoRef.current?.pause(); + reportPlaybackStopped(); + }, [videoRef]); + + const reportPlaybackStopped = async () => { + await getPlaystateApi(api).onPlaybackStopped({ + itemId: playSettings?.item?.Id!, + mediaSourceId: playSettings.mediaSource?.Id!, + positionTicks: progress.value, + }); + }; + + const reportPlaybackStart = async () => { + await getPlaystateApi(api).onPlaybackStart({ + itemId: playSettings?.item?.Id!, + audioStreamIndex: playSettings.audioIndex + ? playSettings.audioIndex + : undefined, + subtitleStreamIndex: playSettings.subtitleIndex + ? playSettings.subtitleIndex + : undefined, + mediaSourceId: playSettings.mediaSource?.Id!, + playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", + }); + }; + + const firstTime = useRef(true); useEffect(() => { play(); + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("hidden"); + NavigationBar.setBehaviorAsync("overlay-swipe"); + } + + return () => { + stop(); + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("visible"); + NavigationBar.setBehaviorAsync("inset-swipe"); + } + }; }, []); const onProgress = useCallback( - (data: OnProgressData) => { + async (data: OnProgressData) => { if (isSeeking.value === true) return; + const ticks = data.currentTime * 10000000; + progress.value = secondsToTicks(data.currentTime); cacheProgress.value = secondsToTicks(data.playableDuration); setIsBuffering(data.playableDuration === 0); if (!playSettings?.item?.Id || data.currentTime === 0) return; - const ticks = data.currentTime * 10000000; - reportPlaybackProgress({ - api, - itemId: playSettings?.item.Id, - positionTicks: ticks, - sessionId: undefined, - IsPaused: !isPlaying, + + await getPlaystateApi(api).onPlaybackProgress({ + itemId: playSettings.item.Id, + audioStreamIndex: playSettings.audioIndex + ? playSettings.audioIndex + : undefined, + subtitleStreamIndex: playSettings.subtitleIndex + ? playSettings.subtitleIndex + : undefined, + mediaSourceId: playSettings.mediaSource?.Id!, + positionTicks: Math.round(ticks), + isPaused: !isPlaying, + playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", }); }, [playSettings?.item.Id, isPlaying, api] @@ -139,6 +203,13 @@ export default function page() { : false; }, [orientation]); + const { isConnected } = useWebSocket({ + isPlaying: isPlaying, + pauseVideo: pause, + playVideo: play, + stopPlayback: stop, + }); + return ( {}} + onLoad={() => { + if (firstTime.current === true) { + play(); + firstTime.current = false; + } + }} playWhenInactive={true} allowsExternalPlayback={true} playInBackground={true} diff --git a/app/_layout.tsx b/app/_layout.tsx index 206aa7ab..30c525bb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -319,66 +319,64 @@ function Layout() { - - - - - - - - - - - - + + + - - + + + + + + + + diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index fcdf5c23..51920364 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -21,7 +21,14 @@ import { Image } from "expo-image"; import { useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; import Animated from "react-native-reanimated"; @@ -34,17 +41,10 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( ({ item }) => { const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const { playSettings, setPlaySettings, playUrl } = usePlaySettings(); + const { setPlaySettings, playUrl } = usePlaySettings(); const castDevice = useCastDevice(); const navigation = useNavigation(); - const [settings] = useSettings(); - - const [maxBitrate, setMaxBitrate] = useState({ - key: "Max", - value: undefined, - }); const [loadingLogo, setLoadingLogo] = useState(true); @@ -89,7 +89,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( }); setPlaySettings((prev) => ({ - ...prev, + audioIndex: undefined, + subtitleIndex: undefined, + mediaSourceId: undefined, + bitrate: undefined, + mediaSource: undefined, item, })); }, [item]); diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index c0a8e1ca..a91e8688 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,5 +1,4 @@ import { apiAtom } from "@/providers/JellyfinProvider"; -import { usePlayback } from "@/providers/PlaybackProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -40,7 +39,6 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); - const { setCurrentlyPlayingState } = usePlayback(); const mediaStatus = useMediaStatus(); const [colorAtom] = useAtom(itemThemeColorAtom); @@ -64,7 +62,11 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { const onPress = async () => { if (!url || !item) { - console.warn("No URL or item provided to PlayButton"); + console.warn( + "No URL or item provided to PlayButton", + url?.slice(0, 100), + item?.Id + ); return; } if (!client) { diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx index 91a5f160..6310f2f2 100644 --- a/components/video-player/Controls.tsx +++ b/components/video-player/Controls.tsx @@ -19,7 +19,13 @@ import React, { useRef, useState, } from "react"; -import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; +import { + Dimensions, + Platform, + Pressable, + TouchableOpacity, + View, +} from "react-native"; import { Slider } from "react-native-awesome-slider"; import Animated, { runOnJS, @@ -34,6 +40,7 @@ import { VideoRef } from "react-native-video"; import { Text } from "../common/Text"; import { itemRouter } from "../common/TouchableItemRouter"; import { Loader } from "../Loader"; +import { TAB_HEIGHT } from "@/constants/Values"; interface Props { item: BaseItemDto; @@ -67,12 +74,12 @@ export const Controls: React.FC = ({ setIgnoreSafeAreas, }) => { const [settings] = useSettings(); - const [api] = useAtom(apiAtom); const router = useRouter(); const segments = useSegments(); const insets = useSafeAreaInsets(); const screenDimensions = Dimensions.get("screen"); + const windowDimensions = Dimensions.get("window"); const op = useSharedValue(1); const tr = useSharedValue(10); @@ -243,8 +250,8 @@ export const Controls: React.FC = ({ position: "absolute", top: 0, left: 0, - width: screenDimensions.width, - height: screenDimensions.height, + width: windowDimensions.width, + height: windowDimensions.height, }, ]} > @@ -299,8 +306,8 @@ export const Controls: React.FC = ({ position: "absolute", top: 0, left: 0, - width: screenDimensions.width, - height: screenDimensions.height, + width: windowDimensions.width + 100, + height: windowDimensions.height + 100, }, animatedStyles, ]} @@ -313,8 +320,8 @@ export const Controls: React.FC = ({ position: "absolute", top: 0, left: 0, - width: screenDimensions.width, - height: screenDimensions.height, + width: windowDimensions.width, + height: windowDimensions.height, }} pointerEvents="none" className={`flex flex-col items-center justify-center @@ -338,7 +345,7 @@ export const Controls: React.FC = ({ > = ({ onPress={() => { router.back(); }} - className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" + className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" > @@ -360,10 +367,10 @@ export const Controls: React.FC = ({ style={[ { position: "absolute", - width: screenDimensions.width - insets.left - insets.right, - maxHeight: screenDimensions.height, + width: windowDimensions.width - insets.left - insets.right, + maxHeight: windowDimensions.height, left: insets.left, - bottom: insets.bottom, + bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom, }, animatedBottomStyles, ]} @@ -388,7 +395,7 @@ export const Controls: React.FC = ({ ? "flex-row space-x-6 py-2 px-4 rounded-full" : "flex-col-reverse py-4 px-4 rounded-2xl" } - items-center bg-neutral-800`} + items-center bg-neutral-800/90`} > void; + playVideo: () => void; + stopPlayback: () => void; +} + +export const useWebSocket = ({ + isPlaying, + pauseVideo, + playVideo, + stopPlayback, +}: 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, + }); + + useEffect(() => { + 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 = (e) => { + if (keepAliveInterval) { + clearInterval(keepAliveInterval); + } + }; + + setWs(newWebSocket); + + return () => { + if (keepAliveInterval) { + clearInterval(keepAliveInterval); + } + newWebSocket.close(); + }; + }, [api, deviceId, user]); + + useEffect(() => { + if (!ws) return; + + ws.onmessage = (e) => { + const json = JSON.parse(e.data); + const command = json?.Data?.Command; + + console.log("[WS] ~ ", json); + + if (command === "PlayPause") { + console.log("Command ~ PlayPause"); + if (isPlaying) pauseVideo(); + else playVideo(); + } 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(title, body); + } + }; + }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]); + + return { isConnected }; +}; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 05769508..2c966762 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -18,6 +18,8 @@ import old from "@/utils/profiles/old"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { Bitrate } from "@/components/BitrateSelector"; +import ios from "@/utils/profiles/ios"; +import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; export type PlaybackType = { item?: BaseItemDto | null; @@ -30,7 +32,7 @@ export type PlaybackType = { type PlaySettingsContextType = { playSettings: PlaybackType | null; setPlaySettings: React.Dispatch>; - playUrl: string | null; + playUrl?: string | null; reportStopPlayback: (ticks: number) => Promise; }; @@ -65,14 +67,16 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { const fetchPlayUrl = async () => { - console.log("something changed, fetching url", playSettings?.item?.Id); - if (!api || !user || !settings) { + if (!api || !user || !settings || !playSettings) { + console.log("fetchPlayUrl ~ missing params"); setPlayUrl(null); return; } + console.log("fetchPlayUrl ~ fetching url", playSettings?.item?.Id); + // Determine the device profile - let deviceProfile: any = iosFmp4; + let deviceProfile: any = ios; if (settings?.deviceProfile === "Native") deviceProfile = native; if (settings?.deviceProfile === "Old") deviceProfile = old; @@ -100,6 +104,30 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ fetchPlayUrl(); }, [api, settings, user, playSettings]); + useEffect(() => { + let deviceProfile: any = ios; + if (settings?.deviceProfile === "Native") deviceProfile = native; + if (settings?.deviceProfile === "Old") deviceProfile = old; + + const postCaps = async () => { + if (!api) return; + await getSessionApi(api).postFullCapabilities({ + clientCapabilitiesDto: { + AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", + DeviceProfile: deviceProfile, + IconUrl: + "https://github.com/fredrikburmester/streamyfin/blob/master/assets/images/adaptive_icon.png", + PlayableMediaTypes: ["Audio", "Video"], + SupportedCommands: ["Play"], + SupportsMediaControl: true, + SupportsPersistentIdentifier: true, + }, + }); + }; + + postCaps(); + }, [settings]); + return ( { if (!api || !userId || !item?.Id) { + console.log("getStreamUrl: missing params", { + api: api?.basePath, + userId, + item: item?.Id, + }); return null; } @@ -122,6 +127,12 @@ export const getStreamUrl = async ({ } if (!url) { + console.log("getStreamUrl: no url found", { + api: api.basePath, + userId, + item: item.Id, + mediaSourceId, + }); return null; } diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts index d015482a..1342690a 100644 --- a/utils/jellyfin/playstate/reportPlaybackProgress.ts +++ b/utils/jellyfin/playstate/reportPlaybackProgress.ts @@ -2,6 +2,16 @@ import { Api } from "@jellyfin/sdk"; import { getAuthHeaders } from "../jellyfin"; import { postCapabilities } from "../session/capabilities"; import { Settings } from "@/utils/atoms/settings"; +import { + getMediaInfoApi, + getPlaystateApi, + getSessionApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client"; +import { getOrSetDeviceId } from "@/providers/JellyfinProvider"; +import ios from "@/utils/profiles/ios"; +import native from "@/utils/profiles/native"; +import old from "@/utils/profiles/old"; interface ReportPlaybackProgressParams { api?: Api | null; @@ -33,31 +43,29 @@ export const reportPlaybackProgress = async ({ console.info("reportPlaybackProgress ~ IsPaused", IsPaused); try { - await postCapabilities({ - api, + await getPlaystateApi(api).onPlaybackProgress({ itemId, - sessionId, - deviceProfile, + audioStreamIndex: 0, + subtitleStreamIndex: 0, + mediaSourceId: itemId, + positionTicks: Math.round(positionTicks), + isPaused: IsPaused, + isMuted: false, + playMethod: "Transcode", }); - } catch (error) { - console.error("Failed to post capabilities.", error); - throw new Error("Failed to post capabilities."); - } - - try { - await api.axiosInstance.post( - `${api.basePath}/Sessions/Playing/Progress`, - { - ItemId: itemId, - PlaySessionId: sessionId, - IsPaused, - PositionTicks: Math.round(positionTicks), - CanSeek: true, - MediaSourceId: itemId, - EventName: "timeupdate", - }, - { headers: getAuthHeaders(api) } - ); + // await api.axiosInstance.post( + // `${api.basePath}/Sessions/Playing/Progress`, + // { + // ItemId: itemId, + // PlaySessionId: sessionId, + // IsPaused, + // PositionTicks: Math.round(positionTicks), + // CanSeek: true, + // MediaSourceId: itemId, + // EventName: "timeupdate", + // }, + // { headers: getAuthHeaders(api) } + // ); } catch (error) { console.error(error); }