diff --git a/app/(auth)/player/google-cast-player.tsx b/app/(auth)/player/google-cast-player.tsx
index 29eb2626..b0cfd068 100644
--- a/app/(auth)/player/google-cast-player.tsx
+++ b/app/(auth)/player/google-cast-player.tsx
@@ -1,4 +1,4 @@
-import React, { useRef, useState } from "react";
+import React, { useMemo, useRef, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -6,13 +6,30 @@ 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 GoogleCast, {
+ CastButton,
+ CastContext,
+ CastState,
+ MediaInfo,
+ 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 {
+ runOnJS,
+ useAnimatedReaction,
+ useSharedValue,
+} from "react-native-reanimated";
import { debounce } from "lodash";
import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "@/hooks/useHaptic";
@@ -20,472 +37,622 @@ import { writeToLog } from "@/utils/log";
import { formatTimeString } from "@/utils/time";
import { BlurView } from "expo-blur";
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";
export default function Player() {
+ const castState = useCastState();
- const castState = useCastState()
+ const client = useRemoteMediaClient();
+ const castDevice = useCastDevice();
+ const devices = useDevices();
+ const sessionManager = GoogleCast.getSessionManager();
+ const discoveryManager = GoogleCast.getDiscoveryManager();
+ const mediaStatus = useMediaStatus();
- const client = useRemoteMediaClient();
- const castDevice = useCastDevice();
- const devices = useDevices();
- const sessionManager = GoogleCast.getSessionManager();
- const discoveryManager = GoogleCast.getDiscoveryManager();
- const mediaStatus = useMediaStatus();
+ const router = useRouter();
- const router = useRouter()
+ const lightHapticFeedback = useHaptic("light");
- const lightHapticFeedback = useHaptic("light");
+ useEffect(() => {
+ (async () => {
+ if (!discoveryManager) {
+ console.warn("DiscoveryManager is not initialized");
+ return;
+ }
- useEffect(() => {
- (async () => {
- if (!discoveryManager) {
- console.warn("DiscoveryManager is not initialized");
- return;
- }
+ await discoveryManager.startDiscovery();
+ })();
+ }, [client, devices, castDevice, sessionManager, discoveryManager]);
- 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]
+ );
- // Android requires the cast button to be present for startDiscovery to work
- const AndroidCastButton = useCallback(
- () =>
- Platform.OS === "android" ? (
-
- ) : (
- <>>
- ),
- [Platform.OS]
- );
+ const GoHomeButton = () => (
+
+ );
- 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 itemId = mediaStatus.mediaInfo?.contentId
-
- const type = mediaMetadata?.type || 'generic'
- const images = mediaMetadata?.images || []
-
- const { showSkipButton, skipIntro } = useIntroSkipper(
- itemId,
- currentTime,
- seek,
- play,
- false
- );
-
- const { showSkipCreditButton, skipCredit } = useCreditSkipper(
- itemId,
- currentTime,
- seek,
- play,
- false
- );
-
- 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[';
-
- const ItemInfo = useMemo(() => {
- switch(type) {
- case 'generic': return
- case 'movie': return
- case 'tvShow': return
- default: return {type} not implemented yet!
- }
- }, [type])
-
- return (
-
-
-
-
-
- {ItemInfo}
-
-
-
-
-
-
-
-
-
-
-
- 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 ? (
-
- ) : (
-
- )}
-
-
-
-
- { }} >
-
-
-
-
-
-
-
+ 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 ;
}
-type MetadataInfoProps = { mediaMetadata: MediaInfo['metadata'] }
+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";
+
+ // request update of media status every player state change
+ useEffect(() => {
+ client?.requestStatus();
+ }, [mediaStatus.playerState]);
+
+ // update progess on stream position change
+ useEffect(() => {
+ if (streamPosition) progress.value = streamPosition;
+ }, [streamPosition]);
+
+ // update max progress
+ useEffect(() => {
+ if (mediaStatus.mediaInfo?.streamDuration)
+ max.value = mediaStatus.mediaInfo?.streamDuration;
+ }, [mediaStatus.mediaInfo?.streamDuration]);
+
+ const updateTimes = useCallback(
+ (currentProgress: number, maxValue: number) => {
+ setCurrentTime(progress.value);
+ setRemainingTime(max.value - progress.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
+ 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 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 itemId = mediaStatus.mediaInfo?.contentId;
+
+ const type = mediaMetadata?.type || "generic";
+ const images = mediaMetadata?.images || [];
+
+ const item: BaseItemDto | undefined = mediaStatus.mediaInfo?.customData;
+
+ const { previousItem, nextItem } = useAdjacentItems({
+ item: {
+ Id: itemId,
+ SeriesId: item?.SeriesId,
+ Type: item?.Type,
+ },
+ });
+
+ const {
+ trickPlayUrl,
+ calculateTrickplayUrl,
+ trickplayInfo,
+ prefetchAllTrickplayImages,
+ } = useTrickplay(
+ {
+ Id: itemId,
+ RunTimeTicks: secondsToTicks(progress.value),
+ Trickplay: item?.Trickplay,
+ },
+ true
+ );
+
+ useEffect(() => {
+ prefetchAllTrickplayImages();
+ }, []);
+
+ const goToNextItem = () => {
+ console.warn("go to next item not implemented yet");
+ };
+ const goToPreviousItem = () => {
+ console.warn("go to previous item not implemented yet");
+ };
+
+ const { showSkipButton, skipIntro } = useIntroSkipper(
+ itemId,
+ currentTime,
+ seek,
+ play,
+ false
+ );
+
+ const { showSkipCreditButton, skipCredit } = useCreditSkipper(
+ itemId,
+ currentTime,
+ seek,
+ play,
+ false
+ );
+
+ 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]);
+
+ 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[";
+
+ const ItemInfo = useMemo(() => {
+ switch (type) {
+ case "generic":
+ return ;
+ case "movie":
+ return ;
+ case "tvShow":
+ return ;
+ default:
+ return {type} not implemented yet!;
+ }
+ }, [type]);
+
+ // Android requires the cast button to be present for startDiscovery to work
+ const AndroidCastButton = useCallback(
+ () =>
+ Platform.OS === "android" ? (
+
+ ) : (
+ <>>
+ ),
+ [Platform.OS]
+ );
+
+ return (
+
+
+
+
+ {ItemInfo}
+ {
+ CastContext.showCastDialog();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ null}
+ onSlidingStart={handleSliderStart}
+ onSlidingComplete={handleSliderComplete}
+ onValueChange={handleSliderChange}
+ containerStyle={{
+ borderRadius: 100,
+ }}
+ renderBubble={() => isSliding && memoizedRenderBubble()}
+ 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 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type MetadataInfoProps = { mediaMetadata: MediaInfo["metadata"] };
function GenericInfo({ mediaMetadata }: MetadataInfoProps) {
+ const title = mediaMetadata?.title || "Title not found!";
- const title = mediaMetadata?.title || 'Title not found!'
-
- return (
- <>
- {title}
- {
- // @ts-expect-error The metadata type doesn't have subtitle, but the object has
- mediaMetadata?.subtitle && {mediaMetadata?.subtitle}
- }
- >
- )
+ return (
+ <>
+ {title}
+ {
+ // @ts-expect-error The metadata type doesn't have subtitle, but the object has
+ mediaMetadata?.subtitle && {mediaMetadata?.subtitle}
+ }
+ >
+ );
}
function MovieInfo({ mediaMetadata }: MetadataInfoProps) {
+ const title = mediaMetadata?.title || "Title not found!";
- const title = mediaMetadata?.title || 'Title not found!'
-
- return (
- <>
- {title}
- {
- // @ts-expect-error The metadata type doesn't have subtitle, but the object has
- mediaMetadata?.subtitle && {mediaMetadata?.subtitle}
- }
- >
- )
+ return (
+ <>
+ {title}
+ {
+ // @ts-expect-error The metadata type doesn't have subtitle, but the object has
+ mediaMetadata?.subtitle && {mediaMetadata?.subtitle}
+ }
+ >
+ );
}
function TvShowInfo({ mediaMetadata }: MetadataInfoProps) {
+ const itemTitle: string = mediaMetadata?.title || "Title not found!";
+ // @ts-expect-error
+ const seriesTitle: string = mediaMetadata?.seriesTitle || "Title not found!";
- const itemTitle: string = mediaMetadata?.title || 'Title not found!'
- // @ts-expect-error
- const seriesTitle: string = mediaMetadata?.seriesTitle || 'Title not found!'
+ // @ts-expect-error
+ const episodeNumber: number = mediaMetadata?.episodeNumber || 0;
+ // @ts-expect-error
+ const seasonNumber: number = mediaMetadata?.seasonNumber || 0;
- // @ts-expect-error
- const episodeNumber: number = mediaMetadata?.episodeNumber || 0
- // @ts-expect-error
- const seasonNumber: number = mediaMetadata?.seasonNumber || 0
-
- return (
- <>
-
- {seriesTitle}
- {itemTitle}
-
-
- Season {seasonNumber.toLocaleString()} {' '}
- Episode {episodeNumber.toLocaleString()}
-
- >
- )
-}
\ No newline at end of file
+ return (
+ <>
+
+ {seriesTitle}
+ {itemTitle}
+
+
+ Season {seasonNumber.toLocaleString()} Episode{" "}
+ {episodeNumber.toLocaleString()}
+
+ >
+ );
+}