This commit is contained in:
Fredrik Burmester
2024-10-06 13:03:16 +02:00
parent cc242a971f
commit 0233862fc1
13 changed files with 941 additions and 391 deletions

View File

@@ -1,56 +1,60 @@
import { TouchableOpacity, View } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
}
interface Props extends ViewProps {}
export const AudioTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
...props
}) => {
export const AudioTrackSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
const [settings] = useSettings();
const selectedIndex = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
() =>
playSettings?.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio"
),
[playSettings?.mediaSource]
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
const selectedAudioStream = useMemo(
() => audioStreams?.find((x) => x.Index === selectedIndex),
[audioStreams, selectedIndex]
);
// Set default audio stream only if none is selected and we have audio streams
useEffect(() => {
const defaultAudioIndex = audioStreams?.find(
if (playSettings?.audioIndex !== undefined || !audioStreams?.length) return;
const defaultAudioIndex = audioStreams.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings]);
if (defaultAudioIndex !== undefined) {
setPlaySettings((prev) => ({
...prev,
audioIndex: defaultAudioIndex,
}));
} else {
const index = playSettings?.mediaSource?.DefaultAudioStreamIndex ?? 0;
setPlaySettings((prev) => ({
...prev,
audioIndex: index,
}));
}
}, [
audioStreams,
settings?.defaultAudioLanguage,
playSettings?.mediaSource,
setPlaySettings,
]);
return (
<View
@@ -65,7 +69,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
{selectedAudioStream?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
@@ -85,7 +89,10 @@ export const AudioTrackSelector: React.FC<Props> = ({
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
setPlaySettings((prev) => ({
...prev,
audioIndex: audio.Index,
}));
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -15,21 +15,11 @@ import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
@@ -39,20 +29,18 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(-1);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
@@ -99,6 +87,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
),
});
setPlaySettings((prev) => ({
...prev,
item,
}));
}, [item]);
useEffect(() => {
@@ -111,87 +104,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
else headerHeightRef.current = 400;
}, [item, orientation]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item.Id],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) {
return null;
}
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo(
{
itemId: item.Id,
userId: user?.Id,
},
{
method: "POST",
}
);
return playbackData.data;
},
enabled: !!item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item.Id,
maxBitrate,
castDevice?.deviceId,
selectedMediaSource?.Id,
selectedAudioStream,
selectedSubtitleStream,
settings,
sessionData?.PlaySessionId,
],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
if (
item.Type !== "Program" &&
(!sessionData || !selectedMediaSource?.Id)
) {
return null;
}
let deviceProfile: any = iosFmp4;
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
console.log("playbackUrl...");
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
height: maxBitrate.height,
mediaSourceId: selectedMediaSource?.Id,
});
console.info("Stream URL:", url);
return url;
},
enabled: !!api && !!user?.Id && !!item.Id,
staleTime: 0,
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
@@ -200,6 +112,14 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const insets = useSafeAreaInsets();
// useFocusEffect(
// useCallback(() => {
// return () => {
// setPlaySettings(null);
// };
// }, [setPlaySettings])
// );
return (
<View
className="flex-1 relative"
@@ -256,31 +176,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
<MediaSourceSelector className="mr-1" />
<AudioTrackSelector className="mr-1" />
<SubtitleTrackSelector />
</View>
)}
<PlayButton item={item} url={playbackUrl} className="grow" />
<PlayButton item={item} url={playUrl} className="grow" />
</View>
{item.Type === "Episode" && (

View File

@@ -1,41 +1,43 @@
import { tc } from "@/utils/textTools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected: MediaSourceInfo | null;
}
interface Props extends React.ComponentProps<typeof View> {}
export const MediaSourceSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const mediaSources = useMemo(() => {
return item.MediaSources;
}, [item]);
export const MediaSourceSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
const selectedMediaSource = useMemo(
() =>
mediaSources
?.find((x) => x.Id === selected?.Id)
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
[mediaSources, selected]
);
const selectedMediaSource = useMemo(() => {
console.log(
"selectedMediaSource",
playSettings?.mediaSource?.MediaStreams?.length
);
return (
playSettings?.mediaSource?.MediaStreams?.find((x) => x.Type === "Video")
?.DisplayTitle || "N/A"
);
}, [playSettings?.mediaSource]);
// Set default media source on component mount
useEffect(() => {
if (mediaSources?.length) onChange(mediaSources[0]);
}, [mediaSources]);
if (
playSettings?.item?.MediaSources?.length &&
!playSettings?.mediaSource
) {
console.log(
"Setting default media source",
playSettings?.item?.MediaSources?.[0].Id
);
setPlaySettings((prev) => ({
...prev,
mediaSource: playSettings?.item?.MediaSources?.[0],
}));
}
}, [playSettings?.item?.MediaSources, setPlaySettings]);
const name = (name?: string | null) => {
if (name && name.length > 40)
@@ -71,11 +73,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
{playSettings?.item?.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
setPlaySettings((prev) => ({
...prev,
mediaSource: source,
}));
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -65,8 +65,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
router.push("/play");
router.push("/play-video");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
@@ -163,8 +162,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
});
break;
case 1:
setCurrentlyPlayingState({ item, url });
router.push("/play");
router.push("/play-video");
break;
case cancelButtonIndex:
break;

View File

@@ -1,55 +1,46 @@
import { TouchableOpacity, View } from "react-native";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { tc } from "@/utils/textTools";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
}
interface Props extends ViewProps {}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
...props
}) => {
export const SubtitleTrackSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
const [settings] = useSettings();
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
() =>
playSettings?.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle"
) ?? [],
[playSettings?.mediaSource]
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected]
() => subtitleStreams.find((x) => x.Index === playSettings?.subtitleIndex),
[subtitleStreams, playSettings?.subtitleIndex]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
setPlaySettings((prev) => ({
...prev,
subtitleIndex: defaultSubIndex,
}));
return;
}
onChange(-1);
setPlaySettings((prev) => ({
...prev,
subtitleIndex: -1,
}));
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
@@ -88,7 +79,10 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
setPlaySettings((prev) => ({
...prev,
subtitleIndex: -1,
}));
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
@@ -98,7 +92,10 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
setPlaySettings((prev) => ({
...prev,
subtitleIndex: subtitle.Index,
}));
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -0,0 +1,451 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Share,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OnProgressData, ReactVideoProps, VideoRef } from "react-native-video";
import { itemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
togglePlay: (ticks: number) => void;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
}
export const Controls: React.FC<Props> = ({
item,
videoRef,
togglePlay,
isPlaying,
isSeeking,
progress,
cacheProgress,
}) => {
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(item);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
useEffect(() => {
if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
max.value = item.RunTimeTicks || 0;
}
}, [item]);
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
// @ts-ignore
router.push(url);
}, [previousItem, from, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
// @ts-ignore
router.push(url);
}, [nextItem, from, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
return (
<View className="absolute h-screen w-screen top-0 left-0">
<View className="relative">
{(showControls || isBuffering) && (
<View
pointerEvents="none"
className=" bg-black/50 z-0 w-screen h-screen absolute top-0 left-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 absolute bottom-16 right-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
<View className="absolute top-16 right-4 flex flex-row items-center space-x-2 z-10">
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
pointerEvents={showControls ? "auto" : "none"}
className={`absolute bottom-4 left-0 w-screen p-4 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</View>
</View>
);
};