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 { type AspectRatio } from "./VideoScalingModeSelector"; interface Props { item: BaseItemDto; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; progress: SharedValue; isBuffering: boolean; showControls: boolean; enableTrickplay?: boolean; togglePlay: () => void; setShowControls: (shown: boolean) => void; offline?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; startPictureInPicture?: () => Promise; play: () => void; pause: () => void; setVideoAspectRatio?: (aspectRatio: string | null) => Promise; aspectRatio?: AspectRatio; isZoomedToFill?: boolean; onZoomToggle?: () => void; api?: Api | null; downloadedFiles?: DownloadedItem[]; } export const Controls: FC = ({ item, seek, startPictureInPicture, play, pause, togglePlay, isPlaying, isSeeking, progress, isBuffering, cacheProgress, showControls, setShowControls, mediaSource, setVideoAspectRatio, aspectRatio = "default", isZoomedToFill = false, onZoomToggle, offline = 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 - 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 ( {episodeView ? ( setEpisodeView(false)} goToItem={goToItemCommon} /> ) : ( <> )} {settings.maxAutoPlayEpisodeCount.value !== -1 && ( )} ); }; const styles = StyleSheet.create({ controlsContainer: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, }, });