mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-12 04:10:54 +00:00
325 lines
9.6 KiB
TypeScript
325 lines
9.6 KiB
TypeScript
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<NodeJS.Timeout | null>(null);
|
|
const boostTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [isPendingBoost, setIsPendingBoost] = useState(false);
|
|
const [seekDirection, setSeekDirection] = useState<SeekDirection | null>(
|
|
null,
|
|
);
|
|
const [seekId, setSeekId] = useState(0);
|
|
const [isSeeking, setIsSeeking] = useState(false);
|
|
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const singleTapTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const toggleFullscreen = useCallback(() => {
|
|
display?.toggleFullscreen();
|
|
}, [display]);
|
|
|
|
const handleDoubleClick = useCallback(
|
|
(e: PointerEvent<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
// 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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
// 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 ? (
|
|
<div
|
|
key={seekId}
|
|
onAnimationEnd={() => 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"
|
|
}
|
|
>
|
|
<Seek direction={seekDirection} />
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className={classNames("absolute inset-0", {
|
|
"absolute inset-0": true,
|
|
"cursor-none": !props.showingControls,
|
|
})}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerLeave}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|