mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local> Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com> Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
549 lines
16 KiB
TypeScript
549 lines
16 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 FC, useCallback, useEffect, useState } from "react";
|
|
import { StyleSheet, useWindowDimensions, View } 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 { 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 { 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 { useControlsTimeout } from "./useControlsTimeout";
|
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
|
|
|
interface Props {
|
|
item: BaseItemDto;
|
|
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;
|
|
mediaSource?: MediaSourceInfo | null;
|
|
seek: (ticks: number) => void;
|
|
startPictureInPicture?: () => Promise<void>;
|
|
play: () => void;
|
|
pause: () => void;
|
|
aspectRatio?: AspectRatio;
|
|
isZoomedToFill?: boolean;
|
|
onZoomToggle?: () => void;
|
|
api?: Api | null;
|
|
downloadedFiles?: DownloadedItem[];
|
|
// Playback speed props
|
|
playbackSpeed?: number;
|
|
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
|
}
|
|
|
|
export const Controls: FC<Props> = ({
|
|
item,
|
|
seek,
|
|
startPictureInPicture,
|
|
play,
|
|
pause,
|
|
togglePlay,
|
|
isPlaying,
|
|
isSeeking,
|
|
progress,
|
|
isBuffering,
|
|
cacheProgress,
|
|
showControls,
|
|
setShowControls,
|
|
mediaSource,
|
|
aspectRatio = "default",
|
|
isZoomedToFill = false,
|
|
onZoomToggle,
|
|
offline = false,
|
|
api = null,
|
|
downloadedFiles = undefined,
|
|
playbackSpeed = 1.0,
|
|
setPlaybackSpeed,
|
|
}) => {
|
|
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(ticksToMs(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 - MPV uses milliseconds
|
|
useEffect(() => {
|
|
if (item) {
|
|
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
|
max.value = ticksToMs(item.RunTimeTicks || 0);
|
|
}
|
|
}, [item, progress, max]);
|
|
|
|
// Navigation hooks
|
|
const {
|
|
handleSeekBackward,
|
|
handleSeekForward,
|
|
handleSkipBackward,
|
|
handleSkipForward,
|
|
} = useVideoNavigation({
|
|
progress,
|
|
isPlaying,
|
|
seek,
|
|
play,
|
|
});
|
|
|
|
// Time management hook
|
|
const { currentTime, remainingTime } = useVideoTime({
|
|
progress,
|
|
max,
|
|
isSeeking,
|
|
});
|
|
|
|
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,
|
|
showControls,
|
|
isPlaying,
|
|
seek,
|
|
play,
|
|
togglePlay,
|
|
toggleControls,
|
|
calculateTrickplayUrl,
|
|
handleSeekForward,
|
|
handleSeekBackward,
|
|
});
|
|
|
|
// Slider hook
|
|
const {
|
|
isSliding,
|
|
time,
|
|
handleSliderStart,
|
|
handleTouchStart,
|
|
handleTouchEnd,
|
|
handleSliderComplete,
|
|
handleSliderChange,
|
|
} = useVideoSlider({
|
|
progress,
|
|
isSeeking,
|
|
isPlaying,
|
|
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)
|
|
// MPV uses milliseconds
|
|
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
|
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,
|
|
offline,
|
|
api,
|
|
downloadedFiles,
|
|
);
|
|
|
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
|
useCreditSkipper(
|
|
item.Id!,
|
|
currentTime,
|
|
seek,
|
|
play,
|
|
offline,
|
|
api,
|
|
downloadedFiles,
|
|
max.value,
|
|
);
|
|
|
|
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, {
|
|
indexes: previousIndexes,
|
|
source: 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();
|
|
|
|
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,
|
|
disabled: true,
|
|
});
|
|
|
|
const switchOnEpisodeMode = useCallback(() => {
|
|
setEpisodeView(true);
|
|
if (isPlaying) {
|
|
togglePlay();
|
|
}
|
|
}, [isPlaying, togglePlay]);
|
|
|
|
return (
|
|
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
|
{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}
|
|
aspectRatio={aspectRatio}
|
|
isZoomedToFill={isZoomedToFill}
|
|
onZoomToggle={onZoomToggle}
|
|
playbackSpeed={playbackSpeed}
|
|
setPlaybackSpeed={setPlaybackSpeed}
|
|
/>
|
|
</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}
|
|
showSkipButton={showSkipButton}
|
|
showSkipCreditButton={showSkipCreditButton}
|
|
hasContentAfterCredits={hasContentAfterCredits}
|
|
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} />
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
controlsContainer: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
},
|
|
});
|