diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx index 348e1451..c6b7d2c5 100644 --- a/app/(auth)/play-video.tsx +++ b/app/(auth)/play-video.tsx @@ -26,9 +26,10 @@ 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"; +import { useFocusEffect } from "expo-router"; export default function page() { - const { playSettings, playUrl } = usePlaySettings(); + const { playSettings, playUrl, playSessionId } = usePlaySettings(); const api = useAtomValue(apiAtom); const [settings] = useSettings(); const videoRef = useRef(null); @@ -38,6 +39,7 @@ export default function page() { const screenDimensions = Dimensions.get("screen"); + const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, setShowControls] = useState(true); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [isPlaying, setIsPlaying] = useState(false); @@ -68,9 +70,10 @@ export default function page() { ? playSettings.subtitleIndex : undefined, mediaSourceId: playSettings.mediaSource?.Id!, - positionTicks: Math.round(ticks), + positionTicks: Math.floor(ticks), isPaused: true, playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: playSessionId ? playSessionId : undefined, }); } else { setIsPlaying(true); @@ -84,9 +87,10 @@ export default function page() { ? playSettings.subtitleIndex : undefined, mediaSourceId: playSettings.mediaSource?.Id!, - positionTicks: Math.round(ticks), + positionTicks: Math.floor(ticks), isPaused: false, playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: playSessionId ? playSessionId : undefined, }); } }, @@ -105,6 +109,7 @@ export default function page() { }, [videoRef]); const stop = useCallback(() => { + setIsPlaybackStopped(true); setIsPlaying(false); videoRef.current?.pause(); reportPlaybackStopped(); @@ -114,7 +119,8 @@ export default function page() { await getPlaystateApi(api).onPlaybackStopped({ itemId: playSettings?.item?.Id!, mediaSourceId: playSettings.mediaSource?.Id!, - positionTicks: progress.value, + positionTicks: Math.floor(progress.value), + playSessionId: playSessionId ? playSessionId : undefined, }); }; @@ -129,12 +135,14 @@ export default function page() { : undefined, mediaSourceId: playSettings.mediaSource?.Id!, playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: playSessionId ? playSessionId : undefined, }); }; const onProgress = useCallback( async (data: OnProgressData) => { if (isSeeking.value === true) return; + if (isPlaybackStopped) return; const ticks = data.currentTime * 10000000; @@ -156,17 +164,21 @@ export default function page() { positionTicks: Math.round(ticks), isPaused: !isPlaying, playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: playSessionId ? playSessionId : undefined, }); }, - [playSettings?.item.Id, isPlaying, api] + [playSettings?.item.Id, isPlaying, api, isPlaybackStopped] ); - useEffect(() => { - play(); - return () => { - stop(); - }; - }, []); + useFocusEffect( + useCallback(() => { + play(); + + return () => { + stop(); + }; + }, [play, stop]) + ); useEffect(() => { const orientationSubscription = @@ -250,6 +262,7 @@ export default function page() { firstTime.current = false; } }} + progressUpdateInterval={500} playWhenInactive={true} allowsExternalPlayback={true} playInBackground={true} diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx index 8784d3a5..8ad125af 100644 --- a/components/music/SongsListItem.tsx +++ b/components/music/SongsListItem.tsx @@ -41,7 +41,7 @@ export const SongsListItem: React.FC = ({ const client = useRemoteMediaClient(); const { showActionSheetWithOptions } = useActionSheet(); - const { playSettings, setPlaySettings } = usePlaySettings(); + const { setPlaySettings } = usePlaySettings(); const openSelect = () => { if (!castDevice?.deviceId) { @@ -85,7 +85,7 @@ export const SongsListItem: React.FC = ({ const sessionData = response.data; - const url = await getStreamUrl({ + const data = await getStreamUrl({ api, userId: user.Id, item, @@ -95,8 +95,8 @@ export const SongsListItem: React.FC = ({ mediaSourceId: item.Id, }); - if (!url || !item) { - console.warn("No url or item", url, item.Id); + if (!data?.url || !item) { + console.warn("No url or item", data?.url, item.Id); return; } @@ -107,7 +107,7 @@ export const SongsListItem: React.FC = ({ else { client.loadMedia({ mediaInfo: { - contentUrl: url, + contentUrl: data.url!, contentType: "video/mp4", metadata: { type: item.Type === "Episode" ? "tvShow" : "movie", @@ -120,7 +120,7 @@ export const SongsListItem: React.FC = ({ } }); } else { - console.log("Playing on device", url, item.Id); + console.log("Playing on device", data.url, item.Id); setPlaySettings({ item, }); diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index 56cfe6f3..670784e2 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -48,6 +48,7 @@ export const useCreditSkipper = ( return res?.data; }, enabled: !!itemId, + retry: false, }); useEffect(() => { diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 67c599e4..4c60e8c2 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -8,7 +8,7 @@ import { useCallback } from "react"; export const useFileOpener = () => { const router = useRouter(); - const { setPlaySettings, setPlayUrl, setOfflineSettings } = usePlaySettings(); + const { setPlayUrl, setOfflineSettings } = usePlaySettings(); const openFile = useCallback(async (item: BaseItemDto) => { const directory = FileSystem.documentDirectory; diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index 3def1120..68e7a760 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -44,6 +44,7 @@ export const useIntroSkipper = ( return res?.data; }, enabled: !!itemId, + retry: false, }); useEffect(() => { diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index d25714fc..92c647bb 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -103,7 +103,7 @@ export const useWebSocket = ({ console.log("Command ~ DisplayMessage"); const title = json?.Data?.Arguments?.Header; const body = json?.Data?.Arguments?.Text; - Alert.alert(title, body); + Alert.alert("Message from server: " + title, body); } }; }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]); diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 9fd0c774..2654c52b 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -1,7 +1,6 @@ import { Bitrate } from "@/components/BitrateSelector"; import { settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import ios from "@/utils/profiles/ios"; import native from "@/utils/profiles/native"; import old from "@/utils/profiles/old"; @@ -9,7 +8,7 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; +import { getPlaystateApi, getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtomValue } from "jotai"; import React, { createContext, @@ -18,7 +17,7 @@ import React, { useEffect, useState, } from "react"; -import { apiAtom, userAtom } from "./JellyfinProvider"; +import { apiAtom, getOrSetDeviceId, userAtom } from "./JellyfinProvider"; export type PlaybackType = { item?: BaseItemDto | null; @@ -31,10 +30,10 @@ export type PlaybackType = { type PlaySettingsContextType = { playSettings: PlaybackType | null; setPlaySettings: React.Dispatch>; - setOfflineSettings: (data: PlaybackType) => void; playUrl?: string | null; - reportStopPlayback: (ticks: number) => Promise; setPlayUrl: React.Dispatch>; + playSessionId?: string | null; + setOfflineSettings: (data: PlaybackType) => void; }; const PlaySettingsContext = createContext( @@ -46,26 +45,12 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ }) => { const [playSettings, _setPlaySettings] = useState(null); const [playUrl, setPlayUrl] = useState(null); + const [playSessionId, setPlaySessionId] = useState(null); const api = useAtomValue(apiAtom); const settings = useAtomValue(settingsAtom); const user = useAtomValue(userAtom); - const reportStopPlayback = useCallback( - async (ticks: number) => { - const id = playSettings?.item?.Id; - setPlaySettings(null); - - await reportPlaybackStopped({ - api, - itemId: id, - sessionId: undefined, - positionTicks: ticks, - }); - }, - [playSettings?.item?.Id, api] - ); - const setOfflineSettings = useCallback((data: PlaybackType) => { _setPlaySettings(data); }, []); @@ -107,8 +92,9 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ userId: user.Id, forceDirectPlay: false, sessionData: null, - }).then((url) => { - if (url) setPlayUrl(url); + }).then((data) => { + setPlayUrl(data?.url!); + setPlaySessionId(data?.sessionId!); }); return newSettings; @@ -129,7 +115,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679", DeviceProfile: deviceProfile, IconUrl: - "https://github.com/fredrikburmester/streamyfin/blob/master/assets/images/adaptive_icon.png", + "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/redesign/public/assets/images/icon_new_withoutBackground.png", PlayableMediaTypes: ["Audio", "Video"], SupportedCommands: ["Play"], SupportsMediaControl: true, @@ -139,7 +125,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ }; postCaps(); - }, [settings]); + }, [settings, api]); return ( = ({ playSettings, setPlaySettings, playUrl, - reportStopPlayback, setPlayUrl, setOfflineSettings, + playSessionId, }} > {children} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 2b7d0cf6..d9c4eadb 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -33,18 +33,17 @@ export const getStreamUrl = async ({ forceDirectPlay?: boolean; height?: number; mediaSourceId?: string | null; -}) => { +}): Promise<{ + url: string | null | undefined; + sessionId: string | null | undefined; +} | null> => { if (!api || !userId || !item?.Id) { - console.log("getStreamUrl: missing params", { - api: api?.basePath, - userId, - item: item?.Id, - }); return null; } let mediaSource: MediaSourceInfo | undefined; let url: string | null | undefined; + let sessionId: string | null | undefined; if (item.Type === "Program") { const res0 = await getMediaInfoApi(api).getPlaybackInfo( @@ -67,35 +66,67 @@ export const getStreamUrl = async ({ } ); const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl; - if (transcodeUrl) return `${api.basePath}${transcodeUrl}`; + sessionId = res0.data.PlaySessionId; + + if (transcodeUrl) { + return { url: `${api.basePath}${transcodeUrl}`, sessionId }; + } } const itemId = item.Id; - const res2 = await api.axiosInstance.post( - `${api.basePath}/Items/${itemId}/PlaybackInfo`, + // const res2 = await api.axiosInstance.post( + // `${api.basePath}/Items/${itemId}/PlaybackInfo`, + // { + // DeviceProfile: deviceProfile, + // UserId: userId, + // MaxStreamingBitrate: maxStreamingBitrate, + // StartTimeTicks: startTimeTicks, + // EnableTranscoding: maxStreamingBitrate ? true : undefined, + // AutoOpenLiveStream: true, + // MediaSourceId: mediaSourceId, + // AllowVideoStreamCopy: maxStreamingBitrate ? false : true, + // AudioStreamIndex: audioStreamIndex, + // SubtitleStreamIndex: subtitleStreamIndex, + // DeInterlace: true, + // BreakOnNonKeyFrames: false, + // CopyTimestamps: false, + // EnableMpegtsM2TsMode: false, + // }, + // { + // headers: getAuthHeaders(api), + // } + // ); + + const res2 = await getMediaInfoApi(api).getPlaybackInfo( { - DeviceProfile: deviceProfile, - UserId: userId, - MaxStreamingBitrate: maxStreamingBitrate, - StartTimeTicks: startTimeTicks, - EnableTranscoding: maxStreamingBitrate ? true : undefined, - AutoOpenLiveStream: true, - MediaSourceId: mediaSourceId, - AllowVideoStreamCopy: maxStreamingBitrate ? false : true, - AudioStreamIndex: audioStreamIndex, - SubtitleStreamIndex: subtitleStreamIndex, - DeInterlace: true, - BreakOnNonKeyFrames: false, - CopyTimestamps: false, - EnableMpegtsM2TsMode: false, + userId, + itemId: item.Id!, }, { - headers: getAuthHeaders(api), + method: "POST", + data: { + deviceProfile, + userId, + maxStreamingBitrate, + startTimeTicks, + enableTranscoding: maxStreamingBitrate ? true : undefined, + autoOpenLiveStream: true, + mediaSourceId, + allowVideoStreamCopy: maxStreamingBitrate ? false : true, + audioStreamIndex, + subtitleStreamIndex, + deInterlace: true, + breakOnNonKeyFrames: false, + copyTimestamps: false, + enableMpegtsM2TsMode: false, + }, } ); - mediaSource = res2.data.MediaSources.find( + sessionId = res2.data.PlaySessionId; + + mediaSource = res2.data.MediaSources?.find( (source: MediaSourceInfo) => source.Id === mediaSourceId ); @@ -136,5 +167,8 @@ export const getStreamUrl = async ({ return null; } - return url; + return { + url, + sessionId, + }; }; diff --git a/utils/jellyfin/playstate/reportPlaybackStopped.ts b/utils/jellyfin/playstate/reportPlaybackStopped.ts deleted file mode 100644 index 665eb032..00000000 --- a/utils/jellyfin/playstate/reportPlaybackStopped.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Api } from "@jellyfin/sdk"; -import { AxiosError } from "axios"; -import { getAuthHeaders } from "../jellyfin"; - -interface PlaybackStoppedParams { - api: Api | null | undefined; - sessionId: string | null | undefined; - itemId: string | null | undefined; - positionTicks: number | null | undefined; -} - -/** - * Reports playback stopped event to the Jellyfin server. - * - * @param {PlaybackStoppedParams} params - The parameters for the report. - * @param {Api} params.api - The Jellyfin API instance. - * @param {string} params.sessionId - The session ID. - * @param {string} params.itemId - The item ID. - * @param {number} params.positionTicks - The playback position in ticks. - */ -export const reportPlaybackStopped = async ({ - api, - sessionId, - itemId, - positionTicks, -}: PlaybackStoppedParams): Promise => { - if (!positionTicks || positionTicks === 0) return; - - if (!api) { - console.error("Missing api"); - return; - } - - if (!sessionId) { - console.error("Missing sessionId", sessionId); - return; - } - - if (!itemId) { - console.error("Missing itemId"); - return; - } - - try { - const url = `${api.basePath}/PlayingItems/${itemId}`; - const params = { - playSessionId: sessionId, - positionTicks: Math.round(positionTicks), - MediaSourceId: itemId, - IsPaused: true, - }; - const headers = getAuthHeaders(api); - - // Send DELETE request to report playback stopped - await api.axiosInstance.delete(url, { params, headers }); - } catch (error) { - // Log the error with additional context - if (error instanceof AxiosError) { - console.error( - "Failed to report playback progress", - error.message, - error.response?.data - ); - } else { - console.error("Failed to report playback progress", error); - } - } -};