feat: select skip/rewind time + refactor video player

This commit is contained in:
Fredrik Burmester
2024-09-22 23:05:13 +02:00
parent a023c91877
commit ff1decfe2c
6 changed files with 545 additions and 605 deletions

View File

@@ -1,54 +1,49 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useControlsVisibility } from "@/hooks/useControlsVisibility"; import {
import { useTrickplay } from "@/hooks/useTrickplay"; View,
import { apiAtom } from "@/providers/JellyfinProvider"; TouchableOpacity,
Alert,
Dimensions,
BackHandler,
Pressable,
Touchable,
} from "react-native";
import Video, { OnProgressData } from "react-native-video";
import { Slider } from "react-native-awesome-slider";
import { Ionicons } from "@expo/vector-icons";
import { usePlayback } from "@/providers/PlaybackProvider"; import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { useTrickplay } from "@/hooks/useTrickplay";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { setStatusBarHidden, StatusBar } from "expo-status-bar";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
AppState,
AppStateStatus,
BackHandler,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import "react-native-gesture-handler";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { writeToLog } from "@/utils/log";
async function lockOrientation(orientation: ScreenOrientation.OrientationLock) { import { useRouter, useSegments } from "expo-router";
await ScreenOrientation.lockAsync(orientation); import { itemRouter } from "./common/TouchableItemRouter";
} import { Image } from "expo-image";
import { StatusBar } from "expo-status-bar";
async function resetOrientation() { import * as ScreenOrientation from "expo-screen-orientation";
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); import { useAtom } from "jotai";
} import { apiAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import {
formatTimeString,
runtimeTicksToSeconds,
ticksToSeconds,
} from "@/utils/time";
export const FullScreenVideoPlayer: React.FC = () => { export const FullScreenVideoPlayer: React.FC = () => {
const { const {
@@ -61,51 +56,67 @@ export const FullScreenVideoPlayer: React.FC = () => {
isPlaying, isPlaying,
videoRef, videoRef,
onProgress, onProgress,
isBuffering: _isBuffering,
setIsBuffering, setIsBuffering,
} = usePlayback(); } = usePlayback();
const [settings] = useSettings(); const [settings] = useSettings();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const segments = useSegments();
const router = useRouter(); const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying); useTrickplay(currentlyPlaying);
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
const opacity = useSharedValue(1);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const from = useMemo(() => segments[2], [segments]); const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const min = useSharedValue(0); const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const sliding = useRef(false);
const localIsBuffering = useSharedValue(true);
const cacheProgress = useSharedValue(0);
const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
const hideControls = useCallback(() => { const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
"worklet";
opacity.value = 0;
}, [opacity]);
const showControls = useCallback(() => { const from = useMemo(() => segments[2], [segments]);
"worklet";
opacity.value = 1; const updateTimes = useCallback(
}, [opacity]); (currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => { useEffect(() => {
const backAction = () => { const backAction = () => {
if (currentlyPlaying) { if (currentlyPlaying) {
// Your custom back action here
console.log("onback");
Alert.alert("Hold on!", "Are you sure you want to exit?", [ Alert.alert("Hold on!", "Are you sure you want to exit?", [
{ {
text: "Cancel", text: "Cancel",
@@ -114,7 +125,6 @@ export const FullScreenVideoPlayer: React.FC = () => {
}, },
{ text: "Yes", onPress: () => stopPlayback() }, { text: "Yes", onPress: () => stopPlayback() },
]); ]);
return true; return true;
} }
return false; return false;
@@ -126,9 +136,35 @@ export const FullScreenVideoPlayer: React.FC = () => {
); );
return () => backHandler.remove(); return () => backHandler.remove();
}, [currentlyPlaying]); }, [currentlyPlaying, stopPlayback]);
const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
/**
* Event listener for orientation
*/
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
}
);
return () => {
subscription.remove();
};
}, []);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const poster = useMemo(() => { const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return ""; if (!currentlyPlaying?.item || !api) return "";
@@ -157,82 +193,118 @@ export const FullScreenVideoPlayer: React.FC = () => {
title: currentlyPlaying.item?.Name || "Unknown", title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined, description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster, imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined, // Change here subtitle: currentlyPlaying.item?.Album ?? undefined,
}, },
}; };
}, [currentlyPlaying, api, poster]); }, [currentlyPlaying, api, poster]);
useEffect(() => {
max.value = currentlyPlaying?.item.RunTimeTicks || 0;
}, [currentlyPlaying?.item.RunTimeTicks]);
useEffect(() => { useEffect(() => {
if (!currentlyPlaying) { if (!currentlyPlaying) {
resetOrientation(); ScreenOrientation.unlockAsync();
progress.value = 0; progress.value = 0;
min.value = 0;
max.value = 0; max.value = 0;
cacheProgress.value = 0; setShowControls(true);
sliding.current = false; setIsStatusBarHidden(false);
hideControls(); isSeeking.value = false;
setStatusBarHidden(false);
// NavigationBar.setVisibilityAsync("visible")
} else { } else {
setStatusBarHidden(true); setIsStatusBarHidden(true);
// NavigationBar.setVisibilityAsync("hidden") ScreenOrientation.lockAsync(
lockOrientation(
settings?.defaultVideoOrientation || settings?.defaultVideoOrientation ||
ScreenOrientation.OrientationLock.DEFAULT ScreenOrientation.OrientationLock.DEFAULT
); );
progress.value = progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0;
showControls(); setShowControls(true);
} }
}, [currentlyPlaying, settings]); }, [currentlyPlaying, settings]);
/** const toggleControls = () => setShowControls(!showControls);
* Event listener for orientation
*/ const handleVideoProgress = useCallback(
useEffect(() => { (data: OnProgressData) => {
const subscription = ScreenOrientation.addOrientationChangeListener( if (isSeeking.value === true) return;
(event) => { progress.value = secondsToTicks(data.currentTime);
setOrientation( cacheProgress.value = secondsToTicks(data.playableDuration);
orientationToOrientationLock(event.orientationInfo.orientation) setIsBufferingState(data.playableDuration === 0);
); setIsBuffering(data.playableDuration === 0);
} onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
); );
return () => { const handleVideoError = useCallback(
subscription.remove(); (e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = () => {
if (isPlaying) pauseVideo();
else playVideo();
}; };
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 isLandscape = useMemo(() => { const handleSkipBackward = useCallback(async () => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || if (!settings) return;
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT try {
? true const curr = await videoRef.current?.getCurrentPosition();
: false; if (curr !== undefined) {
}, [orientation]); videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
const animatedStyles = {
controls: useAnimatedStyle(() => ({
opacity: withTiming(opacity.value, { duration: 300 }),
})),
videoContainer: useAnimatedStyle(() => ({
opacity: withTiming(
opacity.value === 1 || localIsBuffering.value ? 0.5 : 1,
{
duration: 300,
} }
), } catch (error) {
})), writeToLog("ERROR", "Error seeking video backwards", error);
loader: useAnimatedStyle(() => ({ }
opacity: withTiming( }, [settings]);
localIsBuffering.value === true || progress.value === 0 ? 1 : 0,
{ duration: 300 } 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 = () => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
};
const handleGoToNextItem = () => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
};
const toggleIgnoreSafeArea = () => {
setIgnoreSafeArea(!ignoreSafeArea);
}; };
const { data: introTimestamps } = useQuery({ const { data: introTimestamps } = useQuery({
@@ -266,201 +338,16 @@ export const FullScreenVideoPlayer: React.FC = () => {
enabled: !!currentlyPlaying?.item.Id, enabled: !!currentlyPlaying?.item.Id,
}); });
const animatedIntroSkipperStyle = useAnimatedStyle(() => { const skipIntro = async () => {
const showButtonAt = secondsToTicks(introTimestamps?.ShowSkipPromptAt || 0);
const hideButtonAt = secondsToTicks(introTimestamps?.HideSkipPromptAt || 0);
const showButton =
progress.value > showButtonAt && progress.value < hideButtonAt;
return {
opacity: withTiming(
localIsBuffering.value === false && showButton && progress.value !== 0
? 1
: 0,
{
duration: 300,
}
),
bottom: withTiming(
opacity.value === 0 ? insets.bottom + 8 : isLandscape ? 85 : 140,
{
duration: 300,
}
),
};
});
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
const handleToggleControlsPress = useCallback(() => {
if (opacity.value === 1) {
hideControls();
} else {
showControls();
}
}, [opacity.value, hideControls, showControls]);
const skipIntro = useCallback(async () => {
if (!introTimestamps || !videoRef.current) return; if (!introTimestamps || !videoRef.current) return;
try { try {
videoRef.current.seek(introTimestamps.IntroEnd); videoRef.current.seek(introTimestamps.IntroEnd);
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error skipping intro", error); writeToLog("ERROR", "Error skipping intro", error);
} }
}, [introTimestamps]); };
const handleVideoProgress = useCallback( if (!currentlyPlaying) return null;
(e: OnProgressData) => {
if (e.playableDuration === 0) {
setIsBuffering(true);
localIsBuffering.value = true;
} else {
setIsBuffering(false);
localIsBuffering.value = false;
}
if (sliding.current) return;
onProgress(e);
progress.value = secondsToTicks(e.currentTime);
cacheProgress.value = secondsToTicks(e.playableDuration);
},
[onProgress, setIsBuffering]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handleSkipBackward = useCallback(async () => {
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - 15));
showControls();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [videoRef, showControls]);
const handleSkipForward = useCallback(async () => {
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + 15));
showControls();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [videoRef, showControls]);
const handlePlayPause = useCallback(() => {
console.log("handlePlayPause");
if (isPlaying) pauseVideo();
else playVideo();
showControls();
}, [isPlaying, pauseVideo, playVideo, showControls]);
const handleSliderStart = useCallback(() => {
if (opacity.value === 0) return;
sliding.current = true;
}, []);
const handleSliderComplete = useCallback(
(val: number) => {
if (opacity.value === 0) return;
const tick = Math.floor(val);
videoRef.current?.seek(tick / 10000000);
sliding.current = false;
},
[videoRef]
);
const handleSliderChange = useCallback(
(val: number) => {
if (opacity.value === 0) return;
const tick = Math.floor(val);
progress.value = tick;
calculateTrickplayUrl(progress);
showControls();
},
[progress, calculateTrickplayUrl, showControls]
);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
const videoTap = Gesture.Tap().onBegin(() => {
runOnJS(handleToggleControlsPress)();
});
const toggleIgnoreSafeAreaGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(toggleIgnoreSafeArea)();
});
const playPauseGesture = Gesture.Tap()
.onBegin(() => {
console.log("playPauseGesture ~", opacity.value);
})
.onStart(() => {
runOnJS(handlePlayPause)();
})
.onFinalize(() => {
if (opacity.value === 0) opacity.value = 1;
});
const goToPreviouItemGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(handleGoToPreviousItem)();
});
const goToNextItemGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(handleGoToNextItem)();
});
const skipBackwardGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(handleSkipBackward)();
});
const skipForwardGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(handleSkipForward)();
});
const skipIntroGesture = Gesture.Tap().onStart(() => {
runOnJS(skipIntro)();
});
if (!api || !currentlyPlaying) return null;
return ( return (
<View <View
@@ -471,8 +358,8 @@ export const FullScreenVideoPlayer: React.FC = () => {
}} }}
> >
<StatusBar hidden={isStatusBarHidden} /> <StatusBar hidden={isStatusBarHidden} />
<GestureDetector gesture={videoTap}> <TouchableOpacity
<Animated.View onPress={toggleControls}
style={[ style={[
{ {
position: "absolute", position: "absolute",
@@ -484,113 +371,61 @@ export const FullScreenVideoPlayer: React.FC = () => {
? screenWidth ? screenWidth
: screenWidth - (insets.left + insets.right), : screenWidth - (insets.left + insets.right),
}, },
animatedStyles.videoContainer,
]} ]}
>
<View
style={{
width: "100%",
height: "100%",
}}
> >
{videoSource && ( {videoSource && (
<Video <Video
ref={videoRef} ref={videoRef}
allowsExternalPlayback source={videoSource}
style={{ style={{ width: "100%", height: "100%" }}
width: "100%",
height: "100%",
}}
resizeMode={ignoreSafeArea ? "cover" : "contain"} resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true} playWhenInactive={true}
playInBackground={true} playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore" ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
onProgress={handleVideoProgress}
subtitleStyle={{
fontSize: 16,
}}
source={videoSource}
onPlaybackStateChanged={(e) => {
if (e.isPlaying === true) {
playVideo(false);
} else if (e.isPlaying === false) {
pauseVideo(false);
}
}}
onBuffer={(e) => {
if (e.isBuffering) {
console.log("Buffering...");
setIsBuffering(true);
localIsBuffering.value = true;
}
}}
onRestoreUserInterfaceForPictureInPictureStop={() => {
showControls();
}}
onVolumeChange={(e) => {
setVolume(e.volume);
}}
fullscreen={false} fullscreen={false}
onLoadStart={() => {
localIsBuffering.value = true;
}}
onLoad={() => {
localIsBuffering.value = true;
}}
progressUpdateInterval={1000}
onError={handleVideoError}
/> />
)} )}
</View> </TouchableOpacity>
</Animated.View>
</GestureDetector>
<Animated.View {isBuffering && (
<View
pointerEvents="none" pointerEvents="none"
style={[ className="fixed top-0 brightness-50 bg-black/50 left-0 w-screen h-screen flex flex-col items-center justify-center"
{
position: "absolute" as const,
top: 0,
bottom: 0,
left: ignoreSafeArea ? 0 : insets.left,
right: ignoreSafeArea ? 0 : insets.right,
width: ignoreSafeArea
? screenWidth
: screenWidth - (insets.left + insets.right),
justifyContent: "center",
alignItems: "center",
},
animatedStyles.loader,
]}
> >
<Loader /> <Loader />
</Animated.View> </View>
)}
<Animated.View {introTimestamps &&
currentTime > introTimestamps.ShowSkipPromptAt &&
currentTime < introTimestamps.HideSkipPromptAt && (
<View
style={[ style={[
{ {
position: "absolute", position: "absolute",
bottom: insets.bottom + 8 * 8, bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16, right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
zIndex: 10, zIndex: 10,
}, },
animatedIntroSkipperStyle,
]} ]}
className=""
> >
<View className="flex flex-row items-center h-full"> <TouchableOpacity
<TouchableOpacity className="flex flex-col items-center justify-center px-3 py-2 bg-purple-600 rounded-full"> onPress={skipIntro}
<GestureDetector gesture={skipIntroGesture}> className="bg-purple-600 rounded-full p-2"
<Text className="font-semibold">Skip intro</Text> >
</GestureDetector> <Text className="text-white">Skip Intro</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Animated.View> )}
<Animated.View {showControls && (
pointerEvents={opacity.value === 0 ? "none" : "auto"} <>
<View
style={[ style={[
{ {
position: "absolute", position: "absolute",
@@ -598,31 +433,28 @@ export const FullScreenVideoPlayer: React.FC = () => {
right: isLandscape ? insets.right + 32 : insets.right + 8, right: isLandscape ? insets.right + 32 : insets.right + 8,
height: 70, height: 70,
}, },
animatedStyles.controls,
]} ]}
className="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"
> >
<View className="flex flex-row items-center h-full space-x-2 z-10">
<GestureDetector gesture={toggleIgnoreSafeAreaGesture}>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2">
<Ionicons <Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"} name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24} size={24}
color="white" color="white"
/> />
</TouchableOpacity> </TouchableOpacity>
</GestureDetector>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={stopPlayback}
stopPlayback(); className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
}}
className="aspect-square bg-neutral-800 rounded-xl flex flex-col items-center justify-center p-2"
> >
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Animated.View>
<Animated.View <View
style={[ style={[
{ {
position: "absolute", position: "absolute",
@@ -632,7 +464,6 @@ export const FullScreenVideoPlayer: React.FC = () => {
? screenWidth - insets.left - insets.right - 64 ? screenWidth - insets.left - insets.right - 64
: screenWidth - insets.left - insets.right - 32, : screenWidth - insets.left - insets.right - 32,
}, },
animatedStyles.controls,
]} ]}
> >
<View className="shrink flex flex-col justify-center h-full mb-2"> <View className="shrink flex flex-col justify-center h-full mb-2">
@@ -666,13 +497,11 @@ export const FullScreenVideoPlayer: React.FC = () => {
style={{ style={{
opacity: !previousItem ? 0.5 : 1, opacity: !previousItem ? 0.5 : 1,
}} }}
onPress={handleGoToPreviousItem}
> >
<GestureDetector gesture={goToPreviouItemGesture}>
<Ionicons name="play-skip-back" size={24} color="white" /> <Ionicons name="play-skip-back" size={24} color="white" />
</GestureDetector>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity> <TouchableOpacity onPress={handleSkipBackward}>
<GestureDetector gesture={skipBackwardGesture}>
<Ionicons <Ionicons
name="refresh-outline" name="refresh-outline"
size={26} size={26}
@@ -681,30 +510,24 @@ export const FullScreenVideoPlayer: React.FC = () => {
transform: [{ scaleY: -1 }, { rotate: "180deg" }], transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}} }}
/> />
</GestureDetector>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity> <TouchableOpacity onPress={handlePlayPause}>
<GestureDetector gesture={playPauseGesture}>
<Ionicons <Ionicons
name={isPlaying ? "pause" : "play"} name={isPlaying ? "pause" : "play"}
size={30} size={30}
color="white" color="white"
/> />
</GestureDetector>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity> <TouchableOpacity onPress={handleSkipForward}>
<GestureDetector gesture={skipForwardGesture}>
<Ionicons name="refresh-outline" size={26} color="white" /> <Ionicons name="refresh-outline" size={26} color="white" />
</GestureDetector>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={{ style={{
opacity: !nextItem ? 0.5 : 1, opacity: !nextItem ? 0.5 : 1,
}} }}
onPress={handleGoToNextItem}
> >
<GestureDetector gesture={goToNextItemGesture}>
<Ionicons name="play-skip-forward" size={24} color="white" /> <Ionicons name="play-skip-forward" size={24} color="white" />
</GestureDetector>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View <View
@@ -776,15 +599,17 @@ export const FullScreenVideoPlayer: React.FC = () => {
/> />
<View className="flex flex-row items-center justify-between mt-0.5"> <View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400"> <Text className="text-[12px] text-neutral-400">
{runtimeTicksToSeconds(progress.value)} {formatTimeString(currentTime)}
</Text> </Text>
<Text className="text-[12px] text-neutral-400"> <Text className="text-[12px] text-neutral-400">
-{runtimeTicksToSeconds(max.value - progress.value)} -{formatTimeString(remainingTime)}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
</Animated.View> </View>
</>
)}
</View> </View>
); );
}; };

