import classNames from "classnames"; import { PointerEvent, useCallback, useEffect, useRef, useState } from "react"; import { useEffectOnce, useTimeoutFn } from "react-use"; import { Seek, SeekDirection } from "@/components/player/atoms/Seek"; import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; import { useOverlayStack } from "@/stores/interface/overlayStack"; import { PlayerHoverState } from "@/stores/player/slices/interface"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { useWatchPartyStore } from "@/stores/watchParty"; export function VideoClickTarget(props: { showingControls: boolean }) { const show = useShouldShowVideoElement(); const display = usePlayerStore((s) => s.display); const time = usePlayerStore((s) => s.progress.time); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const updateInterfaceHovering = usePlayerStore( (s) => s.updateInterfaceHovering, ); const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted); const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator); const hovering = usePlayerStore((s) => s.interface.hovering); const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); const isInWatchParty = useWatchPartyStore((s) => s.enabled); const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); const enableDoubleClickToSeek = usePreferencesStore( (s) => s.enableDoubleClickToSeek, ); const [_, cancel, reset] = useTimeoutFn(() => { updateInterfaceHovering(PlayerHoverState.NOT_HOVERING); }, 3000); useEffectOnce(() => { cancel(); }); const previousRateRef = useRef(playbackRate); const isHoldingRef = useRef(false); const speedIndicatorTimeoutRef = useRef(null); const boostTimeoutRef = useRef(null); const [isPendingBoost, setIsPendingBoost] = useState(false); const [seekDirection, setSeekDirection] = useState( null, ); const [seekId, setSeekId] = useState(0); const [isSeeking, setIsSeeking] = useState(false); const seekTimeoutRef = useRef(null); const singleTapTimeout = useRef(null); const toggleFullscreen = useCallback(() => { display?.toggleFullscreen(); }, [display]); const handleDoubleClick = useCallback( (e: PointerEvent) => { if (!enableDoubleClickToSeek) { toggleFullscreen(); return; } const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const oneThird = rect.width / 3; if (x < oneThird) { display?.setTime(time - 10); setSeekDirection("backward"); setSeekId((s) => s + 1); setIsSeeking(true); } else if (x > oneThird * 2) { display?.setTime(time + 10); setSeekDirection("forward"); setSeekId((s) => s + 1); setIsSeeking(true); } else { toggleFullscreen(); } }, [display, toggleFullscreen, enableDoubleClickToSeek, time], ); useEffect(() => { if (isSeeking) { if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); } seekTimeoutRef.current = setTimeout(() => { setIsSeeking(false); }, 400); } }, [seekId, isSeeking]); const togglePause = useCallback( (e: PointerEvent) => { // Don't toggle pause if holding for speed change if (isHoldingRef.current) { isHoldingRef.current = false; return; } // Cancel any pending boost if we're clicking to pause if (isPendingBoost) { clearTimeout(boostTimeoutRef.current!); setIsPendingBoost(false); isHoldingRef.current = false; } // pause on mouse click if (e.pointerType === "mouse") { if (e.button !== 0) return; if (isPaused) display?.play(); else display?.pause(); return; } // toggle on other types of clicks if (isSeeking) return; if (hovering !== PlayerHoverState.MOBILE_TAPPED) { updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED); reset(); } else { updateInterfaceHovering(PlayerHoverState.NOT_HOVERING); cancel(); } }, [ display, isPaused, hovering, updateInterfaceHovering, reset, cancel, isPendingBoost, isSeeking, ], ); const handleTap = useCallback( (e: PointerEvent) => { if (e.pointerType === "mouse" && e.button !== 0) return; if (singleTapTimeout.current) { clearTimeout(singleTapTimeout.current); singleTapTimeout.current = null; handleDoubleClick(e); } else { if (!enableDoubleClickToSeek) { togglePause(e); } singleTapTimeout.current = setTimeout(() => { if (enableDoubleClickToSeek) { togglePause(e); } singleTapTimeout.current = null; }, 250); } }, [handleDoubleClick, togglePause, enableDoubleClickToSeek], ); const handlePointerDown = useCallback( (e: PointerEvent) => { if ( ((e.pointerType === "mouse" && e.button === 0) || e.pointerType === "touch") && !isInWatchParty && enableHoldToBoost ) { if (isPaused) return; // Don't boost if video is paused // Store current rate before changing previousRateRef.current = playbackRate; // Set a timeout before actually boosting speed if (boostTimeoutRef.current) { clearTimeout(boostTimeoutRef.current); } setIsPendingBoost(true); boostTimeoutRef.current = setTimeout(() => { // Only apply boost if we're still holding down isHoldingRef.current = true; setIsPendingBoost(false); // Show speed indicator setSpeedBoosted(true); setShowSpeedIndicator(true); setCurrentOverlay("speed"); if (speedIndicatorTimeoutRef.current) { clearTimeout(speedIndicatorTimeoutRef.current); } // Set to 2x speed display?.setPlaybackRate(2); }, 300); // 300ms delay before boost takes effect } }, [ display, playbackRate, isPaused, setSpeedBoosted, setShowSpeedIndicator, setCurrentOverlay, isInWatchParty, enableHoldToBoost, ], ); const handlePointerUp = useCallback( (e: PointerEvent) => { // If we have a pending boost that hasn't activated yet, clear it if (isPendingBoost) { clearTimeout(boostTimeoutRef.current!); setIsPendingBoost(false); handleTap(e); return; } if ( isHoldingRef.current && enableHoldToBoost && ((e.pointerType === "mouse" && e.button === 0) || e.pointerType === "touch") ) { // Restore previous rate display?.setPlaybackRate(previousRateRef.current); isHoldingRef.current = false; // Update state for speed indicator setSpeedBoosted(false); // Set a timeout to hide the speed indicator if (speedIndicatorTimeoutRef.current) { clearTimeout(speedIndicatorTimeoutRef.current); } speedIndicatorTimeoutRef.current = setTimeout(() => { setShowSpeedIndicator(false); setCurrentOverlay(null); speedIndicatorTimeoutRef.current = null; }, 1500); } else { // Regular click handler handleTap(e); } }, [ display, handleTap, setSpeedBoosted, setShowSpeedIndicator, setCurrentOverlay, isPendingBoost, enableHoldToBoost, ], ); // Handle case where mouse leaves the player while still pressed const handlePointerLeave = useCallback(() => { // Clear pending boost if mouse leaves if (isPendingBoost) { clearTimeout(boostTimeoutRef.current!); setIsPendingBoost(false); } if (isHoldingRef.current) { display?.setPlaybackRate(previousRateRef.current); isHoldingRef.current = false; // Update state for speed indicator setSpeedBoosted(false); // Set a timeout to hide the speed indicator if (speedIndicatorTimeoutRef.current) { clearTimeout(speedIndicatorTimeoutRef.current); } speedIndicatorTimeoutRef.current = setTimeout(() => { setShowSpeedIndicator(false); setCurrentOverlay(null); speedIndicatorTimeoutRef.current = null; }, 1500); } }, [ display, setSpeedBoosted, setShowSpeedIndicator, setCurrentOverlay, isPendingBoost, ]); if (!show) return null; return ( <> {seekDirection ? (
setSeekDirection(null)} className={ seekDirection === "backward" ? "absolute inset-0 flex items-center justify-start ml-32" : "absolute inset-0 flex items-center justify-end mr-32" } >
) : null}
e.preventDefault()} /> ); }