mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-28 23:12:23 +00:00
Removes extensive dead code including unused components, utilities, and augmentations that were no longer referenced in the codebase. Simplifies play settings logic by removing complex stream ranking algorithm in favor of direct previous index matching for audio and subtitle selections. Removes aspectRatio prop from video player as it was set to a constant "default" value and never changed. Inlines POSTER_CAROUSEL_HEIGHT constant directly where used instead of importing from centralized constants file. Eliminates unused features including image color extraction for TV platforms, M3U8 subtitle parsing, and various Jellyfin API helpers that were no longer needed. Cleans up credential management by making internal helper functions private that should not be exposed to external consumers.
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
import { Api } from "@jellyfin/sdk";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
import { useLocalSearchParams } 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 useRouter from "@/hooks/useAppRouter";
|
|
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 { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
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";
|
|
|
|
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;
|
|
mediaSource?: MediaSourceInfo | null;
|
|
seek: (ticks: number) => void;
|
|
startPictureInPicture?: () => Promise<void>;
|
|
play: () => void;
|
|
pause: () => void;
|
|
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,
|
|
isZoomedToFill = false,
|
|
onZoomToggle,
|
|
api = null,
|
|
downloadedFiles = undefined,
|
|
playbackSpeed = 1.0,
|
|
setPlaybackSpeed,
|
|
}) => {
|
|
const offline = useOfflineMode();
|
|
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);
|
|
// Regular value for use during render (avoids Reanimated warning)
|
|
const maxMs = ticksToMs(item.RunTimeTicks || 0);
|
|
const max = useSharedValue(maxMs);
|
|
|
|
// 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,
|
|
maxMs,
|
|
);
|
|
|
|
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}
|
|
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,
|
|
},
|
|
});
|