diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx index 879ffff5..d6c3266c 100644 --- a/app/(auth)/play-music.tsx +++ b/app/(auth)/play-music.tsx @@ -1,4 +1,3 @@ -import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer"; import { StatusBar } from "expo-status-bar"; import { View, ViewProps } from "react-native"; @@ -8,7 +7,6 @@ export default function page() { return ( ); } diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx new file mode 100644 index 00000000..51625d10 --- /dev/null +++ b/app/(auth)/play-offline-video.tsx @@ -0,0 +1,246 @@ +import { Controls } from "@/components/video-player/Controls"; +import { useWebSocket } from "@/hooks/useWebsockets"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { + PlaybackType, + usePlaySettings, +} from "@/providers/PlaySettingsProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +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 * as ScreenOrientation from "expo-screen-orientation"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +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 { useLocalSearchParams, useGlobalSearchParams, Link } from "expo-router"; + +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; + +export default function page() { + const { playSettings, playUrl } = usePlaySettings(); + + const api = useAtomValue(apiAtom); + const [settings] = useSettings(); + const videoRef = useRef(null); + const videoSource = useVideoSource(playSettings, api, playUrl); + const firstTime = useRef(true); + + const screenDimensions = Dimensions.get("screen"); + + const [showControls, setShowControls] = useState(true); + const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isBuffering, setIsBuffering] = useState(true); + const [orientation, setOrientation] = useState( + ScreenOrientation.OrientationLock.UNKNOWN + ); + + const progress = useSharedValue(0); + const isSeeking = useSharedValue(false); + const cacheProgress = useSharedValue(0); + + if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item) + return null; + + const togglePlay = useCallback(async () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (isPlaying) { + setIsPlaying(false); + videoRef.current?.pause(); + } else { + setIsPlaying(true); + videoRef.current?.resume(); + } + }, [isPlaying, api, playSettings?.item?.Id, videoRef, settings]); + + const play = useCallback(() => { + setIsPlaying(true); + videoRef.current?.resume(); + }, [videoRef]); + + const stop = useCallback(() => { + setIsPlaying(false); + videoRef.current?.pause(); + }, [videoRef]); + + useEffect(() => { + play(); + return () => { + stop(); + }; + }); + + useEffect(() => { + const orientationSubscription = + ScreenOrientation.addOrientationChangeListener((event) => { + setOrientation( + orientationToOrientationLock(event.orientationInfo.orientation) + ); + }); + + ScreenOrientation.getOrientationAsync().then((orientation) => { + setOrientation(orientationToOrientationLock(orientation)); + }); + + return () => { + orientationSubscription.remove(); + }; + }, []); + + useEffect(() => { + if (settings?.autoRotate) { + // Don't need to do anything + } else if (settings?.defaultVideoOrientation) { + ScreenOrientation.lockAsync(settings.defaultVideoOrientation); + } + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("hidden"); + NavigationBar.setBehaviorAsync("overlay-swipe"); + } + + return () => { + if (settings?.autoRotate) { + ScreenOrientation.unlockAsync(); + } else { + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + } + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("visible"); + NavigationBar.setBehaviorAsync("inset-swipe"); + } + }; + }, [settings]); + + const onProgress = useCallback( + async (data: OnProgressData) => { + if (isSeeking.value === true) return; + progress.value = secondsToTicks(data.currentTime); + cacheProgress.value = secondsToTicks(data.playableDuration); + setIsBuffering(data.playableDuration === 0); + }, + [playSettings?.item.Id, isPlaying, api] + ); + + return ( + + + ); +} + +export function usePoster( + playSettings: PlaybackType | null, + 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` + : getBackdropUrl({ + api, + item: playSettings.item, + quality: 70, + width: 200, + }); + }, [playSettings?.item, api]); + + return poster ?? undefined; +} + +export function useVideoSource( + playSettings: PlaybackType | null, + api: Api | null, + playUrl?: string | null +) { + const videoSource = useMemo(() => { + if (!playSettings || !api || !playUrl) { + return null; + } + + const startPosition = 0; + + return { + uri: playUrl, + isNetwork: false, + startPosition, + metadata: { + artist: playSettings.item?.AlbumArtist ?? undefined, + title: playSettings.item?.Name || "Unknown", + description: playSettings.item?.Overview ?? undefined, + subtitle: playSettings.item?.Album ?? undefined, + }, + }; + }, [playSettings, api]); + + return videoSource; +} diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx index d6bb866c..348e1451 100644 --- a/app/(auth)/play-video.tsx +++ b/app/(auth)/play-video.tsx @@ -34,6 +34,7 @@ export default function page() { 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"); @@ -131,25 +132,6 @@ export default function page() { }); }; - 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( async (data: OnProgressData) => { if (isSeeking.value === true) return; @@ -179,6 +161,13 @@ export default function page() { [playSettings?.item.Id, isPlaying, api] ); + useEffect(() => { + play(); + return () => { + stop(); + }; + }, []); + useEffect(() => { const orientationSubscription = ScreenOrientation.addOrientationChangeListener((event) => { @@ -196,14 +185,35 @@ export default function page() { }; }, []); - const isLandscape = useMemo(() => { - return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || - orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ? true - : false; - }, [orientation]); + useEffect(() => { + if (settings?.autoRotate) { + // Don't need to do anything + } else if (settings?.defaultVideoOrientation) { + ScreenOrientation.lockAsync(settings.defaultVideoOrientation); + } - const { isConnected } = useWebSocket({ + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("hidden"); + NavigationBar.setBehaviorAsync("overlay-swipe"); + } + + return () => { + if (settings?.autoRotate) { + ScreenOrientation.unlockAsync(); + } else { + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + } + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("visible"); + NavigationBar.setBehaviorAsync("inset-swipe"); + } + }; + }, [settings]); + + useWebSocket({ isPlaying: isPlaying, pauseVideo: pause, playVideo: play, @@ -262,7 +272,6 @@ export default function page() { isBuffering={isBuffering} showControls={showControls} setShowControls={setShowControls} - isLandscape={isLandscape} setIgnoreSafeAreas={setIgnoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas} /> diff --git a/app/(auth)/play.tsx b/app/(auth)/play.tsx deleted file mode 100644 index 5ca25b2b..00000000 --- a/app/(auth)/play.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer"; -import { useSettings } from "@/utils/atoms/settings"; -import * as NavigationBar from "expo-navigation-bar"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; -import { Platform, View, ViewProps } from "react-native"; - -interface Props extends ViewProps {} - -export default function page() { - const [settings] = useSettings(); - - useEffect(() => { - if (settings?.autoRotate) { - // Don't need to do anything - } else if (settings?.defaultVideoOrientation) { - ScreenOrientation.lockAsync(settings.defaultVideoOrientation); - } - - if (Platform.OS === "android") { - NavigationBar.setVisibilityAsync("hidden"); - NavigationBar.setBehaviorAsync("overlay-swipe"); - } - - return () => { - if (settings?.autoRotate) { - ScreenOrientation.unlockAsync(); - } else { - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP - ); - } - - if (Platform.OS === "android") { - NavigationBar.setVisibilityAsync("visible"); - NavigationBar.setBehaviorAsync("inset-swipe"); - } - }; - }, [settings]); - - return ( - - - ); -} diff --git a/app/_layout.tsx b/app/_layout.tsx index 30c525bb..9b18a08f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,7 +5,6 @@ import { JellyfinProvider, } from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; -import { PlaybackProvider } from "@/providers/PlaybackProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Settings, useSettings } from "@/utils/atoms/settings"; @@ -313,12 +312,12 @@ function Layout() { return ( - - - - - - + + + + + + @@ -330,7 +329,7 @@ function Layout() { }} /> - - - - - - + + + + + + ); diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index bde7e8cb..f028fff7 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -1,60 +1,57 @@ -import { useSettings } from "@/utils/atoms/settings"; -import { useAtom } from "jotai"; -import { useEffect, useMemo } from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; +import { atom, useAtom } from "jotai"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useMemo } from "react"; +import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; +import { tc } from "@/utils/textTools"; +import { useSettings } from "@/utils/atoms/settings"; -interface Props extends ViewProps {} +interface Props extends React.ComponentProps { + source: MediaSourceInfo; + onChange: (value: number) => void; + selected?: number | null; +} -export const AudioTrackSelector: React.FC = ({ ...props }) => { - const { playSettings, setPlaySettings, playUrl } = usePlaySettings(); +export const AudioTrackSelector: React.FC = ({ + source, + onChange, + selected, + ...props +}) => { const [settings] = useSettings(); - const selectedIndex = useMemo(() => { - return playSettings?.audioIndex; - }, [playSettings?.audioIndex]); - const audioStreams = useMemo( - () => - playSettings?.mediaSource?.MediaStreams?.filter( - (x) => x.Type === "Audio" - ), - [playSettings?.mediaSource] + () => source.MediaStreams?.filter((x) => x.Type === "Audio"), + [source] ); - const selectedAudioStream = useMemo( - () => audioStreams?.find((x) => x.Index === selectedIndex), - [audioStreams, selectedIndex] + const selectedAudioSteam = useMemo( + () => audioStreams?.find((x) => x.Index === selected), + [audioStreams, selected] ); - // Set default audio stream only if none is selected and we have audio streams useEffect(() => { - if (playSettings?.audioIndex !== undefined || !audioStreams?.length) return; - - const defaultAudioIndex = audioStreams.find( + if (selected) return; + const defaultAudioIndex = audioStreams?.find( (x) => x.Language === settings?.defaultAudioLanguage )?.Index; - - if (defaultAudioIndex !== undefined) { - setPlaySettings((prev) => ({ - ...prev, - audioIndex: defaultAudioIndex, - })); - } else { - const index = playSettings?.mediaSource?.DefaultAudioStreamIndex ?? 0; - setPlaySettings((prev) => ({ - ...prev, - audioIndex: index, - })); + if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) { + onChange(defaultAudioIndex); + return; } - }, [ - audioStreams, - settings?.defaultAudioLanguage, - playSettings?.mediaSource, - setPlaySettings, - ]); + const index = source.DefaultAudioStreamIndex; + if (index !== undefined && index !== null) { + onChange(index); + return; + } + + onChange(0); + }, [audioStreams, settings, source]); return ( = ({ ...props }) => { Audio - {selectedAudioStream?.DisplayTitle} + {selectedAudioSteam?.DisplayTitle} @@ -89,10 +86,7 @@ export const AudioTrackSelector: React.FC = ({ ...props }) => { key={idx.toString()} onSelect={() => { if (audio.Index !== null && audio.Index !== undefined) - setPlaySettings((prev) => ({ - ...prev, - audioIndex: audio.Index, - })); + onChange(audio.Index); }} > diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 28674c48..5e504cd0 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,8 +1,7 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { useEffect, useMemo } from "react"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; +import { useMemo } from "react"; export type Bitrate = { key: string; @@ -10,7 +9,7 @@ export type Bitrate = { height?: number; }; -export const BITRATES: Bitrate[] = [ +const BITRATES: Bitrate[] = [ { key: "Max", value: undefined, @@ -43,11 +42,17 @@ export const BITRATES: Bitrate[] = [ ]; interface Props extends React.ComponentProps { - inverted?: boolean; + onChange: (value: Bitrate) => void; + selected?: Bitrate | null; + inverted?: boolean | null; } -export const BitrateSelector: React.FC = ({ inverted, ...props }) => { - const { setPlaySettings, playSettings } = usePlaySettings(); +export const BitrateSelector: React.FC = ({ + onChange, + selected, + inverted, + ...props +}) => { const sorted = useMemo(() => { if (inverted) return BITRATES.sort( @@ -58,18 +63,6 @@ export const BitrateSelector: React.FC = ({ inverted, ...props }) => { ); }, []); - const selected = useMemo(() => { - return sorted.find((b) => b.value === playSettings?.bitrate?.value); - }, [playSettings?.bitrate]); - - // Set default bitrate on load - useEffect(() => { - setPlaySettings((prev) => ({ - ...prev, - bitrate: BITRATES[0], - })); - }, []); - return ( = ({ inverted, ...props }) => { Quality - {selected?.key} + {BITRATES.find((b) => b.value === selected?.value)?.key} @@ -103,10 +96,7 @@ export const BitrateSelector: React.FC = ({ inverted, ...props }) => { { - setPlaySettings((prev) => ({ - ...prev, - bitrate: b, - })); + onChange(b); }} > {b.key} diff --git a/components/FullScreenMusicPlayer.tsx b/components/FullScreenMusicPlayer.tsx deleted file mode 100644 index 94c6b57a..00000000 --- a/components/FullScreenMusicPlayer.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; -import { useCreditSkipper } from "@/hooks/useCreditSkipper"; -import { useIntroSkipper } from "@/hooks/useIntroSkipper"; -import { useTrickplay } from "@/hooks/useTrickplay"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { usePlayback } from "@/providers/PlaybackProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; -import orientationToOrientationLock from "@/utils/OrientationLockConverter"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { formatTimeString, ticksToSeconds } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { useRouter, useSegments } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - Alert, - BackHandler, - Dimensions, - Pressable, - TouchableOpacity, - View, -} from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { - runOnJS, - useAnimatedReaction, - useSharedValue, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Video, { OnProgressData } from "react-native-video"; -import { Text } from "./common/Text"; -import { itemRouter } from "./common/TouchableItemRouter"; -import { Loader } from "./Loader"; - -const windowDimensions = Dimensions.get("window"); -const screenDimensions = Dimensions.get("screen"); - -export const FullScreenMusicPlayer: React.FC = () => { - const { - currentlyPlaying, - pauseVideo, - playVideo, - stopPlayback, - setIsPlaying, - isPlaying, - videoRef, - onProgress, - setIsBuffering, - } = usePlayback(); - - const [settings] = useSettings(); - const [api] = useAtom(apiAtom); - const router = useRouter(); - const segments = useSegments(); - const insets = useSafeAreaInsets(); - - const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); - - const [showControls, setShowControls] = useState(true); - const [isBuffering, setIsBufferingState] = useState(true); - - // Seconds - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(0); - - const isSeeking = useSharedValue(false); - - const cacheProgress = useSharedValue(0); - const progress = useSharedValue(0); - const min = useSharedValue(0); - const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); - - const [dimensions, setDimensions] = useState({ - window: windowDimensions, - screen: screenDimensions, - }); - - useEffect(() => { - const subscription = Dimensions.addEventListener( - "change", - ({ window, screen }) => { - setDimensions({ window, screen }); - } - ); - return () => subscription?.remove(); - }); - - const from = useMemo(() => segments[2], [segments]); - - const updateTimes = useCallback( - (currentProgress: number, maxValue: number) => { - const current = ticksToSeconds(currentProgress); - const remaining = ticksToSeconds(maxValue - current); - - setCurrentTime(current); - setRemainingTime(remaining); - }, - [] - ); - - const { showSkipButton, skipIntro } = useIntroSkipper( - currentlyPlaying?.item.Id, - currentTime, - videoRef - ); - - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - currentlyPlaying?.item.Id, - currentTime, - videoRef - ); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - if (result.isSeeking === false) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes] - ); - - useEffect(() => { - const backAction = () => { - if (currentlyPlaying) { - Alert.alert("Hold on!", "Are you sure you want to exit?", [ - { - text: "Cancel", - onPress: () => null, - style: "cancel", - }, - { - text: "Yes", - onPress: () => { - stopPlayback(); - router.back(); - }, - }, - ]); - return true; - } - return false; - }; - - const backHandler = BackHandler.addEventListener( - "hardwareBackPress", - backAction - ); - - return () => backHandler.remove(); - }, [currentlyPlaying, stopPlayback, router]); - - const poster = useMemo(() => { - if (!currentlyPlaying?.item || !api) return ""; - return currentlyPlaying.item.Type === "Audio" - ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` - : getBackdropUrl({ - api, - item: currentlyPlaying.item, - quality: 70, - width: 200, - }); - }, [currentlyPlaying?.item, api]); - - const videoSource = useMemo(() => { - if (!api || !currentlyPlaying || !poster) return null; - const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks - ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000) - : 0; - return { - uri: currentlyPlaying.url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - artist: currentlyPlaying.item?.AlbumArtist ?? undefined, - title: currentlyPlaying.item?.Name || "Unknown", - description: currentlyPlaying.item?.Overview ?? undefined, - imageUri: poster, - subtitle: currentlyPlaying.item?.Album ?? undefined, - }, - }; - }, [currentlyPlaying, api, poster]); - - useEffect(() => { - if (currentlyPlaying) { - progress.value = - currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; - max.value = currentlyPlaying.item.RunTimeTicks || 0; - setShowControls(true); - playVideo(); - } - }, [currentlyPlaying]); - - const toggleControls = () => setShowControls(!showControls); - - const handleVideoProgress = useCallback( - (data: OnProgressData) => { - if (isSeeking.value === true) return; - progress.value = secondsToTicks(data.currentTime); - cacheProgress.value = secondsToTicks(data.playableDuration); - setIsBufferingState(data.playableDuration === 0); - setIsBuffering(data.playableDuration === 0); - onProgress(data); - }, - [onProgress, setIsBuffering, isSeeking] - ); - - const handleVideoError = useCallback( - (e: any) => { - console.log(e); - writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); - Alert.alert("Error", "Cannot play this video file."); - setIsPlaying(false); - }, - [setIsPlaying] - ); - - const handlePlayPause = useCallback(() => { - if (isPlaying) pauseVideo(); - else playVideo(); - }, [isPlaying, pauseVideo, playVideo]); - - const handleSliderComplete = (value: number) => { - progress.value = value; - isSeeking.value = false; - videoRef.current?.seek(value / 10000000); - }; - - const handleSliderChange = (value: number) => {}; - - const handleSliderStart = useCallback(() => { - if (showControls === false) return; - isSeeking.value = true; - }, []); - - const handleSkipBackward = useCallback(async () => { - if (!settings) return; - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [settings]); - - const handleSkipForward = useCallback(async () => { - if (!settings) return; - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [settings]); - - const handleGoToPreviousItem = useCallback(() => { - if (!previousItem || !from) return; - const url = itemRouter(previousItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [previousItem, from, stopPlayback, router]); - - const handleGoToNextItem = useCallback(() => { - if (!nextItem || !from) return; - const url = itemRouter(nextItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [nextItem, from, stopPlayback, router]); - - if (!currentlyPlaying) return null; - - return ( - - - {videoSource && ( - <> - - - - - - - {(showControls || isBuffering) && ( - - )} - - {isBuffering && ( - - - - )} - - {showSkipButton && ( - - - Skip Intro - - - )} - - {showSkipCreditButton && ( - - - Skip Credits - - - )} - - {showControls && ( - <> - - { - stopPlayback(); - router.back(); - }} - className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" - > - - - - - - - {currentlyPlaying.item?.Name} - {currentlyPlaying.item?.Type === "Episode" && ( - - {currentlyPlaying.item.SeriesName} - - )} - {currentlyPlaying.item?.Type === "Movie" && ( - - {currentlyPlaying.item?.ProductionYear} - - )} - {currentlyPlaying.item?.Type === "Audio" && ( - - {currentlyPlaying.item?.Album} - - )} - - - - - - - - - - - - - - - - - - - - - - - - {formatTimeString(currentTime)} - - - -{formatTimeString(remainingTime)} - - - - - - - )} - - ); -}; diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx deleted file mode 100644 index 9453c2b3..00000000 --- a/components/FullScreenVideoPlayer.tsx +++ /dev/null @@ -1,626 +0,0 @@ -import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; -import { useCreditSkipper } from "@/hooks/useCreditSkipper"; -import { useIntroSkipper } from "@/hooks/useIntroSkipper"; -import { useTrickplay } from "@/hooks/useTrickplay"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { usePlayback } from "@/providers/PlaybackProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; -import orientationToOrientationLock from "@/utils/OrientationLockConverter"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { formatTimeString, ticksToSeconds } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { useRouter, useSegments } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - Alert, - BackHandler, - Dimensions, - Pressable, - TouchableOpacity, - View, -} from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { - runOnJS, - useAnimatedReaction, - useSharedValue, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Video, { OnProgressData, ReactVideoProps } from "react-native-video"; -import { Text } from "./common/Text"; -import { itemRouter } from "./common/TouchableItemRouter"; -import { Loader } from "./Loader"; - -const windowDimensions = Dimensions.get("window"); -const screenDimensions = Dimensions.get("screen"); - -export const FullScreenVideoPlayer: React.FC = () => { - const { - currentlyPlaying, - pauseVideo, - playVideo, - stopPlayback, - setIsPlaying, - isPlaying, - videoRef, - onProgress, - setIsBuffering, - } = usePlayback(); - - const [settings] = useSettings(); - const [api] = useAtom(apiAtom); - const router = useRouter(); - const segments = useSegments(); - const insets = useSafeAreaInsets(); - - const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); - const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = - useTrickplay(currentlyPlaying); - - const [showControls, setShowControls] = useState(true); - const [isBuffering, setIsBufferingState] = useState(true); - const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); - const [orientation, setOrientation] = useState( - ScreenOrientation.OrientationLock.UNKNOWN - ); - - // Seconds - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(0); - - const isSeeking = useSharedValue(false); - - const cacheProgress = useSharedValue(0); - const progress = useSharedValue(0); - const min = useSharedValue(0); - const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); - - const [dimensions, setDimensions] = useState({ - window: windowDimensions, - screen: screenDimensions, - }); - - useEffect(() => { - const dimensionsSubscription = Dimensions.addEventListener( - "change", - ({ window, screen }) => { - setDimensions({ window, screen }); - } - ); - - const orientationSubscription = - ScreenOrientation.addOrientationChangeListener((event) => { - setOrientation( - orientationToOrientationLock(event.orientationInfo.orientation) - ); - }); - - ScreenOrientation.getOrientationAsync().then((orientation) => { - setOrientation(orientationToOrientationLock(orientation)); - }); - - return () => { - dimensionsSubscription.remove(); - orientationSubscription.remove(); - }; - }, []); - - const from = useMemo(() => segments[2], [segments]); - - const updateTimes = useCallback( - (currentProgress: number, maxValue: number) => { - const current = ticksToSeconds(currentProgress); - const remaining = ticksToSeconds(maxValue - current); - - setCurrentTime(current); - setRemainingTime(remaining); - }, - [] - ); - - const { showSkipButton, skipIntro } = useIntroSkipper( - currentlyPlaying?.item.Id, - currentTime, - videoRef - ); - - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - currentlyPlaying?.item.Id, - currentTime, - videoRef - ); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - if (result.isSeeking === false) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes] - ); - - useEffect(() => { - const backAction = () => { - if (currentlyPlaying) { - Alert.alert("Hold on!", "Are you sure you want to exit?", [ - { - text: "Cancel", - onPress: () => null, - style: "cancel", - }, - { - text: "Yes", - onPress: () => { - stopPlayback(); - router.back(); - }, - }, - ]); - return true; - } - return false; - }; - - const backHandler = BackHandler.addEventListener( - "hardwareBackPress", - backAction - ); - - return () => backHandler.remove(); - }, [currentlyPlaying, stopPlayback, router]); - - const isLandscape = useMemo(() => { - return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || - orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ? true - : false; - }, [orientation]); - - const poster = useMemo(() => { - if (!currentlyPlaying?.item || !api) return ""; - return currentlyPlaying.item.Type === "Audio" - ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` - : getBackdropUrl({ - api, - item: currentlyPlaying.item, - quality: 70, - width: 200, - }); - }, [currentlyPlaying?.item, api]); - - const videoSource: ReactVideoProps["source"] = useMemo(() => { - if (!api || !currentlyPlaying || !poster) return undefined; - const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks - ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000) - : 0; - return { - uri: currentlyPlaying.url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - artist: currentlyPlaying.item?.AlbumArtist ?? undefined, - title: currentlyPlaying.item?.Name || "Unknown", - description: currentlyPlaying.item?.Overview ?? undefined, - imageUri: poster, - subtitle: currentlyPlaying.item?.Album ?? undefined, - }, - }; - }, [currentlyPlaying, api, poster]); - - useEffect(() => { - if (currentlyPlaying) { - progress.value = - currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; - max.value = currentlyPlaying.item.RunTimeTicks || 0; - setShowControls(true); - playVideo(); - } - }, [currentlyPlaying]); - - const toggleControls = () => setShowControls(!showControls); - - const handleVideoProgress = useCallback( - (data: OnProgressData) => { - if (isSeeking.value === true) return; - progress.value = secondsToTicks(data.currentTime); - cacheProgress.value = secondsToTicks(data.playableDuration); - setIsBufferingState(data.playableDuration === 0); - setIsBuffering(data.playableDuration === 0); - onProgress(data); - }, - [onProgress, setIsBuffering, isSeeking] - ); - - const handleVideoError = useCallback( - (e: any) => { - console.log(e); - writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); - Alert.alert("Error", "Cannot play this video file."); - setIsPlaying(false); - }, - [setIsPlaying] - ); - - const handlePlayPause = useCallback(() => { - if (isPlaying) pauseVideo(); - else playVideo(); - }, [isPlaying, pauseVideo, playVideo]); - - const handleSliderComplete = (value: number) => { - progress.value = value; - isSeeking.value = false; - videoRef.current?.seek(value / 10000000); - }; - - const handleSliderChange = (value: number) => { - calculateTrickplayUrl(value); - }; - - const handleSliderStart = useCallback(() => { - if (showControls === false) return; - isSeeking.value = true; - }, []); - - const handleSkipBackward = useCallback(async () => { - if (!settings) return; - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [settings]); - - const handleSkipForward = useCallback(async () => { - if (!settings) return; - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [settings]); - - const handleGoToPreviousItem = useCallback(() => { - if (!previousItem || !from) return; - const url = itemRouter(previousItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [previousItem, from, stopPlayback, router]); - - const handleGoToNextItem = useCallback(() => { - if (!nextItem || !from) return; - const url = itemRouter(nextItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [nextItem, from, stopPlayback, router]); - - const toggleIgnoreSafeArea = useCallback(() => { - setIgnoreSafeArea((prev) => !prev); - }, []); - - if (!currentlyPlaying) return null; - - return ( - - - - - {(showControls || isBuffering) && ( - - )} - - {isBuffering && ( - - - - )} - - {showSkipButton && ( - - - Skip Intro - - - )} - - {showSkipCreditButton && ( - - - Skip Credits - - - )} - - {showControls && ( - <> - - - - - { - stopPlayback(); - router.back(); - }} - className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" - > - - - - - - - {currentlyPlaying.item?.Name} - {currentlyPlaying.item?.Type === "Episode" && ( - - {currentlyPlaying.item.SeriesName} - - )} - {currentlyPlaying.item?.Type === "Movie" && ( - - {currentlyPlaying.item?.ProductionYear} - - )} - {currentlyPlaying.item?.Type === "Audio" && ( - - {currentlyPlaying.item?.Album} - - )} - - - - - - - - - - - - - - - - - - - - - { - if (!trickPlayUrl || !trickplayInfo) { - return null; - } - const { x, y, url } = trickPlayUrl; - - const tileWidth = 150; - const tileHeight = 150 / trickplayInfo.aspectRatio!; - return ( - - - - ); - }} - sliderHeight={10} - thumbWidth={0} - progress={progress} - minimumValue={min} - maximumValue={max} - /> - - - {formatTimeString(currentTime)} - - - -{formatTimeString(remainingTime)} - - - - - - - )} - - ); -}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 51920364..47147da4 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -12,23 +12,18 @@ import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { useImageColors } from "@/hooks/useImageColors"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { apiAtom } from "@/providers/JellyfinProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; import Animated from "react-native-reanimated"; @@ -41,7 +36,7 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( ({ item }) => { const [api] = useAtom(apiAtom); - const { setPlaySettings, playUrl } = usePlaySettings(); + const { setPlaySettings, playUrl, playSettings } = usePlaySettings(); const castDevice = useCastDevice(); const navigation = useNavigation(); @@ -52,6 +47,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( ScreenOrientation.Orientation.PORTRAIT_UP ); + 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 | undefined) => { + setPlaySettings((prev) => ({ + ...prev, + audioIndex, + })); + }; + + const selectedSubtitleStream = useMemo(() => { + return playSettings?.subtitleIndex; + }, [playSettings?.subtitleIndex]); + + const setSelectedSubtitleStream = (subtitleIndex: number | undefined) => { + setPlaySettings((prev) => ({ + ...prev, + subtitleIndex, + })); + }; + + const maxBitrate = useMemo(() => { + return playSettings?.bitrate; + }, [playSettings?.bitrate]); + + const setMaxBitrate = (bitrate: Bitrate | undefined) => { + console.log("setMaxBitrate", bitrate); + setPlaySettings((prev) => ({ + ...prev, + bitrate, + })); + }; + useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { @@ -79,21 +119,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( {item.Type !== "Program" && ( - <> + - + )} ), }); setPlaySettings((prev) => ({ + ...prev, audioIndex: undefined, subtitleIndex: undefined, mediaSourceId: undefined, bitrate: undefined, - mediaSource: undefined, + mediaSource: item.MediaSources?.[0], item, })); }, [item]); @@ -167,10 +208,32 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( {item.Type !== "Program" && ( - - - - + setMaxBitrate(val)} + selected={maxBitrate} + /> + + {selectedMediaSource && ( + <> + + + + )} )} diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 452ef9f8..79e8e5e5 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -1,43 +1,39 @@ -import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; -import { useAtom } from "jotai"; +import { tc } from "@/utils/textTools"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { useEffect, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; +import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; -interface Props extends React.ComponentProps {} +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: MediaSourceInfo) => void; + selected?: MediaSourceInfo | null; +} -export const MediaSourceSelector: React.FC = ({ ...props }) => { - const { playSettings, setPlaySettings, playUrl } = usePlaySettings(); +export const MediaSourceSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const selectedName = useMemo( + () => + item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( + (x) => x.Type === "Video" + )?.DisplayTitle || "", + [item.MediaSources, selected] + ); - const selectedMediaSource = useMemo(() => { - console.log( - "selectedMediaSource", - playSettings?.mediaSource?.MediaStreams?.length - ); - return ( - playSettings?.mediaSource?.MediaStreams?.find((x) => x.Type === "Video") - ?.DisplayTitle || "N/A" - ); - }, [playSettings?.mediaSource]); - - // Set default media source on component mount useEffect(() => { - if ( - playSettings?.item?.MediaSources?.length && - !playSettings?.mediaSource - ) { - console.log( - "Setting default media source", - playSettings?.item?.MediaSources?.[0].Id - ); - setPlaySettings((prev) => ({ - ...prev, - mediaSource: playSettings?.item?.MediaSources?.[0], - })); + if (!selected && item.MediaSources && item.MediaSources.length > 0) { + onChange(item.MediaSources[0]); } - }, [playSettings?.item?.MediaSources, setPlaySettings]); + }, [item.MediaSources, selected]); const name = (name?: string | null) => { if (name && name.length > 40) @@ -58,8 +54,8 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => { Video - - {selectedMediaSource} + + {selectedName} @@ -73,14 +69,11 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => { sideOffset={8} > Media sources - {playSettings?.item?.MediaSources?.map((source, idx: number) => ( + {item.MediaSources?.map((source, idx: number) => ( { - setPlaySettings((prev) => ({ - ...prev, - mediaSource: source, - })); + onChange(source); }} > diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 5c5a3cac..ce0f986c 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -1,46 +1,55 @@ -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { tc } from "@/utils/textTools"; -import { useEffect, useMemo } from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; +import { atom, useAtom } from "jotai"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useMemo } from "react"; +import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; +import { tc } from "@/utils/textTools"; +import { useSettings } from "@/utils/atoms/settings"; -interface Props extends ViewProps {} +interface Props extends React.ComponentProps { + source: MediaSourceInfo; + onChange: (value: number) => void; + selected?: number | null; +} -export const SubtitleTrackSelector: React.FC = ({ ...props }) => { - const { playSettings, setPlaySettings, playUrl } = usePlaySettings(); +export const SubtitleTrackSelector: React.FC = ({ + source, + onChange, + selected, + ...props +}) => { const [settings] = useSettings(); const subtitleStreams = useMemo( - () => - playSettings?.mediaSource?.MediaStreams?.filter( - (x) => x.Type === "Subtitle" - ) ?? [], - [playSettings?.mediaSource] + () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], + [source] ); const selectedSubtitleSteam = useMemo( - () => subtitleStreams.find((x) => x.Index === playSettings?.subtitleIndex), - [subtitleStreams, playSettings?.subtitleIndex] + () => subtitleStreams.find((x) => x.Index === selected), + [subtitleStreams, selected] ); useEffect(() => { + // const index = source.DefaultAudioStreamIndex; + // if (index !== undefined && index !== null) { + // onChange(index); + // return; + // } const defaultSubIndex = subtitleStreams?.find( (x) => x.Language === settings?.defaultSubtitleLanguage?.value )?.Index; if (defaultSubIndex !== undefined && defaultSubIndex !== null) { - setPlaySettings((prev) => ({ - ...prev, - subtitleIndex: defaultSubIndex, - })); + onChange(defaultSubIndex); return; } - setPlaySettings((prev) => ({ - ...prev, - subtitleIndex: -1, - })); + onChange(-1); }, [subtitleStreams, settings]); if (subtitleStreams.length === 0) return null; @@ -79,10 +88,7 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => { { - setPlaySettings((prev) => ({ - ...prev, - subtitleIndex: -1, - })); + onChange(-1); }} > None @@ -92,10 +98,7 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => { key={idx.toString()} onSelect={() => { if (subtitle.Index !== undefined && subtitle.Index !== null) - setPlaySettings((prev) => ({ - ...prev, - subtitleIndex: subtitle.Index, - })); + onChange(subtitle.Index); }} > diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx index edc88f66..8784d3a5 100644 --- a/components/music/SongsListItem.tsx +++ b/components/music/SongsListItem.tsx @@ -1,6 +1,6 @@ import { Text } from "@/components/common/Text"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { usePlayback } from "@/providers/PlaybackProvider"; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { chromecastProfile } from "@/utils/profiles/chromecast"; import ios from "@/utils/profiles/ios"; @@ -41,7 +41,7 @@ export const SongsListItem: React.FC = ({ const client = useRemoteMediaClient(); const { showActionSheetWithOptions } = useActionSheet(); - const { setCurrentlyPlayingState } = usePlayback(); + const { playSettings, setPlaySettings } = usePlaySettings(); const openSelect = () => { if (!castDevice?.deviceId) { @@ -121,9 +121,8 @@ export const SongsListItem: React.FC = ({ }); } else { console.log("Playing on device", url, item.Id); - setCurrentlyPlayingState({ + setPlaySettings({ item, - url, }); router.push("/play-music"); } diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx index 6310f2f2..f888e48e 100644 --- a/components/video-player/Controls.tsx +++ b/components/video-player/Controls.tsx @@ -55,7 +55,6 @@ interface Props { setShowControls: (shown: boolean) => void; ignoreSafeAreas?: boolean; setIgnoreSafeAreas: React.Dispatch>; - isLandscape: boolean; } export const Controls: React.FC = ({ @@ -69,7 +68,6 @@ export const Controls: React.FC = ({ cacheProgress, showControls, setShowControls, - isLandscape, ignoreSafeAreas, setIgnoreSafeAreas, }) => { @@ -259,8 +257,8 @@ export const Controls: React.FC = ({ style={[ { position: "absolute", - bottom: isLandscape ? insets.bottom + 55 : insets.bottom + 97, - right: isLandscape ? insets.right : insets.right, + bottom: insets.bottom + 97, + right: insets.right, }, ]} className={`z-10 p-4 @@ -278,8 +276,8 @@ export const Controls: React.FC = ({ = ({ )} { const router = useRouter(); - const { startDownloadedFilePlayback } = usePlayback(); + const { setPlaySettings, setPlayUrl, setOfflineSettings } = usePlaySettings(); - const openFile = useCallback( - async (item: BaseItemDto) => { - const directory = FileSystem.documentDirectory; + const openFile = useCallback(async (item: BaseItemDto) => { + const directory = FileSystem.documentDirectory; - if (!directory) { - throw new Error("Document directory is not available"); + if (!directory) { + throw new Error("Document directory is not available"); + } + + if (!item.Id) { + throw new Error("Item ID is not available"); + } + + try { + const files = await FileSystem.readDirectoryAsync(directory); + for (let f of files) { + console.log(f); + } + const path = item.Id!; + const matchingFile = files.find((file) => file.startsWith(path)); + + if (!matchingFile) { + throw new Error(`No file found for item ${path}`); } - if (!item.Id) { - throw new Error("Item ID is not available"); - } + const url = `${directory}${matchingFile}`; - try { - const files = await FileSystem.readDirectoryAsync(directory); - for (let f of files) { - console.log(f); - } - const path = item.Id!; - const matchingFile = files.find((file) => file.startsWith(path)); + setOfflineSettings({ + item, + }); + setPlayUrl(url); - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); - } - - const url = `${directory}${matchingFile}`; - - console.log("Opening " + url); - - startDownloadedFilePlayback({ - item, - url, - }); - router.push("/play"); - } catch (error) { - console.error("Error opening file:", error); - // Handle the error appropriately, e.g., show an error message to the user - } - }, - [startDownloadedFilePlayback] - ); + router.push("/play-offline-video"); + } catch (error) { + console.error("Error opening file:", error); + // Handle the error appropriately, e.g., show an error message to the user + } + }, []); return { openFile }; }; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 2c966762..9fd0c774 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -1,25 +1,24 @@ -import React, { - createContext, - useState, - useContext, - useEffect, - useCallback, -} from "react"; +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"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { settingsAtom } from "@/utils/atoms/settings"; -import { apiAtom, userAtom } from "./JellyfinProvider"; -import { useAtomValue } from "jotai"; -import iosFmp4 from "@/utils/profiles/iosFmp4"; -import native from "@/utils/profiles/native"; -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"; +import { useAtomValue } from "jotai"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { apiAtom, userAtom } from "./JellyfinProvider"; export type PlaybackType = { item?: BaseItemDto | null; @@ -32,8 +31,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>; }; const PlaySettingsContext = createContext( @@ -43,7 +44,7 @@ const PlaySettingsContext = createContext( export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const [playSettings, setPlaySettings] = useState(null); + const [playSettings, _setPlaySettings] = useState(null); const [playUrl, setPlayUrl] = useState(null); const api = useAtomValue(apiAtom); @@ -65,44 +66,56 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ [playSettings?.item?.Id, api] ); - useEffect(() => { - const fetchPlayUrl = async () => { - if (!api || !user || !settings || !playSettings) { - console.log("fetchPlayUrl ~ missing params"); - setPlayUrl(null); - return; - } + const setOfflineSettings = useCallback((data: PlaybackType) => { + _setPlaySettings(data); + }, []); - console.log("fetchPlayUrl ~ fetching url", playSettings?.item?.Id); + const setPlaySettings = useCallback( + async ( + dataOrUpdater: + | PlaybackType + | null + | ((prev: PlaybackType | null) => PlaybackType | null) + ) => { + _setPlaySettings((prevSettings) => { + const newSettings = + typeof dataOrUpdater === "function" + ? dataOrUpdater(prevSettings) + : dataOrUpdater; - // Determine the device profile - let deviceProfile: any = ios; - if (settings?.deviceProfile === "Native") deviceProfile = native; - if (settings?.deviceProfile === "Old") deviceProfile = old; + if (!api || !user || !settings || newSettings === null) { + return newSettings; + } - const url = await getStreamUrl({ - api, - deviceProfile, - item: playSettings?.item, - mediaSourceId: playSettings?.mediaSource?.Id, - startTimeTicks: 0, - maxStreamingBitrate: playSettings?.bitrate?.value, - audioStreamIndex: playSettings?.audioIndex - ? playSettings?.audioIndex - : 0, - subtitleStreamIndex: playSettings?.subtitleIndex - ? playSettings?.subtitleIndex - : -1, - userId: user.Id, - forceDirectPlay: false, - sessionData: null, + let deviceProfile: any = ios; + if (settings?.deviceProfile === "Native") deviceProfile = native; + if (settings?.deviceProfile === "Old") deviceProfile = old; + + getStreamUrl({ + api, + deviceProfile, + item: newSettings?.item, + mediaSourceId: newSettings?.mediaSource?.Id, + startTimeTicks: 0, + maxStreamingBitrate: newSettings?.bitrate?.value, + audioStreamIndex: newSettings?.audioIndex + ? newSettings?.audioIndex + : 0, + subtitleStreamIndex: newSettings?.subtitleIndex + ? newSettings?.subtitleIndex + : -1, + userId: user.Id, + forceDirectPlay: false, + sessionData: null, + }).then((url) => { + if (url) setPlayUrl(url); + }); + + return newSettings; }); - - setPlayUrl(url); - }; - - fetchPlayUrl(); - }, [api, settings, user, playSettings]); + }, + [api, user, settings, setPlayUrl] + ); useEffect(() => { let deviceProfile: any = ios; @@ -130,7 +143,14 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ return ( {children} diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx deleted file mode 100644 index 37286540..00000000 --- a/providers/PlaybackProvider.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import React, { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; - -import { useSettings } from "@/utils/atoms/settings"; -import { getDeviceId } from "@/utils/device"; -import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles"; -import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; -import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; -import { postCapabilities } from "@/utils/jellyfin/session/capabilities"; -import { - BaseItemDto, - PlaybackInfoResponse, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import * as Linking from "expo-linking"; -import { useRouter } from "expo-router"; -import { useAtom } from "jotai"; -import { debounce } from "lodash"; -import { Alert } from "react-native"; -import { OnProgressData, type VideoRef } from "react-native-video"; -import { apiAtom, userAtom } from "./JellyfinProvider"; - -export type CurrentlyPlayingState = { - url: string; - item: BaseItemDto; -}; - -interface PlaybackContextType { - sessionData: PlaybackInfoResponse | null | undefined; - currentlyPlaying: CurrentlyPlayingState | null; - videoRef: React.MutableRefObject; - isPlaying: boolean; - isFullscreen: boolean; - progressTicks: number | null; - playVideo: (triggerRef?: boolean) => void; - pauseVideo: (triggerRef?: boolean) => void; - stopPlayback: () => void; - presentFullscreenPlayer: () => void; - dismissFullscreenPlayer: () => void; - setIsFullscreen: (isFullscreen: boolean) => void; - setIsPlaying: (isPlaying: boolean) => void; - isBuffering: boolean; - setIsBuffering: (val: boolean) => void; - onProgress: (data: OnProgressData) => void; - setVolume: (volume: number) => void; - setCurrentlyPlayingState: ( - currentlyPlaying: CurrentlyPlayingState | null - ) => void; - startDownloadedFilePlayback: ( - currentlyPlaying: CurrentlyPlayingState | null - ) => void; - subtitles: SubtitleTrack[]; -} - -const PlaybackContext = createContext(null); - -export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const router = useRouter(); - - const videoRef = useRef(null); - - const [settings] = useSettings(); - - const previousVolume = useRef(null); - - const [isPlaying, _setIsPlaying] = useState(false); - const [isBuffering, setIsBuffering] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const [progressTicks, setProgressTicks] = useState(0); - const [volume, _setVolume] = useState(null); - const [session, setSession] = useState(null); - const [subtitles, setSubtitles] = useState([]); - const [currentlyPlaying, setCurrentlyPlaying] = - useState(null); - - // WS - const [ws, setWs] = useState(null); - const [isConnected, setIsConnected] = useState(false); - - const setVolume = useCallback( - (newVolume: number) => { - previousVolume.current = volume; - _setVolume(newVolume); - videoRef.current?.setVolume(newVolume); - }, - [_setVolume] - ); - - const { data: deviceId } = useQuery({ - queryKey: ["deviceId", api], - queryFn: getDeviceId, - }); - - const startDownloadedFilePlayback = useCallback( - async (state: CurrentlyPlayingState | null) => { - if (!state) { - setCurrentlyPlaying(null); - setIsPlaying(false); - return; - } - - setCurrentlyPlaying(state); - setIsPlaying(true); - }, - [] - ); - - const setCurrentlyPlayingState = useCallback( - async (state: CurrentlyPlayingState | null) => { - try { - if (state?.item.Id && user?.Id) { - const vlcLink = "vlc://" + state?.url; - if (vlcLink && settings?.openInVLC) { - Linking.openURL("vlc://" + state?.url || ""); - return; - } - - // Support live tv - const res = - state.item.Type !== "Program" - ? await getMediaInfoApi(api!).getPlaybackInfo({ - itemId: state.item.Id, - userId: user.Id, - }) - : await getMediaInfoApi(api!).getPlaybackInfo({ - itemId: state.item.ChannelId!, - userId: user.Id, - }); - - await postCapabilities({ - api, - itemId: state.item.Id, - sessionId: res.data.PlaySessionId, - deviceProfile: settings?.deviceProfile, - }); - - setSession(res.data); - setCurrentlyPlaying(state); - setIsPlaying(true); - } else { - setCurrentlyPlaying(null); - setIsFullscreen(false); - setIsPlaying(false); - } - } catch (e) { - console.error(e); - Alert.alert( - "Something went wrong", - "The item could not be played. Maybe there is no internet connection?", - [ - { - style: "destructive", - text: "Try force play", - onPress: () => { - setCurrentlyPlaying(state); - setIsPlaying(true); - }, - }, - { - text: "Ok", - style: "default", - }, - ] - ); - } - }, - [settings, user, api] - ); - - const playVideo = useCallback( - (triggerRef: boolean = true) => { - if (triggerRef === true) { - videoRef.current?.resume(); - } - _setIsPlaying(true); - reportPlaybackProgress({ - api, - itemId: currentlyPlaying?.item.Id, - positionTicks: progressTicks ? progressTicks : 0, - sessionId: session?.PlaySessionId, - IsPaused: false, - }); - }, - [api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks] - ); - - const pauseVideo = useCallback( - (triggerRef: boolean = true) => { - if (triggerRef === true) { - videoRef.current?.pause(); - } - _setIsPlaying(false); - reportPlaybackProgress({ - api, - itemId: currentlyPlaying?.item.Id, - positionTicks: progressTicks ? progressTicks : 0, - sessionId: session?.PlaySessionId, - IsPaused: true, - }); - }, - [session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks] - ); - - const stopPlayback = useCallback(async () => { - const id = currentlyPlaying?.item?.Id; - setCurrentlyPlayingState(null); - - await reportPlaybackStopped({ - api, - itemId: id, - sessionId: session?.PlaySessionId, - positionTicks: progressTicks ? progressTicks : 0, - }); - }, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]); - - const setIsPlaying = useCallback( - debounce((value: boolean) => { - _setIsPlaying(value); - }, 500), - [] - ); - - const _onProgress = useCallback( - ({ currentTime }: OnProgressData) => { - if ( - !session?.PlaySessionId || - !currentlyPlaying?.item.Id || - currentTime === 0 - ) - return; - const ticks = currentTime * 10000000; - setProgressTicks(ticks); - reportPlaybackProgress({ - api, - itemId: currentlyPlaying?.item.Id, - positionTicks: ticks, - sessionId: session?.PlaySessionId, - IsPaused: !isPlaying, - }); - }, - [session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api] - ); - - const onProgress = useCallback( - debounce((e: OnProgressData) => { - _onProgress(e); - }, 500), - [_onProgress] - ); - - const presentFullscreenPlayer = useCallback(() => { - videoRef.current?.presentFullscreenPlayer(); - setIsFullscreen(true); - }, []); - - const dismissFullscreenPlayer = useCallback(() => { - videoRef.current?.dismissFullscreenPlayer(); - setIsFullscreen(false); - }, []); - - 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); - // Start sending "KeepAlive" message every 30 seconds - 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); - - // On PlayPause - 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 (command === "Mute") { - console.log("Command ~ Mute"); - setVolume(0); - } else if (command === "Unmute") { - console.log("Command ~ Unmute"); - setVolume(previousVolume.current || 20); - } else if (command === "SetVolume") { - console.log("Command ~ SetVolume"); - } 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]); - - return ( - - {children} - - ); -}; - -export const usePlayback = () => { - const context = useContext(PlaybackContext); - - if (!context) { - throw new Error("usePlayback must be used within a PlaybackProvider"); - } - - return context; -};