mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
5 Commits
renovate/r
...
chore/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd26e68c7 | ||
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 61,
|
||||
"versionCode": 62,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -98,7 +98,7 @@ export default function page() {
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
const [_settings] = useSettings();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
@@ -280,11 +280,15 @@ export default function page() {
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
@@ -293,7 +297,7 @@ export default function page() {
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = () => {
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
if (!stream) return;
|
||||
return {
|
||||
itemId: item?.Id!,
|
||||
@@ -309,7 +313,32 @@ export default function page() {
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
};
|
||||
};
|
||||
}, [
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
|
||||
// Track when seeking ends to update URL immediately
|
||||
useAnimatedReaction(
|
||||
() => isSeeking.get(),
|
||||
(currentSeeking, previousSeeking) => {
|
||||
if (previousSeeking && !currentSeeking) {
|
||||
// Seeking just ended
|
||||
wasJustSeeking.value = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
@@ -322,10 +351,20 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update the playback position in the URL.
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
wasJustSeeking.value = false;
|
||||
|
||||
if (
|
||||
shouldUpdateUrl ||
|
||||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||
) {
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
lastUrlUpdateTime.value = now;
|
||||
}
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
@@ -398,6 +437,7 @@ export default function page() {
|
||||
console.error("Error toggling mute:", error);
|
||||
}
|
||||
}, [previousVolume]);
|
||||
|
||||
const volumeDownCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -512,7 +552,7 @@ export default function page() {
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
const initOptions = [``];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
@@ -537,6 +577,60 @@ export default function page() {
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Memoize video ref functions to prevent unnecessary re-renders
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play?.();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
videoRef.current?.seekTo?.(position);
|
||||
}, []);
|
||||
const getAudioTracks = useCallback(async () => {
|
||||
return videoRef.current?.getAudioTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const getSubtitleTracks = useCallback(async () => {
|
||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const setSubtitleTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setSubtitleTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
||||
// Note: VlcPlayer type only expects url parameter
|
||||
videoRef.current?.setSubtitleURL?.(url);
|
||||
}, []);
|
||||
|
||||
const setAudioTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setAudioTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setVideoAspectRatio = useCallback(
|
||||
async (aspectRatio: string | null) => {
|
||||
return (
|
||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
||||
Promise.resolve()
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
||||
return (
|
||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
||||
);
|
||||
}, []);
|
||||
|
||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
return (
|
||||
@@ -567,7 +661,7 @@ export default function page() {
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "blue",
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
@@ -624,19 +718,19 @@ export default function page() {
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={videoRef.current?.startPictureInPicture}
|
||||
play={videoRef.current?.play || (() => {})}
|
||||
pause={videoRef.current?.pause || (() => {})}
|
||||
seek={videoRef.current?.seekTo || (() => {})}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current?.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current?.setAudioTrack}
|
||||
setVideoAspectRatio={videoRef.current?.setVideoAspectRatio}
|
||||
setVideoScaleFactor={videoRef.current?.setVideoScaleFactor}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import Animated, { type SharedValue } from "react-native-reanimated";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { SLIDER_CONFIG, SLIDER_THEME } from "../constants";
|
||||
import NextEpisodeCountDownButton from "../NextEpisodeCountDownButton";
|
||||
import SkipButton from "../SkipButton";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
interface BottomControlsProps {
|
||||
@@ -20,46 +19,47 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
nextItem?: BaseItemDto;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
cacheProgress: SharedValue<number>;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
handleControlsInteraction: () => void;
|
||||
|
||||
// Slider props
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
effectiveProgress: SharedValue<number>;
|
||||
animatedControlsStyle: any;
|
||||
animatedSliderStyle: any;
|
||||
trickPlayUrl?: {
|
||||
cacheProgress: SharedValue<number>;
|
||||
handleSliderStart: () => void;
|
||||
handleSliderComplete: (value: number) => void;
|
||||
handleSliderChange: (value: number) => void;
|
||||
handleTouchStart: () => void;
|
||||
handleTouchEnd: () => void;
|
||||
|
||||
// Trickplay props
|
||||
trickPlayUrl: {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
};
|
||||
trickplayInfo?: {
|
||||
aspectRatio: number;
|
||||
} | null;
|
||||
trickplayInfo: {
|
||||
aspectRatio?: number;
|
||||
data: {
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
getEndTime: () => string;
|
||||
onControlsInteraction: () => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchEnd: () => void;
|
||||
onSliderStart: () => void;
|
||||
onSliderComplete: (value: number) => void;
|
||||
onSliderChange: (value: number) => void;
|
||||
onSkipIntro: () => void;
|
||||
onSkipCredit: () => void;
|
||||
onNextEpisodeAutoPlay: () => void;
|
||||
onNextEpisodeManual: () => void;
|
||||
}
|
||||
|
||||
export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
item,
|
||||
showControls,
|
||||
isSliding,
|
||||
@@ -67,43 +67,32 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
nextItem,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
cacheProgress,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleControlsInteraction,
|
||||
min,
|
||||
max,
|
||||
effectiveProgress,
|
||||
animatedControlsStyle,
|
||||
animatedSliderStyle,
|
||||
cacheProgress,
|
||||
handleSliderStart,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
getEndTime,
|
||||
onControlsInteraction,
|
||||
onTouchStart,
|
||||
onTouchEnd,
|
||||
onSliderStart,
|
||||
onSliderComplete,
|
||||
onSliderChange,
|
||||
onSkipIntro,
|
||||
onSkipCredit,
|
||||
onNextEpisodeAutoPlay,
|
||||
onNextEpisodeManual,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderTrickplayBubble = () => (
|
||||
<TrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
@@ -113,10 +102,9 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
},
|
||||
animatedControlsStyle,
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
onTouchStart={onControlsInteraction}
|
||||
onTouchStart={handleControlsInteraction}
|
||||
>
|
||||
<View
|
||||
className='shrink flex flex-col justify-center h-full'
|
||||
@@ -128,7 +116,8 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
alignSelf: "flex-end", // Shrink height based on content
|
||||
alignSelf: "flex-end",
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
@@ -148,12 +137,12 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={onSkipIntro}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={showSkipCreditButton}
|
||||
onPress={onSkipCredit}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
@@ -167,63 +156,69 @@ export const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={onNextEpisodeAutoPlay}
|
||||
onPress={onNextEpisodeManual}
|
||||
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"}>
|
||||
<View
|
||||
style={{
|
||||
height: SLIDER_CONFIG.HEIGHT,
|
||||
height: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<Animated.View style={animatedSliderStyle}>
|
||||
<Slider
|
||||
theme={SLIDER_THEME}
|
||||
renderThumb={() => null}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={onSliderStart}
|
||||
onSlidingComplete={onSliderComplete}
|
||||
onValueChange={onSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: SLIDER_CONFIG.BORDER_RADIUS,
|
||||
}}
|
||||
renderBubble={() =>
|
||||
(isSliding || showRemoteBubble) && renderTrickplayBubble()
|
||||
}
|
||||
sliderHeight={SLIDER_CONFIG.HEIGHT}
|
||||
thumbWidth={SLIDER_CONFIG.THUMB_WIDTH}
|
||||
progress={effectiveProgress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<View className='flex flex-col items-end'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getEndTime()}
|
||||
</Text>
|
||||
</View>
|
||||
<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) && (
|
||||
<TrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={effectiveProgress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
</View>
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,88 +1,79 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "../AudioSlider";
|
||||
import BrightnessSlider from "../BrightnessSlider";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
|
||||
interface CenterControlsProps {
|
||||
showControls: boolean;
|
||||
showAudioSlider: boolean;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
rewindSkipTime?: number;
|
||||
forwardSkipTime?: number;
|
||||
animatedControlsStyle: any;
|
||||
showAudioSlider: boolean;
|
||||
setShowAudioSlider: (show: boolean) => void;
|
||||
onTogglePlay: () => void;
|
||||
onSkipBackward: () => void;
|
||||
onSkipForward: () => void;
|
||||
togglePlay: () => void;
|
||||
handleSkipBackward: () => void;
|
||||
handleSkipForward: () => void;
|
||||
}
|
||||
|
||||
export const CenterControls: React.FC<CenterControlsProps> = ({
|
||||
export const CenterControls: FC<CenterControlsProps> = ({
|
||||
showControls,
|
||||
showAudioSlider,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
rewindSkipTime,
|
||||
forwardSkipTime,
|
||||
animatedControlsStyle,
|
||||
showAudioSlider,
|
||||
setShowAudioSlider,
|
||||
onTogglePlay,
|
||||
onSkipBackward,
|
||||
onSkipForward,
|
||||
togglePlay,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Animated.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: 17,
|
||||
},
|
||||
animatedControlsStyle,
|
||||
]}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
transform: [{ translateY: -22.5 }],
|
||||
paddingHorizontal: "28%",
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
{/* Brightness Control */}
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
left: 0,
|
||||
bottom: 30,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<BrightnessSlider />
|
||||
</View>
|
||||
|
||||
{/* Skip Backward */}
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={onSkipBackward}>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={50}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
@@ -97,20 +88,22 @@ export const CenterControls: React.FC<CenterControlsProps> = ({
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{rewindSkipTime}
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Button */}
|
||||
<View style={{ alignItems: "center" }}>
|
||||
<TouchableOpacity onPress={onTogglePlay}>
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={50}
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
style={{
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
@@ -118,17 +111,21 @@ export const CenterControls: React.FC<CenterControlsProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Skip Forward */}
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={onSkipForward}>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='refresh-outline' size={50} color='white' />
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -138,25 +135,24 @@ export const CenterControls: React.FC<CenterControlsProps> = ({
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{forwardSkipTime}
|
||||
{settings?.forwardSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Volume/Audio Control */}
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
bottom: 30,
|
||||
right: 0,
|
||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
@@ -16,35 +16,30 @@ import { useWindowDimensions } from "react-native";
|
||||
import {
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { BottomControls } from "./components/BottomControls";
|
||||
import { CenterControls } from "./components/CenterControls";
|
||||
// Extracted components
|
||||
import { TopControlsBar } from "./components/TopControlsBar";
|
||||
// Constants and utilities
|
||||
import { ANIMATION_DURATION, CONTROLS_TIMEOUT } from "./constants";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import { useEpisodeNavigation } from "./hooks/useEpisodeNavigation";
|
||||
// Extracted hooks
|
||||
import { useRemoteControls } from "./hooks/useRemoteControls";
|
||||
import { useSkipControls } from "./hooks/useSkipControls";
|
||||
import { useSliderInteractions } from "./hooks/useSliderInteractions";
|
||||
import { useTimeManagement } from "./hooks/useTimeManagement";
|
||||
import { useVideoScaling } from "./hooks/useVideoScaling";
|
||||
import { HeaderControls } from "./HeaderControls";
|
||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { type ScaleFactor } from "./ScaleFactorSelector";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { initializeProgress } from "./utils/progressUtils";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
|
||||
@@ -112,116 +107,67 @@ export const Controls: FC<Props> = ({
|
||||
offline = false,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
// Local state
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
isOffline: offline,
|
||||
});
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item);
|
||||
|
||||
// Initialize progress values
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
// Animated opacity for smooth transitions
|
||||
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
||||
|
||||
// Trickplay
|
||||
const { trickPlayUrl, trickplayInfo, prefetchAllTrickplayImages } =
|
||||
useTrickplay(item);
|
||||
|
||||
// Initialize progress on item change
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
const { initialProgress, maxProgress } = initializeProgress(item, isVlc);
|
||||
progress.value = initialProgress;
|
||||
max.value = maxProgress;
|
||||
}
|
||||
}, [item, isVlc, progress, max]);
|
||||
|
||||
// Prefetch trickplay images
|
||||
useEffect(() => {
|
||||
prefetchAllTrickplayImages();
|
||||
}, [prefetchAllTrickplayImages]);
|
||||
|
||||
// Animate controls opacity
|
||||
// Initialize progress values
|
||||
useEffect(() => {
|
||||
controlsOpacity.value = withTiming(showControls ? 1 : 0, {
|
||||
duration: ANIMATION_DURATION.CONTROLS_FADE,
|
||||
});
|
||||
}, [showControls, controlsOpacity]);
|
||||
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, progress, max]);
|
||||
|
||||
// Animated styles
|
||||
const animatedControlsStyle = useAnimatedStyle(() => ({
|
||||
opacity: controlsOpacity.value,
|
||||
}));
|
||||
// Navigation hooks
|
||||
const {
|
||||
handleSeekBackward,
|
||||
handleSeekForward,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
} = useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
});
|
||||
|
||||
const animatedOverlayStyle = useAnimatedStyle(() => ({
|
||||
opacity: controlsOpacity.value * 0.75,
|
||||
}));
|
||||
|
||||
// Extracted hooks
|
||||
const { currentTime, remainingTime, getEndTime } = useTimeManagement({
|
||||
// Time management hook
|
||||
const { currentTime, remainingTime } = useVideoTime({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
});
|
||||
|
||||
const {
|
||||
isSliding,
|
||||
time,
|
||||
sliderScale,
|
||||
handleSliderStart,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
} = useSliderInteractions({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
showControls,
|
||||
item,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
});
|
||||
|
||||
const {
|
||||
previousItem,
|
||||
nextItem,
|
||||
goToItemCommon,
|
||||
goToPreviousItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleContinueWatching,
|
||||
} = useEpisodeNavigation({
|
||||
item,
|
||||
offline,
|
||||
mediaSource,
|
||||
});
|
||||
|
||||
const { handleAspectRatioChange, handleScaleFactorChange } = useVideoScaling({
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
});
|
||||
|
||||
const { handleSkipBackward, handleSkipForward } = useSkipControls({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
const toggleControls = useCallback(() => {
|
||||
if (showControls) {
|
||||
setShowAudioSlider(false);
|
||||
@@ -231,21 +177,89 @@ export const Controls: FC<Props> = ({
|
||||
}
|
||||
}, [showControls, setShowControls]);
|
||||
|
||||
const { showRemoteBubble, time: remoteTime } = useRemoteControls({
|
||||
// Remote control hook
|
||||
const {
|
||||
remoteScrubProgress,
|
||||
isRemoteScrubbing,
|
||||
showRemoteBubble,
|
||||
isSliding: isRemoteSliding,
|
||||
time: remoteTime,
|
||||
} = useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
item,
|
||||
seek,
|
||||
play,
|
||||
togglePlay,
|
||||
toggleControls,
|
||||
calculateTrickplayUrl,
|
||||
handleSeekForward,
|
||||
handleSeekBackward,
|
||||
});
|
||||
|
||||
// Skip intro/credits
|
||||
// Slider hook
|
||||
const {
|
||||
isSliding,
|
||||
time,
|
||||
handleSliderStart,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
} = useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
calculateTrickplayUrl,
|
||||
showControls,
|
||||
});
|
||||
|
||||
const effectiveProgress = useSharedValue(0);
|
||||
|
||||
// Recompute progress whenever remote scrubbing is active or when progress significantly changes
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
isScrubbing: isRemoteScrubbing.value,
|
||||
scrub: remoteScrubProgress.value,
|
||||
actual: progress.value,
|
||||
}),
|
||||
(current, previous) => {
|
||||
// Always update if scrubbing state changed or we're currently scrubbing
|
||||
if (
|
||||
current.isScrubbing !== previous?.isScrubbing ||
|
||||
current.isScrubbing
|
||||
) {
|
||||
effectiveProgress.value =
|
||||
current.isScrubbing && current.scrub != null
|
||||
? current.scrub
|
||||
: current.actual;
|
||||
} else {
|
||||
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||
const progressUnit = isVlc
|
||||
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
|
||||
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
|
||||
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||
if (progressDiff >= progressUnit) {
|
||||
effectiveProgress.value = current.actual;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
bitrateValue: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
@@ -264,7 +278,123 @@ export const Controls: FC<Props> = ({
|
||||
offline,
|
||||
);
|
||||
|
||||
// Controls timeout
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) {
|
||||
return;
|
||||
}
|
||||
lightHapticFeedback();
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex, 10)
|
||||
: undefined,
|
||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
console.log("queryParams", queryParams);
|
||||
|
||||
// @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 hideControls = useCallback(() => {
|
||||
setShowControls(false);
|
||||
setShowAudioSlider(false);
|
||||
@@ -272,29 +402,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const { handleControlsInteraction } = useControlsTimeout({
|
||||
showControls,
|
||||
isSliding,
|
||||
isSliding: isSliding || isRemoteSliding,
|
||||
episodeView,
|
||||
onHideControls: hideControls,
|
||||
timeout: CONTROLS_TIMEOUT,
|
||||
timeout: CONTROLS_CONSTANTS.TIMEOUT,
|
||||
});
|
||||
|
||||
// Effective progress calculation
|
||||
const effectiveProgress = useSharedValue(0);
|
||||
|
||||
// For remote scrubbing, we'll need to adapt this - for now using the basic progress
|
||||
useAnimatedReaction(
|
||||
() => progress.value,
|
||||
(value) => {
|
||||
effectiveProgress.value = value;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Animated style for slider scale
|
||||
const animatedSliderStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scaleY: sliderScale.value }],
|
||||
}));
|
||||
|
||||
const switchOnEpisodeMode = useCallback(() => {
|
||||
setEpisodeView(true);
|
||||
if (isPlaying) {
|
||||
@@ -302,11 +415,6 @@ export const Controls: FC<Props> = ({
|
||||
}
|
||||
}, [isPlaying, togglePlay]);
|
||||
|
||||
const onClose = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
router.back();
|
||||
}, [lightHapticFeedback, router]);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
@@ -324,51 +432,42 @@ export const Controls: FC<Props> = ({
|
||||
<VideoTouchOverlay
|
||||
screenWidth={screenWidth}
|
||||
screenHeight={screenHeight}
|
||||
onToggleControls={toggleControls}
|
||||
animatedStyle={animatedOverlayStyle}
|
||||
/>
|
||||
|
||||
<TopControlsBar
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
offline={offline}
|
||||
showControls={showControls}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
previousItem={previousItem || undefined}
|
||||
nextItem={nextItem || undefined}
|
||||
animatedControlsStyle={animatedControlsStyle}
|
||||
screenWidth={screenWidth}
|
||||
onToggleControls={toggleControls}
|
||||
/>
|
||||
<HeaderControls
|
||||
item={item}
|
||||
showControls={showControls}
|
||||
offline={offline}
|
||||
mediaSource={mediaSource}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
switchOnEpisodeMode={switchOnEpisodeMode}
|
||||
goToPreviousItem={goToPreviousItem}
|
||||
goToNextItem={goToNextItem}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
onAspectRatioChange={handleAspectRatioChange}
|
||||
onScaleFactorChange={handleScaleFactorChange}
|
||||
onEpisodeModeToggle={switchOnEpisodeMode}
|
||||
onGoToPreviousItem={goToPreviousItem}
|
||||
onGoToNextItem={() => handleNextEpisodeManual()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<CenterControls
|
||||
showControls={showControls}
|
||||
showAudioSlider={showAudioSlider}
|
||||
isPlaying={isPlaying}
|
||||
isBuffering={isBuffering}
|
||||
rewindSkipTime={settings?.rewindSkipTime}
|
||||
forwardSkipTime={settings?.forwardSkipTime}
|
||||
animatedControlsStyle={animatedControlsStyle}
|
||||
showAudioSlider={showAudioSlider}
|
||||
setShowAudioSlider={setShowAudioSlider}
|
||||
onTogglePlay={togglePlay}
|
||||
onSkipBackward={handleSkipBackward}
|
||||
onSkipForward={handleSkipForward}
|
||||
togglePlay={togglePlay}
|
||||
handleSkipBackward={handleSkipBackward}
|
||||
handleSkipForward={handleSkipForward}
|
||||
/>
|
||||
|
||||
<BottomControls
|
||||
item={item}
|
||||
showControls={showControls}
|
||||
@@ -377,29 +476,26 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
nextItem={nextItem || undefined}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
cacheProgress={cacheProgress}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
handleControlsInteraction={handleControlsInteraction}
|
||||
min={min}
|
||||
max={max}
|
||||
effectiveProgress={effectiveProgress}
|
||||
animatedControlsStyle={animatedControlsStyle}
|
||||
animatedSliderStyle={animatedSliderStyle}
|
||||
trickPlayUrl={trickPlayUrl || undefined}
|
||||
trickplayInfo={trickplayInfo || undefined}
|
||||
time={remoteTime || time}
|
||||
getEndTime={getEndTime}
|
||||
onControlsInteraction={handleControlsInteraction}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onSliderStart={handleSliderStart}
|
||||
onSliderComplete={handleSliderComplete}
|
||||
onSliderChange={handleSliderChange}
|
||||
onSkipIntro={skipIntro}
|
||||
onSkipCredit={skipCredit}
|
||||
onNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
onNextEpisodeManual={handleNextEpisodeManual}
|
||||
cacheProgress={cacheProgress}
|
||||
handleSliderStart={handleSliderStart}
|
||||
handleSliderComplete={handleSliderComplete}
|
||||
handleSliderChange={handleSliderChange}
|
||||
handleTouchStart={handleTouchStart}
|
||||
handleTouchEnd={handleTouchEnd}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,78 +3,101 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useRouter } from "expo-router";
|
||||
import { type Dispatch, type FC, type SetStateAction } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { VideoProvider } from "../contexts/VideoContext";
|
||||
import DropdownView from "../dropdown/DropdownView";
|
||||
import { type ScaleFactor, ScaleFactorSelector } from "../ScaleFactorSelector";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||
import {
|
||||
type AspectRatio,
|
||||
AspectRatioSelector,
|
||||
} from "../VideoScalingModeSelector";
|
||||
} from "./VideoScalingModeSelector";
|
||||
|
||||
interface TopControlsBarProps {
|
||||
interface HeaderControlsProps {
|
||||
item: BaseItemDto;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
offline: boolean;
|
||||
showControls: boolean;
|
||||
aspectRatio: AspectRatio;
|
||||
scaleFactor: ScaleFactor;
|
||||
previousItem?: BaseItemDto;
|
||||
nextItem?: BaseItemDto;
|
||||
animatedControlsStyle: any;
|
||||
screenWidth: number;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
offline: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
switchOnEpisodeMode: () => void;
|
||||
goToPreviousItem: () => void;
|
||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||
previousItem?: BaseItemDto | null;
|
||||
nextItem?: BaseItemDto | null;
|
||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
onAspectRatioChange: (ratio: AspectRatio) => void;
|
||||
onScaleFactorChange: (scale: ScaleFactor) => void;
|
||||
onEpisodeModeToggle: () => void;
|
||||
onGoToPreviousItem: () => void;
|
||||
onGoToNextItem: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
||||
export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
item,
|
||||
mediaSource,
|
||||
offline,
|
||||
showControls,
|
||||
aspectRatio,
|
||||
scaleFactor,
|
||||
offline,
|
||||
mediaSource,
|
||||
startPictureInPicture,
|
||||
switchOnEpisodeMode,
|
||||
goToPreviousItem,
|
||||
goToNextItem,
|
||||
previousItem,
|
||||
nextItem,
|
||||
animatedControlsStyle,
|
||||
screenWidth,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
aspectRatio = "default",
|
||||
scaleFactor = 1.0,
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
startPictureInPicture,
|
||||
onAspectRatioChange,
|
||||
onScaleFactorChange,
|
||||
onEpisodeModeToggle,
|
||||
onGoToPreviousItem,
|
||||
onGoToNextItem,
|
||||
onClose,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleAspectRatioChange = async (newRatio: AspectRatio) => {
|
||||
if (!setAspectRatio || !setVideoAspectRatio) return;
|
||||
|
||||
setAspectRatio(newRatio);
|
||||
const aspectRatioString = newRatio === "default" ? null : newRatio;
|
||||
await setVideoAspectRatio(aspectRatioString);
|
||||
};
|
||||
|
||||
const handleScaleFactorChange = async (newScale: ScaleFactor) => {
|
||||
if (!setScaleFactor || !setVideoScaleFactor) return;
|
||||
|
||||
setScaleFactor(newScale);
|
||||
await setVideoScaleFactor(newScale);
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
lightHapticFeedback();
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
@@ -83,8 +106,8 @@ export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
||||
width: settings?.safeAreaInControlsEnabled
|
||||
? screenWidth - insets.left - insets.right
|
||||
: screenWidth,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
animatedControlsStyle,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
@@ -103,7 +126,7 @@ export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 '>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV &&
|
||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
||||
Platform.OS === "android") && (
|
||||
@@ -113,7 +136,7 @@ export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
||||
>
|
||||
<MaterialIcons
|
||||
name='picture-in-picture'
|
||||
size={24}
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
style={{ opacity: showControls ? 1 : 0 }}
|
||||
/>
|
||||
@@ -121,46 +144,53 @@ export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
||||
)}
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={onEpisodeModeToggle}
|
||||
onPress={switchOnEpisodeMode}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='list' size={24} color='white' />
|
||||
<Ionicons name='list' size={ICON_SIZES.HEADER} color='white' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{previousItem && (
|
||||
<TouchableOpacity
|
||||
onPress={onGoToPreviousItem}
|
||||
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' />
|
||||
<Ionicons
|
||||
name='play-skip-back'
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{nextItem && (
|
||||
<TouchableOpacity
|
||||
onPress={onGoToNextItem}
|
||||
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' />
|
||||
<Ionicons
|
||||
name='play-skip-forward'
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Video Controls */}
|
||||
<AspectRatioSelector
|
||||
currentRatio={aspectRatio}
|
||||
onRatioChange={onAspectRatioChange}
|
||||
onRatioChange={handleAspectRatioChange}
|
||||
disabled={!setVideoAspectRatio}
|
||||
/>
|
||||
<ScaleFactorSelector
|
||||
currentScale={scaleFactor}
|
||||
onScaleChange={onScaleFactorChange}
|
||||
onScaleChange={handleScaleFactorChange}
|
||||
disabled={!setVideoScaleFactor}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
<Ionicons name='close' size={ICON_SIZES.HEADER} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
43
components/video-player/controls/TimeDisplay.tsx
Normal file
43
components/video-player/controls/TimeDisplay.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
interface TimeDisplayProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
}) => {
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
||||
const finishTime = new Date(now.getTime() + remainingMs);
|
||||
return finishTime.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<View className='flex flex-col items-end'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,22 @@
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
calculateTrickplayDimensions,
|
||||
formatTimeForBubble,
|
||||
} from "../utils/trickplayUtils";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
|
||||
interface TrickplayBubbleProps {
|
||||
trickPlayUrl?: {
|
||||
trickPlayUrl: {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
};
|
||||
trickplayInfo?: {
|
||||
aspectRatio: number;
|
||||
} | null;
|
||||
trickplayInfo: {
|
||||
aspectRatio?: number;
|
||||
data: {
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -27,7 +24,7 @@ interface TrickplayBubbleProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
@@ -37,9 +34,8 @@ export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
||||
}
|
||||
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const { tileWidth, tileHeight, scaledWidth } = calculateTrickplayDimensions(
|
||||
trickplayInfo.aspectRatio,
|
||||
);
|
||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -49,7 +45,7 @@ export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
||||
bottom: 0,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 5,
|
||||
width: scaledWidth,
|
||||
width: tileWidth * 1.5,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
@@ -67,10 +63,10 @@ export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * (trickplayInfo.data.TileWidth || 1),
|
||||
width: tileWidth * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio) *
|
||||
(trickplayInfo.data.TileHeight || 1),
|
||||
(tileWidth / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
@@ -87,7 +83,9 @@ export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{formatTimeForBubble(time)}
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -1,43 +1,38 @@
|
||||
import { Pressable } from "react-native";
|
||||
import Animated, { type AnimatedStyle } from "react-native-reanimated";
|
||||
import { useTapDetection } from "./useTapDetection";
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
interface Props {
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
showControls: boolean;
|
||||
onToggleControls: () => void;
|
||||
animatedStyle: AnimatedStyle;
|
||||
}
|
||||
|
||||
export const VideoTouchOverlay = ({
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
showControls,
|
||||
onToggleControls,
|
||||
animatedStyle,
|
||||
}: Props) => {
|
||||
const { handleTouchStart, handleTouchEnd } = useTapDetection({
|
||||
onValidTap: onToggleControls,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
<Pressable
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: "black",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: "black",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: showControls ? 0.75 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
export const CONTROLS_TIMEOUT = 4000;
|
||||
|
||||
export const TRICKPLAY_TILE_WIDTH = 150;
|
||||
export const TRICKPLAY_TILE_SCALE = 1.4;
|
||||
|
||||
export const SLIDER_SCALE_UP = 1.4;
|
||||
export const SLIDER_SCALE_NORMAL = 1.0;
|
||||
|
||||
export const ANIMATION_DURATION = {
|
||||
CONTROLS_FADE: 300,
|
||||
SLIDER_SCALE: 300,
|
||||
SLIDER_SCALE_COMPLETE: 200,
|
||||
export const CONTROLS_CONSTANTS = {
|
||||
TIMEOUT: 4000,
|
||||
SCRUB_INTERVAL_MS: 10 * 1000, // 10 seconds in ms
|
||||
SCRUB_INTERVAL_TICKS: 10 * 10000000, // 10 seconds in ticks
|
||||
TILE_WIDTH: 150,
|
||||
PROGRESS_UNIT_MS: 1000, // 1 second in ms
|
||||
PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks
|
||||
LONG_PRESS_INITIAL_SEEK: 10,
|
||||
LONG_PRESS_ACCELERATION: 1.1,
|
||||
LONG_PRESS_INTERVAL: 300,
|
||||
SLIDER_DEBOUNCE_MS: 3,
|
||||
} as const;
|
||||
|
||||
export const SLIDER_CONFIG = {
|
||||
HEIGHT: 10,
|
||||
THUMB_WIDTH: 0,
|
||||
BORDER_RADIUS: 100,
|
||||
} as const;
|
||||
|
||||
export const SLIDER_THEME = {
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#666",
|
||||
heartbeatColor: "#999",
|
||||
export const ICON_SIZES = {
|
||||
HEADER: 24,
|
||||
CENTER: 50,
|
||||
} as const;
|
||||
|
||||
@@ -85,6 +85,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
@@ -114,6 +115,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
mediaSource?.TranscodingUrl &&
|
||||
!onTextBasedSubtitle;
|
||||
|
||||
console.log("Set player params", index, serverIndex);
|
||||
if (shouldChangePlayerParams) {
|
||||
setPlayerParams({
|
||||
chosenSubtitleIndex: serverIndex.toString(),
|
||||
|
||||
4
components/video-player/controls/hooks/index.ts
Normal file
4
components/video-player/controls/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useRemoteControl } from "./useRemoteControl";
|
||||
export { useVideoNavigation } from "./useVideoNavigation";
|
||||
export { useVideoSlider } from "./useVideoSlider";
|
||||
export { useVideoTime } from "./useVideoTime";
|
||||
@@ -1,169 +0,0 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
interface UseEpisodeNavigationProps {
|
||||
item: BaseItemDto;
|
||||
offline: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
}
|
||||
|
||||
export const useEpisodeNavigation = ({
|
||||
item,
|
||||
offline,
|
||||
mediaSource,
|
||||
}: UseEpisodeNavigationProps) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
bitrateValue: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
isOffline: offline,
|
||||
});
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) {
|
||||
return;
|
||||
}
|
||||
lightHapticFeedback();
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex, 10)
|
||||
: undefined,
|
||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
lightHapticFeedback,
|
||||
],
|
||||
);
|
||||
|
||||
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, settings, updateSettings],
|
||||
);
|
||||
|
||||
// Memoized handlers
|
||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
|
||||
const handleNextEpisodeManual = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: false });
|
||||
}, [goToNextItem]);
|
||||
|
||||
const handleContinueWatching = useCallback(
|
||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
||||
goToNextItem(options);
|
||||
},
|
||||
[goToNextItem],
|
||||
);
|
||||
|
||||
return {
|
||||
previousItem,
|
||||
nextItem,
|
||||
goToItemCommon,
|
||||
goToPreviousItem,
|
||||
goToNextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleContinueWatching,
|
||||
};
|
||||
};
|
||||
170
components/video-player/controls/hooks/useRemoteControl.ts
Normal file
170
components/video-player/controls/hooks/useRemoteControl.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "../constants";
|
||||
|
||||
interface UseRemoteControlProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isVlc: boolean;
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
togglePlay: () => void;
|
||||
toggleControls: () => void;
|
||||
calculateTrickplayUrl: (progressInTicks: number) => void;
|
||||
handleSeekForward: (seconds: number) => void;
|
||||
handleSeekBackward: (seconds: number) => void;
|
||||
}
|
||||
|
||||
export function useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
play,
|
||||
togglePlay,
|
||||
toggleControls,
|
||||
calculateTrickplayUrl,
|
||||
handleSeekForward,
|
||||
handleSeekBackward,
|
||||
}: UseRemoteControlProps) {
|
||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||
const isRemoteScrubbing = useSharedValue(false);
|
||||
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
||||
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
||||
"FF" | "RW" | null
|
||||
>(null);
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const SCRUB_INTERVAL = isVlc
|
||||
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
|
||||
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
|
||||
|
||||
const updateTime = useCallback(
|
||||
(progressValue: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
|
||||
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 });
|
||||
},
|
||||
[isVlc],
|
||||
);
|
||||
|
||||
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);
|
||||
updateTime(updated);
|
||||
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();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
let seekTime = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK;
|
||||
|
||||
const scrubWithLongPress = () => {
|
||||
if (!isActive || !longPressScrubMode) return;
|
||||
|
||||
setIsSliding(true);
|
||||
const scrubFn =
|
||||
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
|
||||
scrubFn(seekTime);
|
||||
seekTime *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
|
||||
|
||||
longPressTimeoutRef.current = setTimeout(
|
||||
scrubWithLongPress,
|
||||
CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL,
|
||||
);
|
||||
};
|
||||
|
||||
if (longPressScrubMode) {
|
||||
isActive = true;
|
||||
scrubWithLongPress();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
setIsSliding(false);
|
||||
if (longPressTimeoutRef.current) {
|
||||
clearTimeout(longPressTimeoutRef.current);
|
||||
longPressTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [longPressScrubMode, handleSeekForward, handleSeekBackward]);
|
||||
|
||||
return {
|
||||
remoteScrubProgress,
|
||||
isRemoteScrubbing,
|
||||
showRemoteBubble,
|
||||
longPressScrubMode,
|
||||
isSliding,
|
||||
time,
|
||||
};
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { msToTicks, secondsToMs, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
interface UseRemoteControlsProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isVlc: boolean;
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
item: BaseItemDto;
|
||||
seek: (ticks: number) => void;
|
||||
play: () => void;
|
||||
togglePlay: () => void;
|
||||
toggleControls: () => void;
|
||||
}
|
||||
|
||||
export const useRemoteControls = ({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
item,
|
||||
seek,
|
||||
play,
|
||||
togglePlay,
|
||||
toggleControls,
|
||||
}: UseRemoteControlsProps) => {
|
||||
const { calculateTrickplayUrl } = useTrickplay(item);
|
||||
|
||||
const remoteScrubProgress = useRef<SharedValue<number | null>>(null);
|
||||
const isRemoteScrubbing = useRef<SharedValue<boolean>>(null);
|
||||
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
|
||||
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
||||
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
||||
"FF" | "RW" | null
|
||||
>(null);
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Initialize shared values if not provided
|
||||
if (!remoteScrubProgress.current) {
|
||||
remoteScrubProgress.current = { value: null } as SharedValue<number | null>;
|
||||
}
|
||||
if (!isRemoteScrubbing.current) {
|
||||
isRemoteScrubbing.current = { value: false } as SharedValue<boolean>;
|
||||
}
|
||||
|
||||
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.current!.value = true;
|
||||
setShowRemoteBubble(true);
|
||||
|
||||
const direction = evt.eventType === "left" ? -1 : 1;
|
||||
const base = remoteScrubProgress.current!.value ?? progress.value;
|
||||
const updated = Math.max(
|
||||
min.value,
|
||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||
);
|
||||
remoteScrubProgress.current!.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.current!.value &&
|
||||
remoteScrubProgress.current!.value != null
|
||||
) {
|
||||
progress.value = remoteScrubProgress.current!.value;
|
||||
|
||||
const seekTarget = isVlc
|
||||
? Math.max(0, remoteScrubProgress.current!.value)
|
||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.current!.value));
|
||||
|
||||
seek(seekTarget);
|
||||
if (isPlaying) play();
|
||||
|
||||
isRemoteScrubbing.current!.value = false;
|
||||
remoteScrubProgress.current!.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
} else {
|
||||
togglePlay();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "down":
|
||||
case "up":
|
||||
// cancel scrubbing on other directions
|
||||
isRemoteScrubbing.current!.value = false;
|
||||
remoteScrubProgress.current!.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!showControls) toggleControls();
|
||||
});
|
||||
|
||||
const handleSeekBackward = (
|
||||
seconds: number,
|
||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
||||
) => {
|
||||
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) {
|
||||
console.error("Error seeking video backwards", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeekForward = (
|
||||
seconds: number,
|
||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
||||
) => {
|
||||
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) {
|
||||
console.error("Error seeking video forwards", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Long press scrubbing effect
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
let seekTime = 10;
|
||||
|
||||
if (longPressScrubMode) {
|
||||
// Function is used, but eslint doesn't detect it inside setTimeout
|
||||
const scrubWithLongPress = (
|
||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
||||
) => {
|
||||
if (!isActive || !longPressScrubMode) return;
|
||||
|
||||
const scrubFn =
|
||||
longPressScrubMode === "FF"
|
||||
? (time: number) => handleSeekForward(time, wasPlayingRef)
|
||||
: (time: number) => handleSeekBackward(time, wasPlayingRef);
|
||||
|
||||
scrubFn(seekTime);
|
||||
seekTime *= 1.1;
|
||||
|
||||
longPressTimeoutRef.current = setTimeout(
|
||||
() => scrubWithLongPress(wasPlayingRef),
|
||||
300,
|
||||
);
|
||||
};
|
||||
|
||||
// Start the scrubbing
|
||||
const wasPlayingRef = { current: isPlaying };
|
||||
scrubWithLongPress(wasPlayingRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
if (longPressTimeoutRef.current) {
|
||||
clearTimeout(longPressTimeoutRef.current);
|
||||
longPressTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [longPressScrubMode, handleSeekBackward, handleSeekForward, isPlaying]);
|
||||
|
||||
return {
|
||||
remoteScrubProgress: remoteScrubProgress.current,
|
||||
isRemoteScrubbing: isRemoteScrubbing.current,
|
||||
showRemoteBubble,
|
||||
longPressScrubMode,
|
||||
time,
|
||||
handleSeekBackward,
|
||||
handleSeekForward,
|
||||
};
|
||||
};
|
||||
@@ -5,25 +5,61 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
interface UseSkipControlsProps {
|
||||
interface UseVideoNavigationProps {
|
||||
progress: SharedValue<number>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
seek: (ticks: number) => void;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
export const useSkipControls = ({
|
||||
export function useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
}: UseSkipControlsProps) => {
|
||||
}: UseVideoNavigationProps) {
|
||||
const [settings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
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, progress],
|
||||
);
|
||||
|
||||
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, progress],
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) {
|
||||
return;
|
||||
@@ -44,7 +80,7 @@ export const useSkipControls = ({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) {
|
||||
@@ -66,10 +102,13 @@ export const useSkipControls = ({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
return {
|
||||
handleSeekBackward,
|
||||
handleSeekForward,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
wasPlayingRef,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { type Dispatch, type SetStateAction, useCallback } from "react";
|
||||
import type { ScaleFactor } from "../ScaleFactorSelector";
|
||||
import type { AspectRatio } from "../VideoScalingModeSelector";
|
||||
|
||||
interface UseVideoScalingProps {
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useVideoScaling = ({
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
}: UseVideoScalingProps) => {
|
||||
const handleAspectRatioChange = useCallback(
|
||||
async (newRatio: AspectRatio) => {
|
||||
if (!setAspectRatio || !setVideoAspectRatio) return;
|
||||
|
||||
setAspectRatio(newRatio);
|
||||
const aspectRatioString = newRatio === "default" ? null : newRatio;
|
||||
await setVideoAspectRatio(aspectRatioString);
|
||||
},
|
||||
[setAspectRatio, setVideoAspectRatio],
|
||||
);
|
||||
|
||||
const handleScaleFactorChange = useCallback(
|
||||
async (newScale: ScaleFactor) => {
|
||||
if (!setScaleFactor || !setVideoScaleFactor) return;
|
||||
|
||||
setScaleFactor(newScale);
|
||||
await setVideoScaleFactor(newScale);
|
||||
},
|
||||
[setScaleFactor, setVideoScaleFactor],
|
||||
);
|
||||
|
||||
return {
|
||||
handleAspectRatioChange,
|
||||
handleScaleFactorChange,
|
||||
};
|
||||
};
|
||||
@@ -1,48 +1,37 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
type SharedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "../constants";
|
||||
|
||||
interface UseSliderInteractionsProps {
|
||||
interface UseVideoSliderProps {
|
||||
progress: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
showControls: boolean;
|
||||
item: BaseItemDto;
|
||||
seek: (ticks: number) => void;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
calculateTrickplayUrl: (progressInTicks: number) => void;
|
||||
showControls: boolean;
|
||||
}
|
||||
|
||||
export const useSliderInteractions = ({
|
||||
export function useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
showControls,
|
||||
item,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
}: UseSliderInteractionsProps) => {
|
||||
calculateTrickplayUrl,
|
||||
showControls,
|
||||
}: UseVideoSliderProps) {
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
const lastProgressRef = useRef<number>(0);
|
||||
|
||||
// Animated scale for slider
|
||||
const sliderScale = useSharedValue(1);
|
||||
|
||||
const { calculateTrickplayUrl } = useTrickplay(item);
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (!showControls) {
|
||||
return;
|
||||
@@ -54,43 +43,35 @@ export const useSliderInteractions = ({
|
||||
|
||||
pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls, isPlaying, pause]);
|
||||
}, [showControls, isPlaying, pause, progress, isSeeking]);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
if (!showControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale up the slider immediately on touch
|
||||
sliderScale.value = withTiming(1.4, { duration: 300 });
|
||||
}, [showControls]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!showControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale down the slider on touch end (only if not sliding, to avoid conflict with onSlidingComplete)
|
||||
if (!isSliding) {
|
||||
sliderScale.value = withTiming(1.0, { duration: 300 });
|
||||
}
|
||||
}, [showControls, isSliding]);
|
||||
}, [showControls]);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
async (value: number) => {
|
||||
setIsSliding(false);
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
setIsSliding(false);
|
||||
|
||||
// Scale down the slider
|
||||
sliderScale.value = withTiming(1.0, { duration: 200 });
|
||||
|
||||
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||
const seekValue = Math.max(
|
||||
0,
|
||||
Math.floor(isVlc ? value : ticksToSeconds(value)),
|
||||
);
|
||||
seek(seekValue);
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[isVlc, seek, play],
|
||||
[isVlc, seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
@@ -102,19 +83,17 @@ export const useSliderInteractions = ({
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, 3),
|
||||
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
||||
[isVlc, calculateTrickplayUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
isSliding,
|
||||
setIsSliding,
|
||||
time,
|
||||
sliderScale,
|
||||
handleSliderStart,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
@@ -6,22 +6,25 @@ import {
|
||||
} from "react-native-reanimated";
|
||||
import { ticksToSeconds } from "@/utils/time";
|
||||
|
||||
interface UseTimeManagementProps {
|
||||
interface UseVideoTimeProps {
|
||||
progress: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
export const useTimeManagement = ({
|
||||
export function useVideoTime({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
}: UseTimeManagementProps) => {
|
||||
}: UseVideoTimeProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
|
||||
const lastCurrentTimeRef = useRef(0);
|
||||
const lastRemainingTimeRef = useRef(0);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
@@ -29,8 +32,25 @@ export const useTimeManagement = ({
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
||||
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
||||
const lastCurrentSeconds = Math.floor(
|
||||
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
||||
);
|
||||
const lastRemainingSeconds = Math.floor(
|
||||
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
||||
);
|
||||
|
||||
if (
|
||||
currentSeconds !== lastCurrentSeconds ||
|
||||
remainingSeconds !== lastRemainingSeconds
|
||||
) {
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
lastCurrentTimeRef.current = current;
|
||||
lastRemainingTimeRef.current = remaining;
|
||||
}
|
||||
},
|
||||
[isVlc],
|
||||
);
|
||||
@@ -49,21 +69,8 @@ export const useTimeManagement = ({
|
||||
[updateTimes],
|
||||
);
|
||||
|
||||
const getEndTime = () => {
|
||||
const now = new Date();
|
||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
||||
const finishTime = new Date(now.getTime() + remainingMs);
|
||||
return finishTime.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
remainingTime,
|
||||
updateTimes,
|
||||
getEndTime,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
|
||||
export const initializeProgress = (item: BaseItemDto, isVlc: boolean) => {
|
||||
const initialProgress = isVlc
|
||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||
|
||||
const maxProgress = isVlc
|
||||
? ticksToMs(item.RunTimeTicks || 0)
|
||||
: item.RunTimeTicks || 0;
|
||||
|
||||
return { initialProgress, maxProgress };
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { TRICKPLAY_TILE_SCALE, TRICKPLAY_TILE_WIDTH } from "../constants";
|
||||
|
||||
export const calculateTrickplayDimensions = (aspectRatio: number) => {
|
||||
const tileWidth = TRICKPLAY_TILE_WIDTH;
|
||||
const tileHeight = TRICKPLAY_TILE_WIDTH / aspectRatio;
|
||||
|
||||
return {
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
scaledWidth: tileWidth * TRICKPLAY_TILE_SCALE,
|
||||
scaledHeight: tileHeight * TRICKPLAY_TILE_SCALE,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatTimeForBubble = (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}) => {
|
||||
return `${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
||||
};
|
||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.32.0",
|
||||
"channel": "0.32.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.32.0",
|
||||
"channel": "0.32.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.32.0",
|
||||
"channel": "0.32.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -70,7 +70,7 @@ export const usePlaybackManager = ({
|
||||
useDownload();
|
||||
|
||||
/** Whether the device is online. actually it's connected to the internet. */
|
||||
const isOnline = netInfo.isConnected;
|
||||
const isOnline = useMemo(() => netInfo.isConnected, [netInfo.isConnected]);
|
||||
|
||||
// Adjacent episodes logic
|
||||
const { data: adjacentItems } = useQuery({
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.32.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.32.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.32.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.32.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user