View File

@@ -9,6 +9,8 @@ interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => { export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
if (!settings) return null;
return ( return (
<View> <View>
<Text className="text-lg font-bold mb-2">Media</Text> <Text className="text-lg font-bold mb-2">Media</Text>
@@ -119,6 +121,82 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Forward skip length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.forwardSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Rewind length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.rewindSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View> </View>
</View> </View>
); );

View File

@@ -34,7 +34,7 @@ export const useTrickplay = (
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null); const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0); const lastCalculationTime = useRef(0);
const throttleDelay = 100; // 200ms throttle const throttleDelay = 200; // 200ms throttle
const trickplayInfo = useMemo(() => { const trickplayInfo = useMemo(() => {
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) { if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
@@ -62,7 +62,7 @@ export const useTrickplay = (
}, [currentlyPlaying]); }, [currentlyPlaying]);
const calculateTrickplayUrl = useCallback( const calculateTrickplayUrl = useCallback(
(progress: SharedValue<number>) => { (progress: number) => {
const now = Date.now(); const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) { if (now - lastCalculationTime.current < throttleDelay) {
return null; return null;
@@ -80,7 +80,7 @@ export const useTrickplay = (
throw new Error("Invalid trickplay data"); throw new Error("Invalid trickplay data");
} }
const currentSecond = Math.max(0, Math.floor(progress.value / 10000000)); const currentSecond = Math.max(0, Math.floor(progress / 10000000));
const cols = TileWidth; const cols = TileWidth;
const rows = TileHeight; const rows = TileHeight;

View File

@@ -70,6 +70,8 @@ type Settings = {
defaultAudioLanguage: DefaultLanguageOption | null; defaultAudioLanguage: DefaultLanguageOption | null;
showHomeTitles: boolean; showHomeTitles: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock; defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
}; };
/** /**
@@ -103,6 +105,8 @@ const loadSettings = async (): Promise<Settings> => {
defaultSubtitleLanguage: null, defaultSubtitleLanguage: null,
showHomeTitles: true, showHomeTitles: true,
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,
}; };
try { try {

View File

@@ -1,6 +1,5 @@
// seconds to ticks util // seconds to ticks util
export function secondsToTicks(seconds: number): number { export function secondsToTicks(seconds: number): number {
"worklet";
return seconds * 10000000; return seconds * 10000000;
} }

View File

@@ -6,7 +6,7 @@
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes. * @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
*/ */
export const runtimeTicksToMinutes = ( export const runtimeTicksToMinutes = (
ticks: number | null | undefined, ticks: number | null | undefined
): string => { ): string => {
if (!ticks) return "0h 0m"; if (!ticks) return "0h 0m";
@@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = (
}; };
export const runtimeTicksToSeconds = ( export const runtimeTicksToSeconds = (
ticks: number | null | undefined, ticks: number | null | undefined
): string => { ): string => {
if (!ticks) return "0h 0m"; if (!ticks) return "0h 0m";
@@ -34,3 +34,37 @@ export const runtimeTicksToSeconds = (
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
else return `${minutes}m ${seconds}s`; else return `${minutes}m ${seconds}s`;
}; };
export const formatTimeString = (
t: number | null | undefined,
tick = false
): string => {
if (t === null || t === undefined) return "0:00";
let seconds = t;
if (tick) {
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
}
if (seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
} else {
return `${minutes}m ${remainingSeconds}s`;
}
};
export const secondsToTicks = (seconds?: number | undefined) => {
if (!seconds) return 0;
return seconds * 10000000;
};
export const ticksToSeconds = (ticks?: number | undefined) => {
if (!ticks) return 0;
return ticks / 10000000;
};