Files
streamyfin_mirror/components/video-player/controls/Controls.tsx
lance chant 89b34eddc1 fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 10:12:40 +02:00

1052 lines
32 KiB
TypeScript

import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import {
type Dispatch,
type FC,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
Platform,
TouchableOpacity,
useTVEventHandler,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
type SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
msToTicks,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: Dispatch<SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
isVlc?: boolean;
}
const CONTROLS_TIMEOUT = 4000;
export const Controls: FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [episodeView, setEpisodeView] = useState(false);
const [isSliding, setIsSliding] = useState(false);
// Used when user changes audio through audio button on device.
const [showAudioSlider, setShowAudioSlider] = useState(false);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay);
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const lightHapticFeedback = useHaptic("light");
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const remoteScrubProgress = useSharedValue<number | null>(null);
const isRemoteScrubbing = useSharedValue(false);
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
const [longPressScrubMode, setLongPressScrubMode] = useState<
"FF" | "RW" | null
>(null);
useTVEventHandler((evt) => {
if (!evt) return;
switch (evt.eventType) {
case "longLeft": {
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
break;
}
case "longRight": {
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
break;
}
case "left":
case "right": {
isRemoteScrubbing.value = true;
setShowRemoteBubble(true);
const direction = evt.eventType === "left" ? -1 : 1;
const base = remoteScrubProgress.value ?? progress.value;
const updated = Math.max(
min.value,
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
break;
}
case "select": {
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
seek(seekTarget);
if (isPlaying) play();
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
} else {
togglePlay();
}
break;
}
case "down":
case "up":
// cancel scrubbing on other directions
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
break;
default:
break;
}
if (!showControls) toggleControls();
});
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
let isActive = true;
let seekTime = 10;
const scrubWithLongPress = () => {
if (!isActive || !longPressScrubMode) return;
setIsSliding(true);
const scrubFn =
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
scrubFn(seekTime);
seekTime *= 1.1;
longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300);
};
if (longPressScrubMode) {
isActive = true;
scrubWithLongPress();
}
return () => {
isActive = false;
setIsSliding(false);
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}
};
}, [longPressScrubMode]);
const effectiveProgress = useSharedValue(0);
// Recompute progress whenever remote scrubbing is active
useAnimatedReaction(
() => ({
isScrubbing: isRemoteScrubbing.value,
scrub: remoteScrubProgress.value,
actual: progress.value,
}),
(current) => {
effectiveProgress.value =
current.isScrubbing && current.scrub != null
? current.scrub
: current.actual;
},
[],
);
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc]);
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
subtitleIndex: string;
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play,
isVlc,
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play,
isVlc,
);
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) {
return;
}
lightHapticFeedback();
const previousIndexes = {
subtitleIndex: subtitleIndex
? Number.parseInt(subtitleIndex)
: undefined,
audioIndex: audioIndex ? Number.parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
previousIndexes,
mediaSource ?? undefined,
);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
return;
}
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon],
);
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
// Add a memoized handler for ContinueWatchingOverlay
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
const goToItem = useCallback(
async (itemId: string) => {
const gotoItem = await getItemById(api, itemId);
if (!gotoItem) {
return;
}
goToItemCommon(gotoItem);
},
[goToItemCommon, api],
);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
},
[goToNextItem, isVlc],
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (!result.isSeeking) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes],
);
const hideControls = useCallback(() => {
setShowControls(false);
setShowAudioSlider(false);
}, []);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding,
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_TIMEOUT,
});
const toggleControls = () => {
if (showControls) {
setShowAudioSlider(false);
setShowControls(false);
} else {
setShowControls(true);
}
};
const handleSliderStart = useCallback(() => {
if (!showControls) {
return;
}
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying, pause]);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current) {
play();
}
},
[isVlc, seek, play],
);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = isVlc ? msToTicks(value) : value;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
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 = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
seek(newTime);
if (wasPlayingRef.current) {
play();
}
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc, play, seek]);
const handleSeekBackward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
seek(newTime);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSeekForward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
seek(Math.max(0, newTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) {
return;
}
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current) {
play();
}
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc, play, seek]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback();
}, []);
const switchOnEpisodeMode = useCallback(() => {
setEpisodeView(true);
if (isPlaying) {
togglePlay();
}
}, [isPlaying, togglePlay]);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
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 },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
lightHapticFeedback();
router.back();
};
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItem}
/>
) : (
<>
<VideoTouchOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
/>
<View
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
width: settings?.safeAreaInControlsEnabled
? screenWidth - insets.left - insets.right
: screenWidth,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={"flex flex-row w-full pt-2"}
>
{!Platform.isTV && (
<View className='mr-auto'>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<DropdownView />
</VideoProvider>
</View>
)}
<View className='flex flex-row items-center space-x-2 '>
{!Platform.isTV &&
settings.defaultPlayer === VideoPlayer.VLC_4 && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={24}
color='white'
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
}}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='list' size={24} color='white' />
</TouchableOpacity>
)}
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='play-skip-back' size={24} color='white' />
</TouchableOpacity>
)}
{nextItem && !offline && (
<TouchableOpacity
onPress={() => goToNextItem({ isAutoPlay: false })}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='play-skip-forward' size={24} color='white' />
</TouchableOpacity>
)}
{/* {mediaSource?.TranscodingUrl && ( */}
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color='white'
/>
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
</View>
<View
style={{
position: "absolute",
top: "50%", // Center vertically
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: "28%", // Add some padding to the left and right
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
left: 0,
bottom: 30,
opacity: showControls ? 1 : 0,
}}
>
<BrightnessSlider />
</View>
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
style={{
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
</View>
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
right: 0,
opacity: showAudioSlider || showControls ? 1 : 0,
}}
>
<AudioSlider setVisibility={setShowAudioSlider} />
</View>
</View>
<View
style={[
{
position: "absolute",
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
},
]}
className={"flex flex-col px-2"}
onTouchStart={handleControlsInteraction}
>
<View
className='shrink flex flex-col justify-center h-full'
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end", // Shrink height based on content
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
{item?.Type === "Episode" && (
<Text className='opacity-50'>
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
</Text>
)}
<Text className='font-bold text-xl'>{item?.Name}</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 flex-row space-x-2'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText='Skip Credits'
/>
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
</View>
</View>
<View
className={"flex flex-col-reverse rounded-lg items-center my-2"}
style={{
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<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: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() =>
(isSliding || showRemoteBubble) && memoizedRenderBubble()
}
sliderHeight={10}
thumbWidth={0}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-[12px] text-neutral-400'>
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>
<Text className='text-[12px] text-neutral-400'>
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text>
</View>
</View>
</View>
</View>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</ControlProvider>
);
};