diff --git a/components/ChromecastControls.tsx b/components/ChromecastControls.tsx new file mode 100644 index 00000000..f9a3bfbd --- /dev/null +++ b/components/ChromecastControls.tsx @@ -0,0 +1,897 @@ +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} + /> + ); +}