From 862e783de15f3d1a2cec939f281d1c77fce8133f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 6 Oct 2024 15:11:06 +0200 Subject: [PATCH] wip --- app/(auth)/play-video.tsx | 151 ++++--- components/BitrateSelector.tsx | 34 +- components/ItemContent.tsx | 18 +- components/PlayButton.tsx | 5 +- components/video-player/Controls.tsx | 624 +++++++++++++++------------ hooks/useCreditSkipper.ts | 3 + hooks/useIntroSkipper.ts | 3 + providers/PlaySettingsProvider.tsx | 6 +- 8 files changed, 484 insertions(+), 360 deletions(-) diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx index 7e8e2d17..cf7b29f2 100644 --- a/app/(auth)/play-video.tsx +++ b/app/(auth)/play-video.tsx @@ -1,31 +1,30 @@ +import { Controls } from "@/components/video-player/Controls"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { settingsAtom, 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 { Api } from "@jellyfin/sdk"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; -import { atom, useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Video, { OnProgressData, VideoRef } from "react-native-video"; -import settings from "./(tabs)/(home)/settings"; -import iosFmp4 from "@/utils/profiles/iosFmp4"; -import native from "@/utils/profiles/native"; -import old from "@/utils/profiles/old"; import { PlaybackType, usePlaySettings, } from "@/providers/PlaySettingsProvider"; -import { StatusBar, View } from "react-native"; -import React from "react"; -import { Controls } from "@/components/video-player/Controls"; +import { useSettings } from "@/utils/atoms/settings"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; -import { useSharedValue } from "react-native-reanimated"; +import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import { secondsToTicks } from "@/utils/secondsToTicks"; +import { Api } from "@jellyfin/sdk"; +import * as Haptics from "expo-haptics"; +import { useRouter } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Dimensions, Pressable, StatusBar, View } from "react-native"; +import { useSharedValue } from "react-native-reanimated"; +import Video, { OnProgressData, VideoRef } from "react-native-video"; export default function page() { const { playSettings, setPlaySettings, playUrl, reportStopPlayback } = @@ -38,24 +37,31 @@ export default function page() { const poster = usePoster(playSettings, api); const videoSource = useVideoSource(playSettings, api, poster, playUrl); + const windowDimensions = Dimensions.get("window"); + const screenDimensions = Dimensions.get("screen"); + + const [showControls, setShowControls] = useState(true); + const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isBuffering, setIsBuffering] = useState(true); + const [orientation, setOrientation] = useState( + ScreenOrientation.OrientationLock.UNKNOWN + ); const progress = useSharedValue(0); const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - useEffect(() => { - console.log("play-video ~", playUrl); - }); - if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item) return null; const togglePlay = useCallback( (ticks: number) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + console.log("togglePlay", ticks); if (isPlaying) { + setIsPlaying(false); videoRef.current?.pause(); reportPlaybackProgress({ api, @@ -65,6 +71,7 @@ export default function page() { IsPaused: true, }); } else { + setIsPlaying(true); videoRef.current?.resume(); reportPlaybackProgress({ api, @@ -78,15 +85,19 @@ export default function page() { [isPlaying, api, playSettings?.item?.Id, videoRef] ); + const play = useCallback(() => { + setIsPlaying(true); + videoRef.current?.resume(); + }, [videoRef]); + useEffect(() => { - if (!isPlaying) { - togglePlay(playSettings.item?.UserData?.PlaybackPositionTicks || 0); - } - }, [isPlaying]); + play(); + }, []); const onProgress = useCallback( (data: OnProgressData) => { if (isSeeking.value === true) return; + progress.value = secondsToTicks(data.currentTime); cacheProgress.value = secondsToTicks(data.playableDuration); setIsBuffering(data.playableDuration === 0); @@ -104,27 +115,65 @@ export default function page() { [playSettings?.item.Id, isPlaying, api] ); + useEffect(() => { + const orientationSubscription = + ScreenOrientation.addOrientationChangeListener((event) => { + setOrientation( + orientationToOrientationLock(event.orientationInfo.orientation) + ); + }); + + ScreenOrientation.getOrientationAsync().then((orientation) => { + setOrientation(orientationToOrientationLock(orientation)); + }); + + return () => { + orientationSubscription.remove(); + }; + }, []); + + const isLandscape = useMemo(() => { + return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || + orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ? true + : false; + }, [orientation]); + return ( - + ); diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 128e561d..28674c48 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,7 +1,8 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; export type Bitrate = { key: string; @@ -9,7 +10,7 @@ export type Bitrate = { height?: number; }; -const BITRATES: Bitrate[] = [ +export const BITRATES: Bitrate[] = [ { key: "Max", value: undefined, @@ -42,17 +43,11 @@ const BITRATES: Bitrate[] = [ ]; interface Props extends React.ComponentProps { - onChange: (value: Bitrate) => void; - selected: Bitrate; inverted?: boolean; } -export const BitrateSelector: React.FC = ({ - onChange, - selected, - inverted, - ...props -}) => { +export const BitrateSelector: React.FC = ({ inverted, ...props }) => { + const { setPlaySettings, playSettings } = usePlaySettings(); const sorted = useMemo(() => { if (inverted) return BITRATES.sort( @@ -63,6 +58,18 @@ export const BitrateSelector: React.FC = ({ ); }, []); + 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 ( = ({ Quality - {BITRATES.find((b) => b.value === selected.value)?.key} + {selected?.key} @@ -96,7 +103,10 @@ export const BitrateSelector: React.FC = ({ { - onChange(b); + setPlaySettings((prev) => ({ + ...prev, + bitrate: b, + })); }} > {b.key} diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 7488fdf4..fcdf5c23 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -13,13 +13,14 @@ import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom, userAtom } 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 { Image } from "expo-image"; import { useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; @@ -29,7 +30,6 @@ import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( ({ item }) => { @@ -112,14 +112,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( const insets = useSafeAreaInsets(); - // useFocusEffect( - // useCallback(() => { - // return () => { - // setPlaySettings(null); - // }; - // }, [setPlaySettings]) - // ); - return ( = React.memo( {item.Type !== "Program" && ( - setMaxBitrate(val)} - selected={maxBitrate} - /> + diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 7a0a2aff..c0a8e1ca 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -63,7 +63,10 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, [url]); const onPress = async () => { - if (!url || !item) return; + if (!url || !item) { + console.warn("No URL or item provided to PlayButton"); + return; + } if (!client) { router.push("/play-video"); return; diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx index 558a2caa..91a5f160 100644 --- a/components/video-player/Controls.tsx +++ b/components/video-player/Controls.tsx @@ -4,11 +4,7 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; @@ -16,30 +12,28 @@ 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, - Share, - TouchableOpacity, - View, -} from "react-native"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; -import { +import Animated, { runOnJS, SharedValue, useAnimatedReaction, + useAnimatedStyle, useSharedValue, + withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { OnProgressData, ReactVideoProps, VideoRef } from "react-native-video"; +import { VideoRef } from "react-native-video"; +import { Text } from "../common/Text"; import { itemRouter } from "../common/TouchableItemRouter"; import { Loader } from "../Loader"; -import { Text } from "../common/Text"; - -const windowDimensions = Dimensions.get("window"); -const screenDimensions = Dimensions.get("screen"); interface Props { item: BaseItemDto; @@ -49,6 +43,12 @@ interface Props { isSeeking: SharedValue; cacheProgress: SharedValue; progress: SharedValue; + isBuffering: boolean; + showControls: boolean; + setShowControls: (shown: boolean) => void; + ignoreSafeAreas?: boolean; + setIgnoreSafeAreas: React.Dispatch>; + isLandscape: boolean; } export const Controls: React.FC = ({ @@ -58,7 +58,13 @@ export const Controls: React.FC = ({ isPlaying, isSeeking, progress, + isBuffering, cacheProgress, + showControls, + setShowControls, + isLandscape, + ignoreSafeAreas, + setIgnoreSafeAreas, }) => { const [settings] = useSettings(); const [api] = useAtom(apiAtom); @@ -66,60 +72,62 @@ export const Controls: React.FC = ({ const segments = useSegments(); const insets = useSafeAreaInsets(); + const screenDimensions = Dimensions.get("screen"); + + const op = useSharedValue(1); + const tr = useSharedValue(10); + const animatedStyles = useAnimatedStyle(() => { + return { + opacity: op.value, + }; + }); + const animatedTopStyles = useAnimatedStyle(() => { + return { + opacity: op.value, + transform: [ + { + translateY: -tr.value, + }, + ], + }; + }); + const animatedBottomStyles = useAnimatedStyle(() => { + return { + opacity: op.value, + transform: [ + { + translateY: tr.value, + }, + ], + }; + }); + + useEffect(() => { + if (showControls || isBuffering) { + op.value = withTiming(1, { duration: 200 }); + tr.value = withTiming(0, { duration: 200 }); + } else { + op.value = withTiming(0, { duration: 200 }); + tr.value = withTiming(10, { duration: 200 }); + } + }, [showControls, isBuffering]); + const { previousItem, nextItem } = useAdjacentEpisodes({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(item); - 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 [currentTime, setCurrentTime] = useState(0); // Seconds + const [remainingTime, setRemainingTime] = useState(0); // Seconds const min = useSharedValue(0); const max = useSharedValue(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); + const remaining = ticksToSeconds(maxValue - currentProgress); setCurrentTime(current); setRemainingTime(remaining); @@ -153,13 +161,6 @@ export const Controls: React.FC = ({ [updateTimes] ); - const isLandscape = useMemo(() => { - return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || - orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ? true - : false; - }, [orientation]); - useEffect(() => { if (item) { progress.value = item?.UserData?.PlaybackPositionTicks || 0; @@ -173,6 +174,9 @@ export const Controls: React.FC = ({ progress.value = value; isSeeking.value = false; videoRef.current?.seek(value / 10000000); + setTimeout(() => { + videoRef.current?.resume(); + }, 200); }; const handleSliderChange = (value: number) => { @@ -182,7 +186,7 @@ export const Controls: React.FC = ({ const handleSliderStart = useCallback(() => { if (showControls === false) return; isSeeking.value = true; - }, []); + }, [showControls]); const handleSkipBackward = useCallback(async () => { if (!settings) return; @@ -190,6 +194,9 @@ export const Controls: React.FC = ({ const curr = await videoRef.current?.getCurrentPosition(); if (curr !== undefined) { videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); + setTimeout(() => { + videoRef.current?.resume(); + }, 200); } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); @@ -202,6 +209,9 @@ export const Controls: React.FC = ({ const curr = await videoRef.current?.getCurrentPosition(); if (curr !== undefined) { videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); + setTimeout(() => { + videoRef.current?.resume(); + }, 200); } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); @@ -222,230 +232,280 @@ export const Controls: React.FC = ({ router.push(url); }, [nextItem, from, router]); - const toggleIgnoreSafeArea = useCallback(() => { - setIgnoreSafeArea((prev) => !prev); + const toggleIgnoreSafeAreas = useCallback(() => { + setIgnoreSafeAreas((prev) => !prev); }, []); return ( - - - {(showControls || isBuffering) && ( - - )} - - {isBuffering && ( - - - - )} - - {showSkipButton && ( - - - Skip Intro - - - )} - - {showSkipCreditButton && ( - - - Skip Credits - - - )} - - - - - - { - router.back(); - }} - className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" - > - - - - - + + - - {item?.Name} - {item?.Type === "Episode" && ( - {item.SeriesName} - )} - {item?.Type === "Movie" && ( - {item?.ProductionYear} - )} - {item?.Type === "Audio" && ( - {item?.Album} - )} - - - - - - - - - - { - togglePlay(progress.value); - }} - > - - - - - - - - - - - { - if (!trickPlayUrl || !trickplayInfo) { - return null; - } - const { x, y, url } = trickPlayUrl; + Skip Intro + + - const tileWidth = 150; - const tileHeight = 150 / trickplayInfo.aspectRatio!; - return ( - - - - ); + + + Skip Credits + + + + { + toggleControls(); + }} + > + + + + + + + + + + + + { + router.back(); + }} + className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" + > + + + + + + + {item?.Name} + {item?.Type === "Episode" && ( + {item.SeriesName} + )} + {item?.Type === "Movie" && ( + {item?.ProductionYear} + )} + {item?.Type === "Audio" && ( + {item?.Album} + )} + + + + + + + + - - - {formatTimeString(currentTime)} - - - -{formatTimeString(remainingTime)} - - + + { + togglePlay(progress.value); + }} + > + + + + + + + + + + + { + 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/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index 2fb2940d..56cfe6f3 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -63,6 +63,9 @@ export const useCreditSkipper = ( if (!creditTimestamps || !videoRef.current) return; try { videoRef.current.seek(creditTimestamps.Credits.End); + setTimeout(() => { + videoRef.current?.resume(); + }, 200); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index df05462e..3def1120 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -59,6 +59,9 @@ export const useIntroSkipper = ( if (!introTimestamps || !videoRef.current) return; try { videoRef.current.seek(introTimestamps.IntroEnd); + setTimeout(() => { + videoRef.current?.resume(); + }, 200); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 1008f6cc..05769508 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -17,13 +17,14 @@ 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"; export type PlaybackType = { item?: BaseItemDto | null; mediaSource?: MediaSourceInfo | null; subtitleIndex?: number | null; audioIndex?: number | null; - quality?: any | null; + bitrate?: Bitrate | null; }; type PlaySettingsContextType = { @@ -64,6 +65,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { const fetchPlayUrl = async () => { + console.log("something changed, fetching url", playSettings?.item?.Id); if (!api || !user || !settings) { setPlayUrl(null); return; @@ -80,7 +82,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ item: playSettings?.item, mediaSourceId: playSettings?.mediaSource?.Id, startTimeTicks: 0, - maxStreamingBitrate: 0, + maxStreamingBitrate: playSettings?.bitrate?.value, audioStreamIndex: playSettings?.audioIndex ? playSettings?.audioIndex : 0,