diff --git a/app/(auth)/player/google-cast-player.tsx b/app/(auth)/player/google-cast-player.tsx index 29eb2626..b0cfd068 100644 --- a/app/(auth)/player/google-cast-player.tsx +++ b/app/(auth)/player/google-cast-player.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -6,13 +6,30 @@ import { Button } from "@/components/Button"; import { Feather, Ionicons } from "@expo/vector-icons"; import { RoundButton } from "@/components/RoundButton"; -import GoogleCast, { CastButton, CastContext, CastState, MediaStatus, RemoteMediaClient, useCastDevice, useCastState, useDevices, useMediaStatus, useRemoteMediaClient, useStreamPosition } from "react-native-google-cast"; +import GoogleCast, { + CastButton, + CastContext, + CastState, + MediaInfo, + MediaStatus, + RemoteMediaClient, + useCastDevice, + useCastState, + useDevices, + useMediaStatus, + useRemoteMediaClient, + useStreamPosition, +} from "react-native-google-cast"; import { useCallback, useEffect } from "react"; import { Platform } from "react-native"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { Slider } from "react-native-awesome-slider"; -import { runOnJS, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; +import { + runOnJS, + useAnimatedReaction, + useSharedValue, +} from "react-native-reanimated"; import { debounce } from "lodash"; import { useSettings } from "@/utils/atoms/settings"; import { useHaptic } from "@/hooks/useHaptic"; @@ -20,472 +37,622 @@ import { writeToLog } from "@/utils/log"; import { formatTimeString } from "@/utils/time"; import { BlurView } from "expo-blur"; import SkipButton from "@/components/video-player/controls/SkipButton"; +import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; +import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { secondsToTicks } from "@/utils/secondsToTicks"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; export default function Player() { + const castState = useCastState(); - const castState = useCastState() + const client = useRemoteMediaClient(); + const castDevice = useCastDevice(); + const devices = useDevices(); + const sessionManager = GoogleCast.getSessionManager(); + const discoveryManager = GoogleCast.getDiscoveryManager(); + const mediaStatus = useMediaStatus(); - const client = useRemoteMediaClient(); - const castDevice = useCastDevice(); - const devices = useDevices(); - const sessionManager = GoogleCast.getSessionManager(); - const discoveryManager = GoogleCast.getDiscoveryManager(); - const mediaStatus = useMediaStatus(); + const router = useRouter(); - const router = useRouter() + const lightHapticFeedback = useHaptic("light"); - const lightHapticFeedback = useHaptic("light"); + useEffect(() => { + (async () => { + if (!discoveryManager) { + console.warn("DiscoveryManager is not initialized"); + return; + } - useEffect(() => { - (async () => { - if (!discoveryManager) { - console.warn("DiscoveryManager is not initialized"); - return; - } + await discoveryManager.startDiscovery(); + })(); + }, [client, devices, castDevice, sessionManager, discoveryManager]); - await discoveryManager.startDiscovery(); - })(); - }, [client, devices, castDevice, sessionManager, discoveryManager]); + // Android requires the cast button to be present for startDiscovery to work + const AndroidCastButton = useCallback( + () => + Platform.OS === "android" ? ( + + ) : ( + <> + ), + [Platform.OS] + ); - // Android requires the cast button to be present for startDiscovery to work - const AndroidCastButton = useCallback( - () => - Platform.OS === "android" ? ( - - ) : ( - <> - ), - [Platform.OS] - ); + const GoHomeButton = () => ( + + ); - const GoHomeButton = () => ( - - ) - - if (castState === CastState.NO_DEVICES_AVAILABLE || castState === CastState.NOT_CONNECTED) { - // no devices to connect to - if (devices.length === 0) { - return ( - - - - No Google Cast devices available. - Are you on the same network? - - - - - - ) - } - // no device selected - return ( - - - - { - lightHapticFeedback(); - CastContext.showCastDialog(); - }} - > - - - - No device selected - Click icon to connect. - - - - - - ) - } - - if (castState === CastState.CONNECTING) { - return ( - - Establishing connection... - - - ) - } - - // connected, but no media playing - if (!mediaStatus) { - return ( - - - No media selected. - Start playing any media - - - - - - ) - } - - return ( - - ) -} - -function ChromecastControls({ mediaStatus, client }: { mediaStatus: MediaStatus, client: RemoteMediaClient | null }) { - const lightHapticFeedback = useHaptic("light"); - - const streamPosition = useStreamPosition() - - const [settings] = useSettings(); - - const [isSliding, setIsSliding] = useState(false) - - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(Infinity); - - const min = useSharedValue(0); - const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0); - const progress = useSharedValue(streamPosition || 0) - const isSeeking = useSharedValue(false); - - const wasPlayingRef = useRef(false); - const lastProgressRef = useRef(0); - - const isPlaying = mediaStatus.playerState === 'playing' - const isBufferingOrLoading = mediaStatus.playerState === 'buffering' || mediaStatus.playerState === 'loading' - - const updateTimes = useCallback((currentProgress: number, maxValue: number) => { - setCurrentTime(progress.value); - setRemainingTime(progress.value - max.value); - }, []); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - if (result.isSeeking === false) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes] - ); - - function pause() { - client?.pause() - } - - function play() { - client?.play() - } - - function seek(time: number) { - client?.seek({ - position: time - }) - } - - function togglePlay() { - if (isPlaying) pause() - else play() - } - - const handleSliderStart = useCallback(() => { - setIsSliding(true); - wasPlayingRef.current = isPlaying; - lastProgressRef.current = progress.value; - - pause(); - isSeeking.value = true; - }, [isPlaying]); - - const handleSliderComplete = useCallback( - async (value: number) => { - isSeeking.value = false; - progress.value = value; - setIsSliding(false); - - seek(Math.max(0, Math.floor(value))); - if (wasPlayingRef.current === true) play(); - }, - [] - ); - - const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); - const handleSliderChange = useCallback( - debounce((value: number) => { - - // TODO check if something must be done here - - const progressInSeconds = Math.floor(value); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); - }, 3), - [] - ); - - const handleSkipBackward = useCallback(async () => { - if (!settings?.rewindSkipTime) return; - wasPlayingRef.current = isPlaying; - lightHapticFeedback(); - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = Math.max(0, curr - settings.rewindSkipTime) - seek(newTime); - if (wasPlayingRef.current === true) play(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [settings, isPlaying]); - - const handleSkipForward = useCallback(async () => { - if (!settings?.forwardSkipTime) return; - wasPlayingRef.current = isPlaying; - lightHapticFeedback(); - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = curr + settings.forwardSkipTime - seek(Math.max(0, newTime)); - if (wasPlayingRef.current === true) play(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [settings, isPlaying]); - - const mediaMetadata = mediaStatus.mediaInfo?.metadata; - const itemId = mediaStatus.mediaInfo?.contentId - - const type = mediaMetadata?.type || 'generic' - const images = mediaMetadata?.images || [] - - const { showSkipButton, skipIntro } = useIntroSkipper( - itemId, - currentTime, - seek, - play, - false - ); - - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - itemId, - currentTime, - seek, - play, - false - ); - - const blurhash = '|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj['; - - const ItemInfo = useMemo(() => { - switch(type) { - case 'generic': return - case 'movie': return - case 'tvShow': return - default: return {type} not implemented yet! - } - }, [type]) - - return ( - - - - - - {ItemInfo} - - - - - - - - - - - - null} - onSlidingStart={handleSliderStart} - onSlidingComplete={handleSliderComplete} - onValueChange={handleSliderChange} - containerStyle={{ - borderRadius: 100, - }} - renderBubble={() => isSliding} - sliderHeight={10} - thumbWidth={0} - progress={progress} - minimumValue={min} - maximumValue={max} - /> - - - {formatTimeString(currentTime, "s")} - - - -{formatTimeString(remainingTime, "s")} - - - - { }} > - - - - - - togglePlay()} - className="flex w-14 h-14 items-center justify-center" - > - {!isBufferingOrLoading ? ( - - ) : ( - - )} - - - - - { }} > - - - - - - - + if ( + castState === CastState.NO_DEVICES_AVAILABLE || + castState === CastState.NOT_CONNECTED + ) { + // no devices to connect to + if (devices.length === 0) { + return ( + + + + + No Google Cast devices available. + + Are you on the same network? + + + + - ) + ); + } + // no device selected + return ( + + + + { + lightHapticFeedback(); + CastContext.showCastDialog(); + }} + > + + + + No device selected + Click icon to connect. + + + + + + ); + } + + if (castState === CastState.CONNECTING) { + return ( + + + Establishing connection... + + + + ); + } + + // connected, but no media playing + if (!mediaStatus) { + return ( + + + No media selected. + Start playing any media + + + + + + ); + } + + return ; } -type MetadataInfoProps = { mediaMetadata: MediaInfo['metadata'] } +function ChromecastControls({ + mediaStatus, + client, +}: { + mediaStatus: MediaStatus; + client: RemoteMediaClient | null; +}) { + const lightHapticFeedback = useHaptic("light"); + + const streamPosition = useStreamPosition(); + + const [settings] = useSettings(); + + const [isSliding, setIsSliding] = useState(false); + + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(Infinity); + + const min = useSharedValue(0); + const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0); + const progress = useSharedValue(streamPosition || 0); + const isSeeking = useSharedValue(false); + + const wasPlayingRef = useRef(false); + const lastProgressRef = useRef(0); + + const isPlaying = mediaStatus.playerState === "playing"; + const isBufferingOrLoading = + mediaStatus.playerState === "buffering" || + mediaStatus.playerState === "loading"; + + // request update of media status every player state change + useEffect(() => { + client?.requestStatus(); + }, [mediaStatus.playerState]); + + // update progess on stream position change + useEffect(() => { + if (streamPosition) progress.value = streamPosition; + }, [streamPosition]); + + // update max progress + useEffect(() => { + if (mediaStatus.mediaInfo?.streamDuration) + max.value = mediaStatus.mediaInfo?.streamDuration; + }, [mediaStatus.mediaInfo?.streamDuration]); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + setCurrentTime(progress.value); + setRemainingTime(max.value - progress.value); + }, + [] + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (result.isSeeking === false) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes] + ); + + function pause() { + client?.pause(); + } + + function play() { + client?.play(); + } + + function seek(time: number) { + client?.seek({ + position: time, + }); + } + + function togglePlay() { + if (isPlaying) pause(); + else play(); + } + + const handleSliderStart = useCallback(() => { + setIsSliding(true); + wasPlayingRef.current = isPlaying; + lastProgressRef.current = progress.value; + + pause(); + isSeeking.value = true; + }, [isPlaying]); + + const handleSliderComplete = useCallback(async (value: number) => { + isSeeking.value = false; + progress.value = value; + setIsSliding(false); + + seek(Math.max(0, Math.floor(value))); + if (wasPlayingRef.current === true) play(); + }, []); + + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + const handleSliderChange = useCallback( + debounce((value: number) => { + // TODO check if something must be done here + calculateTrickplayUrl(secondsToTicks(value)); + const progressInSeconds = Math.floor(value); + const hours = Math.floor(progressInSeconds / 3600); + const minutes = Math.floor((progressInSeconds % 3600) / 60); + const seconds = progressInSeconds % 60; + setTime({ hours, minutes, seconds }); + }, 3), + [] + ); + + const handleSkipBackward = useCallback(async () => { + if (!settings?.rewindSkipTime) return; + wasPlayingRef.current = isPlaying; + lightHapticFeedback(); + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = Math.max(0, curr - settings.rewindSkipTime); + seek(newTime); + if (wasPlayingRef.current === true) play(); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings, isPlaying]); + + const handleSkipForward = useCallback(async () => { + if (!settings?.forwardSkipTime) return; + wasPlayingRef.current = isPlaying; + lightHapticFeedback(); + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = curr + settings.forwardSkipTime; + seek(Math.max(0, newTime)); + if (wasPlayingRef.current === true) play(); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings, isPlaying]); + + const mediaMetadata = mediaStatus.mediaInfo?.metadata; + const itemId = mediaStatus.mediaInfo?.contentId; + + const type = mediaMetadata?.type || "generic"; + const images = mediaMetadata?.images || []; + + const item: BaseItemDto | undefined = mediaStatus.mediaInfo?.customData; + + const { previousItem, nextItem } = useAdjacentItems({ + item: { + Id: itemId, + SeriesId: item?.SeriesId, + Type: item?.Type, + }, + }); + + const { + trickPlayUrl, + calculateTrickplayUrl, + trickplayInfo, + prefetchAllTrickplayImages, + } = useTrickplay( + { + Id: itemId, + RunTimeTicks: secondsToTicks(progress.value), + Trickplay: item?.Trickplay, + }, + true + ); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, []); + + const goToNextItem = () => { + console.warn("go to next item not implemented yet"); + }; + const goToPreviousItem = () => { + console.warn("go to previous item not implemented yet"); + }; + + const { showSkipButton, skipIntro } = useIntroSkipper( + itemId, + currentTime, + seek, + play, + false + ); + + const { showSkipCreditButton, skipCredit } = useCreditSkipper( + itemId, + currentTime, + seek, + play, + false + ); + + const memoizedRenderBubble = useCallback(() => { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + const { x, y, url } = trickPlayUrl; + const tileWidth = 150; + const tileHeight = 150 / trickplayInfo.aspectRatio!; + + return ( + + + + + + {`${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} + + + ); + }, [trickPlayUrl, trickplayInfo, time]); + + const blurhash = + "|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj["; + + const ItemInfo = useMemo(() => { + switch (type) { + case "generic": + return ; + case "movie": + return ; + case "tvShow": + return ; + default: + return {type} not implemented yet!; + } + }, [type]); + + // Android requires the cast button to be present for startDiscovery to work + const AndroidCastButton = useCallback( + () => + Platform.OS === "android" ? ( + + ) : ( + <> + ), + [Platform.OS] + ); + + return ( + + + + + {ItemInfo} + { + CastContext.showCastDialog(); + }} + > + + + + + + + + + + + + + + + null} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={{ + borderRadius: 100, + }} + renderBubble={() => isSliding && memoizedRenderBubble()} + sliderHeight={10} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={max} + /> + + + {formatTimeString(currentTime, "s")} + + + -{formatTimeString(remainingTime, "s")} + + + + + + + + + + togglePlay()} + className="flex w-14 h-14 items-center justify-center" + > + {!isBufferingOrLoading ? ( + + ) : ( + + )} + + + + + + + + + + + + + + ); +} + +type MetadataInfoProps = { mediaMetadata: MediaInfo["metadata"] }; function GenericInfo({ mediaMetadata }: MetadataInfoProps) { + const title = mediaMetadata?.title || "Title not found!"; - const title = mediaMetadata?.title || 'Title not found!' - - return ( - <> - {title} - { - // @ts-expect-error The metadata type doesn't have subtitle, but the object has - mediaMetadata?.subtitle && {mediaMetadata?.subtitle} - } - - ) + return ( + <> + {title} + { + // @ts-expect-error The metadata type doesn't have subtitle, but the object has + mediaMetadata?.subtitle && {mediaMetadata?.subtitle} + } + + ); } function MovieInfo({ mediaMetadata }: MetadataInfoProps) { + const title = mediaMetadata?.title || "Title not found!"; - const title = mediaMetadata?.title || 'Title not found!' - - return ( - <> - {title} - { - // @ts-expect-error The metadata type doesn't have subtitle, but the object has - mediaMetadata?.subtitle && {mediaMetadata?.subtitle} - } - - ) + return ( + <> + {title} + { + // @ts-expect-error The metadata type doesn't have subtitle, but the object has + mediaMetadata?.subtitle && {mediaMetadata?.subtitle} + } + + ); } function TvShowInfo({ mediaMetadata }: MetadataInfoProps) { + const itemTitle: string = mediaMetadata?.title || "Title not found!"; + // @ts-expect-error + const seriesTitle: string = mediaMetadata?.seriesTitle || "Title not found!"; - const itemTitle: string = mediaMetadata?.title || 'Title not found!' - // @ts-expect-error - const seriesTitle: string = mediaMetadata?.seriesTitle || 'Title not found!' + // @ts-expect-error + const episodeNumber: number = mediaMetadata?.episodeNumber || 0; + // @ts-expect-error + const seasonNumber: number = mediaMetadata?.seasonNumber || 0; - // @ts-expect-error - const episodeNumber: number = mediaMetadata?.episodeNumber || 0 - // @ts-expect-error - const seasonNumber: number = mediaMetadata?.seasonNumber || 0 - - return ( - <> - - {seriesTitle} - {itemTitle} - - - Season {seasonNumber.toLocaleString()} {' '} - Episode {episodeNumber.toLocaleString()} - - - ) -} \ No newline at end of file + return ( + <> + + {seriesTitle} + {itemTitle} + + + Season {seasonNumber.toLocaleString()} Episode{" "} + {episodeNumber.toLocaleString()} + + + ); +}