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}
+ />
+ );
+}