diff --git a/app/(auth)/music-player.tsx b/app/(auth)/music-player.tsx index 12b810cb..8ddb1f3a 100644 --- a/app/(auth)/music-player.tsx +++ b/app/(auth)/music-player.tsx @@ -1,11 +1,12 @@ import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; import AlbumCover from "@/components/posters/AlbumCover"; import { Controls } from "@/components/video-player/Controls"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { PlaybackType, usePlaySettings, @@ -13,12 +14,18 @@ import { import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { secondsToTicks } from "@/utils/secondsToTicks"; import { Api } from "@jellyfin/sdk"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { + getPlaystateApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useMemo, useRef, useState } from "react"; @@ -27,12 +34,11 @@ import { useSharedValue } from "react-native-reanimated"; import Video, { OnProgressData, VideoRef } from "react-native-video"; export default function page() { - const { playSettings, playUrl, playSessionId } = usePlaySettings(); const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); const [settings] = useSettings(); const videoRef = useRef(null); - const poster = usePoster(playSettings, api); - const videoSource = useVideoSource(playSettings, api, poster, playUrl); + const firstTime = useRef(true); const screenDimensions = Dimensions.get("screen"); @@ -47,47 +53,127 @@ export default function page() { const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item) - return null; + const { + itemId, + audioIndex: audioIndexStr, + subtitleIndex: subtitleIndexStr, + mediaSourceId, + bitrateValue: bitrateValueStr, + } = useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + }>(); + + const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; + const subtitleIndex = subtitleIndexStr + ? parseInt(subtitleIndexStr, 10) + : undefined; + const bitrateValue = bitrateValueStr + ? parseInt(bitrateValueStr, 10) + : undefined; + + const { + data: item, + isLoading: isLoadingItem, + isError: isErrorItem, + } = useQuery({ + queryKey: ["item", itemId], + queryFn: async () => { + if (!api) return; + const res = await getUserLibraryApi(api).getItem({ + itemId, + userId: user?.Id, + }); + + return res.data; + }, + enabled: !!itemId && !!api, + staleTime: 0, + }); + + const { + data: stream, + isLoading: isLoadingStreamUrl, + isError: isErrorStreamUrl, + } = useQuery({ + queryKey: ["stream-url"], + queryFn: async () => { + if (!api) return; + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: bitrateValue, + mediaSourceId: mediaSourceId, + subtitleStreamIndex: subtitleIndex, + }); + + if (!res) return null; + + const { mediaSource, sessionId, url } = res; + + if (!sessionId || !mediaSource || !url) return null; + + return { + mediaSource, + sessionId, + url, + }; + }, + }); + + 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: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: true, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream?.sessionId, }); } else { videoRef.current?.resume(); - await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: false, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream?.sessionId, }); } }, - [isPlaying, api, playSettings?.item?.Id, videoRef, settings] + [ + isPlaying, + api, + item, + videoRef, + settings, + audioIndex, + subtitleIndex, + mediaSourceId, + stream, + ] ); const play = useCallback(() => { @@ -108,27 +194,30 @@ export default function page() { reportPlaybackStopped(); }, [videoRef]); + const seek = useCallback( + (seconds: number) => { + videoRef.current?.seek(seconds); + }, + [videoRef] + ); + const reportPlaybackStopped = async () => { - await getPlaystateApi(api).onPlaybackStopped({ - itemId: playSettings?.item?.Id!, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackStopped({ + itemId: item?.Id!, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(progress.value), - playSessionId: playSessionId ? playSessionId : undefined, + playSessionId: stream?.sessionId, }); }; 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", - playSessionId: playSessionId ? playSessionId : undefined, + await getPlaystateApi(api!).onPlaybackStart({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, }); }; @@ -143,24 +232,29 @@ export default function page() { cacheProgress.value = secondsToTicks(data.playableDuration); setIsBuffering(data.playableDuration === 0); - if (!playSettings?.item?.Id || data.currentTime === 0) return; + if (!item?.Id || data.currentTime === 0) return; - await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item.Id, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.round(ticks), isPaused: !isPlaying, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, }); }, - [playSettings?.item.Id, isPlaying, api, isPlaybackStopped] + [ + item, + isPlaying, + api, + isPlaybackStopped, + audioIndex, + subtitleIndex, + mediaSourceId, + stream, + ] ); useFocusEffect( @@ -184,6 +278,22 @@ export default function page() { stopPlayback: stop, }); + if (isLoadingItem || isLoadingStreamUrl) + return ( + + + + ); + + if (isErrorItem || isErrorStreamUrl) + return ( + + Error + + ); + + if (!stream || !item || !videoSource) return null; + return ( ); } export function usePoster( - playSettings: PlaybackType | null, + item: BaseItemDto | null | undefined, api: Api | null ): string | undefined { const poster = useMemo(() => { - if (!playSettings?.item || !api) return undefined; - return playSettings.item.Type === "Audio" - ? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` + if (!item || !api) return undefined; + return item.Type === "Audio" + ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` : getBackdropUrl({ api, - item: playSettings.item, + item: item, quality: 70, width: 200, }); - }, [playSettings?.item, api]); + }, [item, api]); return poster ?? undefined; } export function useVideoSource( - playSettings: PlaybackType | null, + item: BaseItemDto | null | undefined, api: Api | null, poster: string | undefined, - playUrl?: string | null + url?: string | null ) { const videoSource = useMemo(() => { - if (!playSettings || !api || !playUrl) { + if (!item || !api || !url) { return null; } - const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks - ? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000) + const startPosition = item?.UserData?.PlaybackPositionTicks + ? Math.round(item.UserData.PlaybackPositionTicks / 10000) : 0; return { - uri: playUrl, + uri: url, isNetwork: true, startPosition, headers: getAuthHeaders(api), metadata: { - artist: playSettings.item?.AlbumArtist ?? undefined, - title: playSettings.item?.Name || "Unknown", - description: playSettings.item?.Overview ?? undefined, + artist: item?.AlbumArtist ?? undefined, + title: item?.Name || "Unknown", + description: item?.Overview ?? undefined, imageUri: poster, - subtitle: playSettings.item?.Album ?? undefined, + subtitle: item?.Album ?? undefined, }, }; - }, [playSettings, api, poster]); + }, [item, api, poster]); return videoSource; } diff --git a/app/(auth)/player.tsx b/app/(auth)/player.tsx index 6fa4057c..d7f23b54 100644 --- a/app/(auth)/player.tsx +++ b/app/(auth)/player.tsx @@ -1,10 +1,12 @@ +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/Controls"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; import { TrackInfo } from "@/modules/vlc-player"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { PlaybackType, usePlaySettings, @@ -12,12 +14,18 @@ import { import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { secondsToTicks } from "@/utils/secondsToTicks"; import { ticksToSeconds } from "@/utils/time"; import { Api } from "@jellyfin/sdk"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { + getPlaystateApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { Pressable, StatusBar, useWindowDimensions, View } from "react-native"; @@ -30,13 +38,11 @@ import Video, { } from "react-native-video"; export default function page() { - const { playSettings, playUrl, playSessionId, mediaSource } = - usePlaySettings(); const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); const [settings] = useSettings(); const videoRef = useRef(null); - const poster = usePoster(playSettings, api); - const videoSource = useVideoSource(playSettings, api, poster, playUrl); + const firstTime = useRef(true); const dimensions = useWindowDimensions(); @@ -50,54 +56,127 @@ export default function page() { const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - if ( - !playSettings || - !playUrl || - !api || - !videoSource || - !playSettings.item || - !mediaSource - ) - return null; + const { + itemId, + audioIndex: audioIndexStr, + subtitleIndex: subtitleIndexStr, + mediaSourceId, + bitrateValue: bitrateValueStr, + } = useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + }>(); + + const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; + const subtitleIndex = subtitleIndexStr + ? parseInt(subtitleIndexStr, 10) + : undefined; + const bitrateValue = bitrateValueStr + ? parseInt(bitrateValueStr, 10) + : undefined; + + const { + data: item, + isLoading: isLoadingItem, + isError: isErrorItem, + } = useQuery({ + queryKey: ["item", itemId], + queryFn: async () => { + if (!api) return; + const res = await getUserLibraryApi(api).getItem({ + itemId, + userId: user?.Id, + }); + + return res.data; + }, + enabled: !!itemId && !!api, + staleTime: 0, + }); + + const { + data: stream, + isLoading: isLoadingStreamUrl, + isError: isErrorStreamUrl, + } = useQuery({ + queryKey: ["stream-url"], + queryFn: async () => { + if (!api) return; + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: bitrateValue, + mediaSourceId: mediaSourceId, + subtitleStreamIndex: subtitleIndex, + }); + + if (!res) return null; + + const { mediaSource, sessionId, url } = res; + + if (!sessionId || !mediaSource || !url) return null; + + return { + mediaSource, + sessionId, + url, + }; + }, + }); + + 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: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: true, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream?.sessionId, }); } else { videoRef.current?.resume(); - await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: false, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream?.sessionId, }); } }, - [isPlaying, api, playSettings?.item?.Id, videoRef, settings] + [ + isPlaying, + api, + item, + videoRef, + settings, + stream, + audioIndex, + subtitleIndex, + mediaSourceId, + ] ); const play = useCallback(() => { @@ -123,26 +202,22 @@ export default function page() { ); const reportPlaybackStopped = async () => { - await getPlaystateApi(api).onPlaybackStopped({ - itemId: playSettings?.item?.Id!, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackStopped({ + itemId: item?.Id!, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(progress.value), - playSessionId: playSessionId ? playSessionId : undefined, + playSessionId: stream?.sessionId, }); }; 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", - playSessionId: playSessionId ? playSessionId : undefined, + await getPlaystateApi(api!).onPlaybackStart({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, }); }; @@ -157,24 +232,30 @@ export default function page() { cacheProgress.value = secondsToTicks(data.playableDuration); setIsBuffering(data.playableDuration === 0); - if (!playSettings?.item?.Id || data.currentTime === 0) return; + if (!item?.Id || data.currentTime === 0) return; - await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item.Id, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + await getPlaystateApi(api!).onPlaybackProgress({ + itemId: item.Id, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.round(ticks), isPaused: !isPlaying, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId, }); }, - [playSettings?.item.Id, isPlaying, api, isPlaybackStopped] + [ + item, + isPlaying, + api, + isPlaybackStopped, + isSeeking, + stream, + mediaSourceId, + audioIndex, + subtitleIndex, + ] ); useFocusEffect( @@ -219,6 +300,22 @@ export default function page() { })); }; + if (isLoadingItem || isLoadingStreamUrl) + return ( + + + + ); + + if (isErrorItem || isErrorStreamUrl) + return ( + + Error + + ); + + if (!stream || !item || !videoSource) return null; + return ( { - console.log("onTextTracks ~", data); setEmbededTextTracks(data.textTracks as any); }} selectedTextTrack={selectedTextTrack} @@ -283,7 +379,7 @@ export default function page() { { - if (!playSettings?.item || !api) return undefined; - return playSettings.item.Type === "Audio" - ? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` + if (!item || !api) return undefined; + return item.Type === "Audio" + ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` : getBackdropUrl({ api, - item: playSettings.item, + item: item, quality: 70, width: 200, }); - }, [playSettings?.item, api]); + }, [item, api]); return poster ?? undefined; } export function useVideoSource( - playSettings: PlaybackType | null, + item: BaseItemDto | null | undefined, api: Api | null, poster: string | undefined, - playUrl?: string | null + url?: string | null ) { const videoSource = useMemo(() => { - if (!playSettings || !api || !playUrl) { + if (!item || !api || !url) { return null; } - const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks - ? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000) + const startPosition = item?.UserData?.PlaybackPositionTicks + ? Math.round(item.UserData.PlaybackPositionTicks / 10000) : 0; return { - uri: playUrl, + uri: url, isNetwork: true, startPosition, headers: getAuthHeaders(api), metadata: { - artist: playSettings.item?.AlbumArtist ?? undefined, - title: playSettings.item?.Name || "Unknown", - description: playSettings.item?.Overview ?? undefined, + artist: item?.AlbumArtist ?? undefined, + title: item?.Name || "Unknown", + description: item?.Overview ?? undefined, imageUri: poster, - subtitle: playSettings.item?.Album ?? undefined, + subtitle: item?.Album ?? undefined, }, }; - }, [playSettings, api, poster]); + }, [item, api, poster]); return videoSource; } diff --git a/app/(auth)/vlc-player.tsx b/app/(auth)/vlc-player.tsx index 6c49f5e9..653eae39 100644 --- a/app/(auth)/vlc-player.tsx +++ b/app/(auth)/vlc-player.tsx @@ -1,5 +1,7 @@ +import { BITRATES } from "@/components/BitrateSelector"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/Controls"; -import { VlcControls } from "@/components/video-player/VlcControls"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; @@ -10,40 +12,37 @@ import { ProgressUpdatePayload, VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { - PlaybackType, - usePlaySettings, -} from "@/providers/PlaySettingsProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; import { msToTicks, ticksToMs } from "@/utils/time"; import { Api } from "@jellyfin/sdk"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { + getPlaystateApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; -import { useFocusEffect, useRouter } from "expo-router"; +import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { Alert, Dimensions, Pressable, StatusBar, View } from "react-native"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + Alert, + Pressable, + StatusBar, + useWindowDimensions, + View, +} from "react-native"; import { useSharedValue } from "react-native-reanimated"; export default function page() { - const { playSettings, playUrl, playSessionId, mediaSource } = - usePlaySettings(); - const api = useAtomValue(apiAtom); const videoRef = useRef(null); - // const poster = usePoster(playSettings, api); - // const user = useAtomValue(userAtom); + const user = useAtomValue(userAtom); + const api = useAtomValue(apiAtom); - const router = useRouter(); - - const screenDimensions = Dimensions.get("screen"); + const windowDimensions = useWindowDimensions(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, setShowControls] = useState(true); @@ -56,50 +55,134 @@ export default function page() { const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - if (!playSettings || !playUrl || !api || !playSettings.item || !mediaSource) { - Alert.alert("Error", "Invalid play settings"); - router.back(); - return null; - } + const { + itemId, + audioIndex: audioIndexStr, + subtitleIndex: subtitleIndexStr, + mediaSourceId, + bitrateValue: bitrateValueStr, + } = useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + }>(); + + const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; + const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; + const bitrateValue = bitrateValueStr + ? parseInt(bitrateValueStr, 10) + : BITRATES[0].value; + + const { + data: item, + isLoading: isLoadingItem, + isError: isErrorItem, + } = useQuery({ + queryKey: ["item", itemId], + queryFn: async () => { + if (!api) return; + const res = await getUserLibraryApi(api).getItem({ + itemId, + userId: user?.Id, + }); + + return res.data; + }, + enabled: !!itemId && !!api, + staleTime: 0, + }); + + const { + data: stream, + isLoading: isLoadingStreamUrl, + isError: isErrorStreamUrl, + } = useQuery({ + queryKey: [ + "stream-url", + itemId, + audioIndex, + subtitleIndex, + mediaSourceId, + bitrateValue, + ], + queryFn: async () => { + if (!api) return; + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: bitrateValue, + mediaSourceId: mediaSourceId, + subtitleStreamIndex: subtitleIndex, + }); + + if (!res) return null; + + const { mediaSource, sessionId, url } = res; + + if (!sessionId || !mediaSource || !url) return null; + + console.log(url); + + return { + mediaSource, + sessionId, + url, + }; + }, + enabled: !!itemId && !!api && !!item, + staleTime: 0, + }); const togglePlay = useCallback( async (ticks: number) => { + if (!api || !stream) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (isPlaying) { videoRef.current?.pause(); await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: true, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream.url?.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream.sessionId, }); } else { videoRef.current?.play(); await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item?.Id!, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(ticks), isPaused: false, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream.sessionId, }); } }, - [isPlaying, api, playSettings?.item?.Id, videoRef] + [ + isPlaying, + api, + item, + stream, + videoRef, + audioIndex, + subtitleIndex, + mediaSourceId, + ] ); const play = useCallback(() => { @@ -118,26 +201,24 @@ export default function page() { }, [videoRef]); const reportPlaybackStopped = async () => { + if (!api) return; await getPlaystateApi(api).onPlaybackStopped({ - itemId: playSettings?.item?.Id!, - mediaSourceId: playSettings.mediaSource?.Id!, + itemId: item?.Id!, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(progress.value), - playSessionId: playSessionId ? playSessionId : undefined, + playSessionId: stream?.sessionId!, }); }; const reportPlaybackStart = async () => { + if (!api || !stream) return; 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", - playSessionId: playSessionId ? playSessionId : undefined, + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId ? stream?.sessionId : undefined, }); }; @@ -145,7 +226,7 @@ export default function page() { async (data: ProgressUpdatePayload) => { if (isSeeking.value === true) return; if (isPlaybackStopped === true) return; - if (!playSettings.item?.Id) return; + if (!item?.Id || !api || !stream) return; const { currentTime, isPlaying } = data.nativeEvent; @@ -153,21 +234,17 @@ export default function page() { const currentTimeInTicks = msToTicks(currentTime); await getPlaystateApi(api).onPlaybackProgress({ - itemId: playSettings.item.Id, - audioStreamIndex: playSettings.audioIndex - ? playSettings.audioIndex - : undefined, - subtitleStreamIndex: playSettings.subtitleIndex - ? playSettings.subtitleIndex - : undefined, - mediaSourceId: playSettings.mediaSource?.Id!, + itemId: item.Id, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, positionTicks: Math.floor(currentTimeInTicks), isPaused: !isPlaying, - playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: playSessionId ? playSessionId : undefined, + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream.sessionId, }); }, - [playSettings?.item.Id, isPlaying, api, isPlaybackStopped] + [item?.Id, isPlaying, api, isPlaybackStopped] ); useFocusEffect( @@ -212,18 +289,27 @@ export default function page() { } }; - useEffect(() => { - console.log( - "PlaybackPositionTicks", - playSettings.item?.UserData?.PlaybackPositionTicks + if (isLoadingItem || isLoadingStreamUrl) + return ( + + + ); - }, [playSettings.item]); + + if (isErrorItem || isErrorStreamUrl) + return ( + + Error + + ); + + if (!stream || !item) return null; return ( { - if (!playSettings?.item || !api) return undefined; - return playSettings.item.Type === "Audio" - ? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` + if (!item || !api) return undefined; + return item.Type === "Audio" + ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` : getBackdropUrl({ api, - item: playSettings.item, + item: item, quality: 70, width: 200, }); - }, [playSettings?.item, api]); + }, [item, api]); return poster ?? undefined; } diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 2c17fe7e..75fd659c 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; interface Props extends React.ComponentProps { - source: MediaSourceInfo; + source?: MediaSourceInfo; onChange: (value: number) => void; - selected?: number | null; + selected?: number | undefined; } export const AudioTrackSelector: React.FC = ({ @@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC = ({ ...props }) => { const audioStreams = useMemo( - () => source.MediaStreams?.filter((x) => x.Type === "Audio"), + () => source?.MediaStreams?.filter((x) => x.Type === "Audio"), [source] ); diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index e3e6406c..13d2f019 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -11,117 +11,60 @@ import { ItemImage } from "@/components/common/ItemImage"; import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColors } from "@/hooks/useImageColors"; +import { useOrientation } from "@/hooks/useOrientation"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useFocusEffect, useNavigation } from "expo-router"; +import { useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, View } from "react-native"; +import React, { useEffect, useMemo, useState } from "react"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; -import { useOrientation } from "@/hooks/useOrientation"; + +export type SelectedOptions = { + bitrate: Bitrate; + mediaSource: MediaSourceInfo | undefined; + audioIndex: number | undefined; + subtitleIndex: number; +}; export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( ({ item }) => { const [api] = useAtom(apiAtom); - - const { setPlaySettings, playSettings } = usePlaySettings(); + const [settings] = useSettings(); const { orientation } = useOrientation(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const [settings] = useSettings(); + useImageColors({ item }); const [loadingLogo, setLoadingLogo] = useState(true); - - useFocusEffect( - useCallback(() => { - if (!settings || item.Type === "Program") return; - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(item, settings); - - setPlaySettings({ - item, - bitrate, - mediaSource, - audioIndex, - subtitleIndex, - }); - - console.log({ - 1: item, - 2: bitrate, - 3: mediaSource, - 4: audioIndex, - 5: subtitleIndex, - }); - - if (!mediaSource) { - Alert.alert("Error", "No media source found for this item."); - navigation.goBack(); - } - }, [item, settings]) - ); - - const selectedMediaSource = useMemo(() => { - return playSettings?.mediaSource || undefined; - }, [playSettings?.mediaSource]); - - const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => { - setPlaySettings((prev) => ({ - ...prev, - mediaSource, - })); - }; - - const selectedAudioStream = useMemo(() => { - return playSettings?.audioIndex; - }, [playSettings?.audioIndex]); - - const setSelectedAudioStream = (audioIndex: number) => { - setPlaySettings((prev) => ({ - ...prev, - audioIndex, - })); - }; - - const selectedSubtitleStream = useMemo(() => { - return playSettings?.subtitleIndex; - }, [playSettings?.subtitleIndex]); - - const setSelectedSubtitleStream = (subtitleIndex: number) => { - setPlaySettings((prev) => ({ - ...prev, - subtitleIndex, - })); - }; - - const maxBitrate = useMemo(() => { - return playSettings?.bitrate; - }, [playSettings?.bitrate]); - - const setMaxBitrate = (bitrate: Bitrate | undefined) => { - setPlaySettings((prev) => ({ - ...prev, - bitrate, - })); - }; - const [headerHeight, setHeaderHeight] = useState(350); - useImageColors({ item }); + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(item, settings); + + const [selectedOptions, setSelectedOptions] = useState({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex || -1, + }); useEffect(() => { navigation.setOptions({ @@ -204,34 +147,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( setMaxBitrate(val)} - selected={maxBitrate} + onChange={(val) => + setSelectedOptions((prev) => ({ ...prev, bitrate: val })) + } + selected={selectedOptions.bitrate} /> + setSelectedOptions((prev) => ({ + ...prev, + mediaSource: val, + })) + } + selected={selectedOptions.mediaSource} + /> + + setSelectedOptions((prev) => ({ + ...prev, + audioIndex: val, + })) + } + selected={selectedOptions.audioIndex} + /> + + setSelectedOptions((prev) => ({ + ...prev, + subtitleIndex: val, + })) + } + selected={selectedOptions.subtitleIndex} /> - {selectedMediaSource && ( - <> - - - - )} )} - + {item.Type === "Episode" && ( diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 79e8e5e5..91e6d91a 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -29,20 +29,6 @@ export const MediaSourceSelector: React.FC = ({ [item.MediaSources, selected] ); - useEffect(() => { - if (!selected && item.MediaSources && item.MediaSources.length > 0) { - onChange(item.MediaSources[0]); - } - }, [item.MediaSources, selected]); - - const name = (name?: string | null) => { - if (name && name.length > 40) - return ( - name.substring(0, 20) + " [...] " + name.substring(name.length - 20) - ); - return name; - }; - return ( = ({ ); }; + +const name = (name?: string | null) => { + if (name && name.length > 40) + return name.substring(0, 20) + " [...] " + name.substring(name.length - 20); + return name; +}; diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 8748f497..0b88d2e4 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,5 +1,4 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; @@ -9,10 +8,12 @@ import { chromecastProfile } from "@/utils/profiles/chromecast"; import { runtimeTicksToMinutes } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useQuery } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo } from "react"; -import { Alert, Linking, Platform, TouchableOpacity, View } from "react-native"; +import { useCallback, useEffect } from "react"; +import { Alert, Platform, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, PlayServicesState, @@ -30,15 +31,21 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Button } from "./Button"; -import { Text } from "./common/Text"; +import { SelectedOptions } from "./ItemContent"; -interface Props extends React.ComponentProps {} +interface Props extends React.ComponentProps { + item: BaseItemDto; + selectedOptions: SelectedOptions; +} const ANIMATION_DURATION = 500; const MIN_PLAYBACK_WIDTH = 15; -export const PlayButton: React.FC = ({ ...props }) => { - const { playSettings, playUrl: url } = usePlaySettings(); +export const PlayButton: React.FC = ({ + item, + selectedOptions, + ...props +}: Props) => { const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); @@ -57,35 +64,58 @@ export const PlayButton: React.FC = ({ ...props }) => { const colorChangeProgress = useSharedValue(0); const [settings] = useSettings(); - const directStream = useMemo(() => { - if (!url || url.length === 0) return "Loading..."; - if (url.includes("m3u8")) return "Transcoded stream"; - return "Direct stream"; - }, [url]); + const {} = useQuery({ + queryKey: ["stream-url"], + queryFn: async () => { + if (!api) return; + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: selectedOptions.audioIndex, + maxStreamingBitrate: selectedOptions.bitrate?.value, + mediaSourceId: selectedOptions.mediaSource?.Id, + subtitleStreamIndex: selectedOptions.subtitleIndex, + }); - const item = useMemo(() => { - return playSettings?.item; - }, [playSettings?.item]); + if (!res) return null; + + const { mediaSource, sessionId, url } = res; + + return res; + }, + }); + + // const directStream = useMemo(() => { + // if (!url || url.length === 0) return "Loading..."; + // if (url.includes("m3u8")) return "Transcoded stream"; + // return "Direct stream"; + // }, [url]); + + // const item = useMemo(() => { + // return playSettings?.item; + // }, [playSettings?.item]); const onPress = useCallback(async () => { - if (!url || !item) { - console.warn( - "No URL or item provided to PlayButton", - url?.slice(0, 100), - item?.Id - ); - return; - } + if (!item) return; + + const queryParams = new URLSearchParams({ + itemId: item.Id!, + audioIndex: selectedOptions.audioIndex?.toString() ?? "", + subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", + mediaSourceId: selectedOptions.mediaSource?.Id ?? "", + bitrate: selectedOptions.bitrate?.value?.toString() ?? "", + }); + + const queryString = queryParams.toString(); if (!client) { - const vlcLink = "vlc://" + url; - if (vlcLink && settings?.openInVLC) { - Linking.openURL(vlcLink); - return; + if (Platform.OS === "ios") { + router.push(`/vlc-player?${queryString}`); + } else { + router.push(`/player?${queryString}`); } - - if (Platform.OS === "ios") router.push("/vlc-player"); - else router.push("/player"); return; } @@ -118,14 +148,14 @@ export const PlayButton: React.FC = ({ ...props }) => { // Get a new URL with the Chromecast device profile: const data = await getStreamUrl({ api, - deviceProfile: chromecastProfile, item, - mediaSourceId: playSettings?.mediaSource?.Id, - startTimeTicks: 0, - maxStreamingBitrate: playSettings?.bitrate?.value, - audioStreamIndex: playSettings?.audioIndex ?? 0, - subtitleStreamIndex: playSettings?.subtitleIndex ?? -1, + deviceProfile: chromecastProfile, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, userId: user?.Id, + audioStreamIndex: selectedOptions.audioIndex, + maxStreamingBitrate: selectedOptions.bitrate?.value, + mediaSourceId: selectedOptions.mediaSource?.Id, + subtitleStreamIndex: selectedOptions.subtitleIndex, }); if (!data?.url) { @@ -206,8 +236,9 @@ export const PlayButton: React.FC = ({ ...props }) => { }); break; case 1: - if (Platform.OS === "ios") router.push("/vlc-player"); - else router.push("/player"); + if (Platform.OS === "ios") + router.push(`/vlc-player?${queryString}`); + else router.push(`/player?${queryString}`); break; case cancelButtonIndex: break; @@ -215,16 +246,15 @@ export const PlayButton: React.FC = ({ ...props }) => { } ); }, [ - url, item, client, settings, api, user, - playSettings, router, showActionSheetWithOptions, mediaStatus, + selectedOptions, ]); const derivedTargetWidth = useDerivedValue(() => { @@ -319,7 +349,7 @@ export const PlayButton: React.FC = ({ ...props }) => { return ( = ({ ...props }) => { - + {/* = ({ ...props }) => { {directStream ? "Direct stream" : "Transcoded stream"} - + */} ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 23f2ebb2..144d20d6 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -6,9 +6,9 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; interface Props extends React.ComponentProps { - source: MediaSourceInfo; + source?: MediaSourceInfo; onChange: (value: number) => void; - selected?: number | null; + selected?: number | undefined; } export const SubtitleTrackSelector: React.FC = ({ @@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => { const subtitleStreams = useMemo( - () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], + () => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], [source] ); diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts new file mode 100644 index 00000000..e127fa48 --- /dev/null +++ b/hooks/useDefaultPlaySettings.ts @@ -0,0 +1,50 @@ +import { Bitrate, BITRATES } from "@/components/BitrateSelector"; +import { Settings } from "@/utils/atoms/settings"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { useMemo } from "react"; + +const useDefaultPlaySettings = ( + item: BaseItemDto, + settings: Settings | null +) => { + const playSettings = useMemo(() => { + // 1. Get first media source + const mediaSource = item.MediaSources?.[0]; + + // 2. Get default or preferred audio + const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; + const preferedAudioIndex = mediaSource?.MediaStreams?.find( + (x) => x.Language === settings?.defaultAudioLanguage + )?.Index; + const firstAudioIndex = mediaSource?.MediaStreams?.find( + (x) => x.Type === "Audio" + )?.Index; + + // 3. Get default or preferred subtitle + const preferedSubtitleIndex = mediaSource?.MediaStreams?.find( + (x) => x.Language === settings?.defaultSubtitleLanguage?.value + )?.Index; + const defaultSubtitleIndex = mediaSource?.MediaStreams?.find( + (stream) => stream.Type === "Subtitle" && stream.IsDefault + )?.Index; + + // 4. Get default bitrate + const bitrate = BITRATES[0]; + + return { + defaultAudioIndex: + preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined, + defaultSubtitleIndex: + preferedSubtitleIndex || defaultSubtitleIndex || undefined, + defaultMediaSource: mediaSource || undefined, + defaultBitrate: bitrate || undefined, + }; + }, [item, settings]); + + return playSettings; +}; + +export default useDefaultPlaySettings; diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index 26a6a935..ff0ff7a8 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -194,6 +194,7 @@ class VlcPlayerView: ExpoView { } if autoplay { + print("Playing...") self.play() } } diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 3b739e18..f5870132 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -27,7 +27,7 @@ export const getStreamUrl = async ({ startTimeTicks: number; maxStreamingBitrate?: number; sessionData?: PlaybackInfoResponse | null; - deviceProfile: any; + deviceProfile?: any; audioStreamIndex?: number; subtitleStreamIndex?: number; height?: number; @@ -42,7 +42,6 @@ export const getStreamUrl = async ({ } let mediaSource: MediaSourceInfo | undefined; - let url: string | null | undefined; let sessionId: string | null | undefined; if (item.Type === "Program") {