diff --git a/.gitignore b/.gitignore index bb6ff7d0..c098c3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ package-lock.json /ios /android -modules/vlc-player/android +modules/player/android pc-api-7079014811501811218-719-3b9f15aeccf8.json credentials.json @@ -35,4 +35,4 @@ credentials.json .continuerc.json # Ignore VLC player android folder -modules/vlc-player/android \ No newline at end of file +modules/player/android \ No newline at end of file diff --git a/app/(auth)/player.tsx b/app/(auth)/player.tsx deleted file mode 100644 index 1d0eeae8..00000000 --- a/app/(auth)/player.tsx +++ /dev/null @@ -1,528 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/Controls"; -import { useOrientation } from "@/hooks/useOrientation"; -import { useOrientationSettings } from "@/hooks/useOrientationSettings"; -import { useWebSocket } from "@/hooks/useWebsockets"; -import { TrackInfo } from "@/modules/vlc-player"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -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 native from "@/utils/profiles/native"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { Api } from "@jellyfin/sdk"; -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, useLocalSearchParams } from "expo-router"; -import { useAtomValue } from "jotai"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, StatusBar, useWindowDimensions, View } from "react-native"; -import { SystemBars } from "react-native-edge-to-edge"; -import { useSharedValue } from "react-native-reanimated"; -import Video, { - OnProgressData, - SelectedTrack, - SelectedTrackType, - VideoRef, -} from "react-native-video"; - -export default function page() { - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const [settings] = useSettings(); - const videoRef = useRef(null); - - const firstTime = useRef(true); - const dimensions = useWindowDimensions(); - - const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); - const [showControls, setShowControls] = useState(true); - const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const [isBuffering, setIsBuffering] = useState(true); - - const progress = useSharedValue(0); - const isSeeking = useSharedValue(false); - const cacheProgress = useSharedValue(0); - - 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) { - throw new Error("No api"); - } - - if (!itemId) { - console.warn("No itemId"); - return null; - } - - const res = await getUserLibraryApi(api).getItem({ - itemId, - userId: user?.Id, - }); - - return res.data; - }, - staleTime: 0, - }); - - const { - data: stream, - isLoading: isLoadingStreamUrl, - isError: isErrorStreamUrl, - } = useQuery({ - queryKey: [ - "stream-url", - itemId, - audioIndex, - subtitleIndex, - bitrateValue, - user, - mediaSourceId, - ], - queryFn: async () => { - if (!api) { - throw new Error("No api"); - } - - if (!item) { - console.warn("No item", itemId, item); - return null; - } - - const res = await getStreamUrl({ - api, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks!, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: bitrateValue, - mediaSourceId: mediaSourceId, - subtitleStreamIndex: subtitleIndex, - deviceProfile: native, - }); - - if (!res) return null; - - const { mediaSource, sessionId, url } = res; - - if (!sessionId || !mediaSource || !url) { - console.warn("No sessionId or mediaSource or url", url); - return null; - } - - return { - mediaSource, - sessionId, - url, - }; - }, - enabled: !!item, - staleTime: 0, - }); - - 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 play = useCallback(() => { - videoRef.current?.resume(); - reportPlaybackStart(); - }, [videoRef]); - - const pause = useCallback(() => { - videoRef.current?.pause(); - }, [videoRef]); - - const stop = useCallback(() => { - setIsPlaybackStopped(true); - videoRef.current?.pause(); - reportPlaybackStopped(); - }, [videoRef]); - - const seek = useCallback( - (seconds: number) => { - videoRef.current?.seek(seconds); - }, - [videoRef] - ); - - const reportPlaybackStopped = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - playSessionId: stream?.sessionId, - }); - }; - - const reportPlaybackStart = async () => { - if (!item?.Id) return; - 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, - }); - }; - - const onProgress = useCallback( - async (data: OnProgressData) => { - if (isSeeking.value === true) return; - if (isPlaybackStopped === true) return; - - const ticks = secondsToTicks(data.currentTime); - - progress.value = ticks; - cacheProgress.value = secondsToTicks(data.playableDuration); - - // TODO: Use this when streaming with HLS url, but NOT when direct playing - // TODO: since playable duration is always 0 then. - // setIsBuffering(data.playableDuration === 0); - - if (!item?.Id || data.currentTime === 0) { - return; - } - - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item.Id, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.round(ticks), - isPaused: !isPlaying, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - }, - [ - item, - isPlaying, - api, - isPlaybackStopped, - isSeeking, - stream, - mediaSourceId, - audioIndex, - subtitleIndex, - ] - ); - - useFocusEffect( - useCallback(() => { - play(); - - return () => { - stop(); - }; - }, [play, stop]) - ); - - useOrientation(); - useOrientationSettings(); - - useWebSocket({ - isPlaying: isPlaying, - pauseVideo: pause, - playVideo: play, - stopPlayback: stop, - }); - - const [selectedTextTrack, setSelectedTextTrack] = useState< - SelectedTrack | undefined - >(); - - const [embededTextTracks, setEmbededTextTracks] = useState< - { - index: number; - language?: string | undefined; - selected?: boolean | undefined; - title?: string | undefined; - type: any; - }[] - >([]); - - const [audioTracks, setAudioTracks] = useState([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState< - SelectedTrack | undefined - >(undefined); - - const getAudioTracks = (): TrackInfo[] => { - return audioTracks.map((t) => ({ - name: t.name, - index: t.index, - })); - }; - - const getSubtitleTracks = (): TrackInfo[] => { - return embededTextTracks.map((t) => ({ - name: t.title ?? "", - index: t.index, - language: t.language, - })); - }; - - if (isLoadingItem || isLoadingStreamUrl) - return ( - - - - ); - - if (isErrorItem || isErrorStreamUrl) - return ( - - Error - - ); - - return ( - - - ); -} - -export function usePoster( - item: BaseItemDto | null | undefined, - api: Api | null -): string | undefined { - const poster = useMemo(() => { - 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: item, - quality: 70, - width: 200, - }); - }, [item, api]); - - return poster ?? undefined; -} - -export function useVideoSource( - item: BaseItemDto | null | undefined, - api: Api | null, - poster: string | undefined, - url?: string | null -) { - const videoSource = useMemo(() => { - if (!item || !api || !url) { - return null; - } - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? Math.round(item.UserData.PlaybackPositionTicks / 10000) - : 0; - - return { - uri: url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - artist: item?.AlbumArtist ?? undefined, - title: item?.Name || "Unknown", - description: item?.Overview ?? undefined, - imageUri: poster, - subtitle: item?.Album ?? undefined, - }, - }; - }, [item, api, poster, url]); - - return videoSource; -} diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx new file mode 100644 index 00000000..aef58664 --- /dev/null +++ b/app/(auth)/player/_layout.tsx @@ -0,0 +1,40 @@ +import { Stack } from "expo-router"; +import React from "react"; +import { SystemBars } from "react-native-edge-to-edge"; + +export default function Layout() { + return ( + <> +