Files
streamyfin/components/video-player/controls/Controls.tsx
Gauvain ead37aa806
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (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
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
fix: resolve type issues and improve component reliability (#1078)
2025-09-25 22:47:49 +02:00

588 lines
17 KiB
TypeScript

import { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useRouter } from "expo-router";
import {
type Dispatch,
type FC,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { useWindowDimensions } from "react-native";
import Animated, {
Easing,
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 { DownloadedItem } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings";
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 { GestureOverlay } from "./GestureOverlay";
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 { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise<void>;
play: () => void;
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
isVlc?: boolean;
api?: Api | null;
downloadedFiles?: DownloadedItem[];
}
export const Controls: FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
scaleFactor = 1.0,
setAspectRatio,
setScaleFactor,
offline = false,
isVlc = false,
api = null,
downloadedFiles = undefined,
}) => {
const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
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);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
// Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
const headerTranslateY = useSharedValue(showControls ? 0 : -50);
const bottomTranslateY = useSharedValue(showControls ? 0 : 50);
useEffect(() => {
prefetchAllTrickplayImages();
}, [prefetchAllTrickplayImages]);
// Animate controls visibility
useEffect(() => {
const animationConfig = {
duration: 300,
easing: Easing.out(Easing.quad),
};
controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig);
headerTranslateY.value = withTiming(
showControls ? 0 : -10,
animationConfig,
);
bottomTranslateY.value = withTiming(showControls ? 0 : 10, animationConfig);
}, [showControls, controlsOpacity, headerTranslateY, bottomTranslateY]);
// Create animated styles
const headerAnimatedStyle = useAnimatedStyle(() => ({
opacity: controlsOpacity.value,
transform: [{ translateY: headerTranslateY.value }],
position: "absolute" as const,
top: 0,
left: 0,
right: 0,
zIndex: 10,
}));
const centerAnimatedStyle = useAnimatedStyle(() => ({
opacity: controlsOpacity.value,
position: "absolute" as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
}));
const bottomAnimatedStyle = useAnimatedStyle(() => ({
opacity: controlsOpacity.value,
transform: [{ translateY: bottomTranslateY.value }],
position: "absolute" as const,
bottom: 0,
left: 0,
right: 0,
zIndex: 10,
}));
// Initialize progress values
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc, progress, max]);
// Navigation hooks
const {
handleSeekBackward,
handleSeekForward,
handleSkipBackward,
handleSkipForward,
} = useVideoNavigation({
progress,
isPlaying,
isVlc,
seek,
play,
});
// Time management hook
const { currentTime, remainingTime } = useVideoTime({
progress,
max,
isSeeking,
isVlc,
});
const toggleControls = useCallback(() => {
if (showControls) {
setShowAudioSlider(false);
setShowControls(false);
} else {
setShowControls(true);
}
}, [showControls, setShowControls]);
// Remote control hook
const {
remoteScrubProgress,
isRemoteScrubbing,
showRemoteBubble,
isSliding: isRemoteSliding,
time: remoteTime,
} = useRemoteControl({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
seek,
play,
togglePlay,
toggleControls,
calculateTrickplayUrl,
handleSeekForward,
handleSeekBackward,
});
// 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,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item?.Id!,
currentTime,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
);
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);
router.replace(`player/direct-player?${queryParams}` as any);
},
[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);
}, [setShowControls]);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding: isSliding || isRemoteSliding,
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
});
const switchOnEpisodeMode = useCallback(() => {
setEpisodeView(true);
if (isPlaying) {
togglePlay();
}
}, [isPlaying, togglePlay]);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItemCommon}
/>
) : (
<>
<GestureOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
<Animated.View
style={headerAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<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}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
/>
</Animated.View>
<Animated.View
style={centerAnimatedStyle}
pointerEvents={showControls ? "box-none" : "none"}
>
<CenterControls
showControls={showControls}
isPlaying={isPlaying}
isBuffering={isBuffering}
showAudioSlider={showAudioSlider}
setShowAudioSlider={setShowAudioSlider}
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
/>
</Animated.View>
<Animated.View
style={bottomAnimatedStyle}
pointerEvents={showControls ? "auto" : "none"}
>
<BottomControls
item={item}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
isVlc={isVlc}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction}
min={min}
max={max}
effectiveProgress={effectiveProgress}
cacheProgress={cacheProgress}
handleSliderStart={handleSliderStart}
handleSliderComplete={handleSliderComplete}
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
/>
</Animated.View>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)}
</ControlProvider>
);
};