Compare commits

...

5 Commits

Author SHA1 Message Date
Fredrik Burmester
edd26e68c7 chore: refactor controls 2025-08-20 13:41:45 +02:00
Fredrik Burmester
7cab50750f chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 6s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 5s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 30s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 28s
2025-08-20 10:30:57 +02:00
Fredrik Burmester
d795e82581 fix: trickplay and re-rendering issues 2025-08-20 09:59:03 +02:00
Fredrik Burmester
e7161bc9ab fix: revert fade in controls 2025-08-20 08:21:01 +02:00
Fredrik Burmester
8e74363f32 Revert "chore: refactor controls (#946)"
This reverts commit 8389404975.
2025-08-20 08:18:12 +02:00
24 changed files with 990 additions and 1014 deletions

View File

@@ -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",

View File

@@ -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/missingdata
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}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
)}

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);

View File

@@ -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,
}}
/>
);
};

View File

@@ -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;

View File

@@ -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(),

View File

@@ -0,0 +1,4 @@
export { useRemoteControl } from "./useRemoteControl";
export { useVideoNavigation } from "./useVideoNavigation";
export { useVideoSlider } from "./useVideoSlider";
export { useVideoTime } from "./useVideoTime";

View File

@@ -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,
};
};

View 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,
};
}

View File

@@ -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,
};
};

View File

@@ -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,
};
};
}

View File

@@ -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,
};
};

View File

@@ -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,
};
};
}

View File

@@ -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,
};
};
}

View File

@@ -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 };
};

View File

@@ -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}`;
};

View File

@@ -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"

View File

@@ -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({

View File

@@ -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]);