import React, { useMemo, useRef, useState } from "react"; import { Alert, TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Feather, Ionicons } from "@expo/vector-icons"; import { RoundButton } from "@/components/RoundButton"; import { CastButton, CastContext, MediaStatus, RemoteMediaClient, useStreamPosition, } from "react-native-google-cast"; import { useCallback, useEffect } from "react"; import { Platform } from "react-native"; import { Image } from "expo-image"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { debounce } from "lodash"; import { useSettings } from "@/utils/atoms/settings"; import { useHaptic } from "@/hooks/useHaptic"; import { writeToLog } from "@/utils/log"; import { formatTimeString } from "@/utils/time"; 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"; import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia"; import { useAtomValue } from "jotai"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast"; import { SelectedOptions } from "./ItemContent"; import { getDefaultPlaySettings, previousIndexes, } from "@/utils/jellyfin/getDefaultPlaySettings"; import { useQuery } from "@tanstack/react-query"; import { getPlaystateApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { useTranslation } from "react-i18next"; import { Colors } from "@/constants/Colors"; import { useRouter } from "expo-router"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ItemImage } from "@/components/common/ItemImage"; import { BitrateSelector } from "@/components/BitrateSelector"; import { ItemHeader } from "@/components/ItemHeader"; import { MediaSourceSelector } from "@/components/MediaSourceSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails"; import { OverviewText } from "@/components/OverviewText"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { PlayedStatus } from "./PlayedStatus"; import { AddToFavorites } from "./AddToFavorites"; export default function ChromecastControls({ mediaStatus, client, setWasMediaPlaying, reportPlaybackStopedRef, }: { mediaStatus: MediaStatus; client: RemoteMediaClient; setWasMediaPlaying: (wasPlaying: boolean) => void; reportPlaybackStopedRef: React.MutableRefObject<() => void>; }) { const lightHapticFeedback = useHaptic("light"); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const [settings] = useSettings(); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Infinity); const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0); const streamPosition = useStreamPosition(); const progress = useSharedValue(streamPosition || 0); const wasPlayingRef = useRef(false); const isSeeking = useSharedValue(false); const isPlaying = useMemo( () => mediaStatus.playerState === "playing", [mediaStatus.playerState] ); const isBufferingOrLoading = useMemo( () => mediaStatus.playerState === null || mediaStatus.playerState === "buffering" || mediaStatus.playerState === "loading", [mediaStatus.playerState] ); // request update of media status every player state change useEffect(() => { client.requestStatus(); }, [mediaStatus.playerState]); // update max progress useEffect(() => { if (mediaStatus.mediaInfo?.streamDuration) max.value = mediaStatus.mediaInfo?.streamDuration; }, [mediaStatus.mediaInfo?.streamDuration]); const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { setCurrentTime(currentProgress); setRemainingTime(maxValue - currentProgress); }, [] ); useAnimatedReaction( () => ({ progress: progress.value, max: max.value, isSeeking: isSeeking.value, }), (result) => { if (result.isSeeking === false) { runOnJS(updateTimes)(result.progress, result.max); } }, [updateTimes] ); const { mediaMetadata, itemId, streamURL } = useMemo( () => ({ mediaMetadata: mediaStatus.mediaInfo?.metadata, itemId: mediaStatus.mediaInfo?.contentId, streamURL: mediaStatus.mediaInfo?.contentUrl, }), [mediaStatus] ); const type = useMemo( () => mediaMetadata?.type || "generic", [mediaMetadata?.type] ); const images = useMemo( () => mediaMetadata?.images || [], [mediaMetadata?.images] ); const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => { const mediaCustomData = mediaStatus.mediaInfo?.customData as | { playbackOptions: SelectedOptions; sessionId?: string; mediaSourceId?: string; } | undefined; return ( mediaCustomData || { playbackOptions: undefined, sessionId: undefined, mediaSourceId: undefined, } ); }, [mediaStatus.mediaInfo?.customData]); const { data: item, // currently nothing is indicating that item is loading, because most of the time it loads very fast isLoading: isLoadingItem, isError: isErrorItem, error, refetch, } = useQuery({ queryKey: ["item", itemId], queryFn: async () => { if (!itemId) return; const res = await getUserLibraryApi(api!).getItem({ itemId, userId: user?.Id, }); return res.data; }, enabled: !!itemId, staleTime: 0, }); const onProgress = useCallback( async (progressInTicks: number, isPlaying: boolean) => { if (!item?.Id || !streamURL) return; await getPlaystateApi(api!).onPlaybackProgress({ itemId: item.Id, audioStreamIndex: playbackOptions?.audioIndex, subtitleStreamIndex: playbackOptions?.subtitleIndex, mediaSourceId, positionTicks: Math.floor(progressInTicks), isPaused: !isPlaying, playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: sessionId, }); }, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId] ); // update progess on stream position change useEffect(() => { if (streamPosition) { progress.value = streamPosition; onProgress(secondsToTicks(streamPosition), isPlaying); } }, [streamPosition, isPlaying]); const reportPlaybackStart = useCallback(async () => { if (!streamURL) return; await getPlaystateApi(api!).onPlaybackStart({ itemId: item?.Id!, audioStreamIndex: playbackOptions?.audioIndex, subtitleStreamIndex: playbackOptions?.subtitleIndex, mediaSourceId, playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: sessionId, }); }, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]); // report playback started useEffect(() => { setWasMediaPlaying(true); reportPlaybackStart(); }, [reportPlaybackStart]); // update the reportPlaybackStoppedRef useEffect(() => { reportPlaybackStopedRef.current = async () => { if (!streamURL) return; await getPlaystateApi(api!).onPlaybackStopped({ itemId: item?.Id!, mediaSourceId, positionTicks: secondsToTicks(progress.value), playSessionId: sessionId, }); }; }, [ api, item, playbackOptions, progress, mediaSourceId, streamURL, sessionId, ]); const { previousItem, nextItem } = useAdjacentItems({ item: { Id: itemId, SeriesId: item?.SeriesId, Type: item?.Type, }, }); const goToItem = useCallback( async (item: BaseItemDto) => { if (!api) { console.warn("Failed to go to item: No api!"); return; } const previousIndexes: previousIndexes = { subtitleIndex: playbackOptions?.subtitleIndex || undefined, audioIndex: playbackOptions?.audioIndex || undefined, }; const { mediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings(item, settings, previousIndexes, undefined); // Get a new URL with the Chromecast device profile: const data = await getStreamUrl({ api, item, deviceProfile: chromecastProfile, startTimeTicks: item?.UserData?.PlaybackPositionTicks!, userId: user?.Id, audioStreamIndex: defaultAudioIndex, // maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit subtitleStreamIndex: defaultSubtitleIndex, mediaSourceId: mediaSource?.Id, }); if (!data?.url) { console.warn("No URL returned from getStreamUrl", data); Alert.alert("Client error", "Could not create stream for Chromecast"); return; } await chromecastLoadMedia({ client, item, contentUrl: data.url, sessionId: data.sessionId || undefined, mediaSourceId: data.mediaSource?.Id || undefined, playbackOptions, images: [ { url: getParentBackdropImageUrl({ api, item, quality: 90, width: 2000, })!, }, ], }); await client.requestStatus(); }, [client, api] ); const goToNextItem = useCallback(() => { if (!nextItem) { console.warn("Failed to skip to next item: No next item!"); return; } lightHapticFeedback(); goToItem(nextItem); }, [nextItem, lightHapticFeedback]); const goToPreviousItem = useCallback(() => { if (!previousItem) { console.warn("Failed to skip to next item: No next item!"); return; } lightHapticFeedback(); goToItem(previousItem); }, [previousItem, lightHapticFeedback]); const pause = useCallback(() => { client.pause(); }, [client]); const play = useCallback(() => { client.play(); }, [client]); const seek = useCallback( (time: number) => { // skip to next episode if seeking to end (for credit skipping) // with 1 second room to react if (nextItem && time >= max.value - 1) { goToNextItem(); return; } client.seek({ position: time, }); }, [client, goToNextItem, nextItem, max] ); const togglePlay = useCallback(() => { if (isPlaying) pause(); else play(); }, [isPlaying, play, pause]); 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 { showSkipButton, skipIntro } = useIntroSkipper( itemId, currentTime, seek, play, false ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( itemId, currentTime, seek, play, false ); // Android requires the cast button to be present for startDiscovery to work const AndroidCastButton = useCallback( () => Platform.OS === "android" ? ( ) : ( <> ), [Platform.OS] ); const TrickplaySliderMemoized = useMemo( () => ( ), [ item, progress, wasPlayingRef, isPlaying, isSeeking, max, play, pause, seek, ] ); const NextEpisodeButtonMemoized = useMemo( () => ( 0 && remainingTime < 10} onFinish={goToNextItem} onPress={goToNextItem} /> ), [nextItem, max, remainingTime, goToNextItem] ); const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const [loadingLogo, setLoadingLogo] = useState(true); const [headerHeight, setHeaderHeight] = useState(350); const logoUrl = useMemo(() => images[0]?.url, [images]); if (isErrorItem) { return ( {t("chromecast.error_loading_item")} {error && ( {error.message} )} refetch()} > {t("chromecast.retry_load_item")} { router.push("/(auth)/(home)/"); }} > {t("chromecast.go_home")} ); } if (!item) { return Do something when item is undefined; } if (!playbackOptions) { return Do something when playbackOptions is undefined; } return ( {/* TODO do navigation header properly */} {item.Type !== "Program" && ( { CastContext.showCastDialog(); }} > )} } logo={ <> {logoUrl ? ( setLoadingLogo(false)} onError={() => setLoadingLogo(false)} /> ) : null} } > {item.Type !== "Program" && !Platform.isTV && ( // setSelectedOptions( // (prev) => prev && { ...prev, bitrate: val } // ) console.log("new selected options", val) } selected={playbackOptions.bitrate} /> // setSelectedOptions((prev) => // prev && { // ...prev, // mediaSource: val, // } // ) console.log("new selected options", val) } selected={playbackOptions.mediaSource} /> { // setSelectedOptions((prev) => // prev && { // ...prev, // audioIndex: val, // } // ); console.log("new selected options", val); }} selected={playbackOptions.audioIndex} /> // setSelectedOptions( // (prev) => // prev && { // ...prev, // subtitleIndex: val, // } // ) console.log("new selected options", val) } selected={playbackOptions.subtitleIndex} /> )} {TrickplaySliderMemoized} {formatTimeString(currentTime, "s")} -{formatTimeString(remainingTime, "s")} togglePlay()} className="flex w-14 h-14 items-center justify-center" > {!isBufferingOrLoading ? ( ) : ( )} {/* TODO find proper placement for these buttons */} {/* {NextEpisodeButtonMemoized} */} ); } type TrickplaySliderProps = { item?: BaseItemDto; progress: SharedValue; wasPlayingRef: React.MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; range: { min?: SharedValue; max: SharedValue }; play: () => void; pause: () => void; seek: (time: number) => void; }; function TrickplaySlider({ item, progress, wasPlayingRef, isPlaying, isSeeking, range, play, pause, seek, }: TrickplaySliderProps) { const [isSliding, setIsSliding] = useState(false); const lastProgressRef = useRef(0); const min = useSharedValue(range.min?.value || 0); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo, prefetchAllTrickplayImages, } = useTrickplay( { Id: item?.Id, RunTimeTicks: secondsToTicks(progress.value), Trickplay: item?.Trickplay, }, true ); useEffect(() => { prefetchAllTrickplayImages(); }, []); 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) => { 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 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]); return ( null} onSlidingStart={handleSliderStart} onSlidingComplete={handleSliderComplete} onValueChange={handleSliderChange} containerStyle={{ borderRadius: 100, }} renderBubble={() => isSliding && memoizedRenderBubble()} sliderHeight={10} thumbWidth={0} progress={progress} minimumValue={min} maximumValue={range.max} /> ); }