mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-20 16:26:24 +00:00
Improves type safety throughout the codebase by eliminating unsafe `any` type assertions and replacing them with proper type definitions. Adds explicit type parameters and constraints to MMKV augmentations, component props, and router navigation calls. Updates function signatures to use `unknown` instead of `any` where appropriate, and properly types Icon glyphs, router Href parameters, and component prop spreads. Enhances maintainability and catches potential type errors at compile time rather than runtime.
588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
import { Api } from "@jellyfin/sdk";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
import { type Href, 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 Href);
|
|
},
|
|
[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>
|
|
);
|
|
};
|