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