From 64765c1a4a3c99d48c1e6a31f8dc062836b78871 Mon Sep 17 00:00:00 2001 From: tom-heidenreich Date: Tue, 21 Jan 2025 00:58:51 +0100 Subject: [PATCH] feat: :sparkles: create custom google cast player --- app/(auth)/player/_layout.tsx | 9 + app/(auth)/player/google-cast-player.tsx | 391 +++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 app/(auth)/player/google-cast-player.tsx diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 5604160e..87912764 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -41,6 +41,15 @@ export default function Layout() { animation: "fade", }} /> + ); diff --git a/app/(auth)/player/google-cast-player.tsx b/app/(auth)/player/google-cast-player.tsx new file mode 100644 index 00000000..1db2d642 --- /dev/null +++ b/app/(auth)/player/google-cast-player.tsx @@ -0,0 +1,391 @@ +import React, { useRef, useState } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +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 { 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 { 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 { BlurView } from "expo-blur"; + +export default function Player() { + + const castState = useCastState() + + const client = useRemoteMediaClient(); + const castDevice = useCastDevice(); + const devices = useDevices(); + const sessionManager = GoogleCast.getSessionManager(); + const discoveryManager = GoogleCast.getDiscoveryManager(); + const mediaStatus = useMediaStatus(); + + const router = useRouter() + + const lightHapticFeedback = useHaptic("light"); + + useEffect(() => { + (async () => { + if (!discoveryManager) { + console.warn("DiscoveryManager is not initialized"); + return; + } + + 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] + ); + + 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 title = mediaMetadata?.title || 'Title not found!' + const type = mediaMetadata?.type || 'generic' + const images = mediaMetadata?.images || [] + + 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['; + + return ( + + + + + {title} + {mediaStatus.playerState} + + + + + + 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 ? ( + + ) : ( + + )} + + + + + { }} > + + + + + + + + ) +} \ No newline at end of file