diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 65d47891..45ea593b 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -14,6 +14,12 @@ export enum Icons { MOVIE_WEB = "movieWeb", DISCORD = "discord", GITHUB = "github", + PLAY = "play", + PAUSE = "pause", + EXPAND = "expand", + COMPRESS = "compress", + VOLUME = "volume", + VOLUME_X = "volume_x", } export interface IconProps { @@ -37,6 +43,12 @@ const iconList: Record = { movieWeb: ``, discord: ``, github: ``, + play: ``, + pause: ``, + expand: ``, + compress: ``, + volume: ``, + volume_x: ``, }; export function Icon(props: IconProps) { diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index 8c1af036..3731c5a6 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -11,12 +11,19 @@ export function DecoratedVideoPlayer(props: VideoPlayerProps) { return ( - - - - - - +
+ +
+
+ +
+ + + +
+ +
+
{props.children} diff --git a/src/components/video/VideoPlayer.tsx b/src/components/video/VideoPlayer.tsx index 4222365d..89f632e4 100644 --- a/src/components/video/VideoPlayer.tsx +++ b/src/components/video/VideoPlayer.tsx @@ -31,7 +31,7 @@ export function VideoPlayer(props: VideoPlayerProps) { return (
| null>(null); + const clickareaRef = useRef(null); const handleMouseMove = useCallback(() => { setMoved(true); @@ -19,10 +20,19 @@ export function BackdropControl(props: BackdropControlProps) { }, 3000); }, [timeout, setMoved]); - const handleClick = useCallback(() => { - if (videoState.isPlaying) videoState.pause(); - else videoState.play(); - }, [videoState]); + const handleMouseLeave = useCallback(() => { + setMoved(false); + }, [setMoved]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!clickareaRef.current || clickareaRef.current !== e.target) return; + + if (videoState.isPlaying) videoState.pause(); + else videoState.play(); + }, + [videoState, clickareaRef] + ); const showUI = moved || videoState.isPaused; @@ -30,24 +40,28 @@ export function BackdropControl(props: BackdropControlProps) {
-
{showUI ? props.children : null}
+
+ {showUI ? props.children : null} +
); } diff --git a/src/components/video/controls/FullscreenControl.tsx b/src/components/video/controls/FullscreenControl.tsx index c045ceb6..67b1e2e4 100644 --- a/src/components/video/controls/FullscreenControl.tsx +++ b/src/components/video/controls/FullscreenControl.tsx @@ -1,9 +1,15 @@ +import { Icons } from "@/components/Icon"; import { useCallback } from "react"; +import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; import { useVideoPlayerState } from "../VideoContext"; const canFullscreen = document.fullscreenEnabled; -export function FullscreenControl() { +interface Props { + className?: string; +} + +export function FullscreenControl(props: Props) { const { videoState } = useVideoPlayerState(); const handleClick = useCallback(() => { @@ -13,16 +19,11 @@ export function FullscreenControl() { if (!canFullscreen) return null; - let text = "not fullscreen"; - if (videoState.isFullscreen) text = "in fullscreen"; - return ( - + icon={videoState.isFullscreen ? Icons.COMPRESS : Icons.EXPAND} + /> ); } diff --git a/src/components/video/controls/PauseControl.tsx b/src/components/video/controls/PauseControl.tsx index 5b2c4466..e528469b 100644 --- a/src/components/video/controls/PauseControl.tsx +++ b/src/components/video/controls/PauseControl.tsx @@ -1,7 +1,13 @@ +import { Icons } from "@/components/Icon"; import { useCallback } from "react"; +import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; import { useVideoPlayerState } from "../VideoContext"; -export function PauseControl() { +interface Props { + className?: string; +} + +export function PauseControl(props: Props) { const { videoState } = useVideoPlayerState(); const handleClick = useCallback(() => { @@ -9,16 +15,14 @@ export function PauseControl() { else videoState.play(); }, [videoState]); - const text = - videoState.isPlaying || videoState.isSeeking ? "playing" : "paused"; + const icon = + videoState.isPlaying || videoState.isSeeking ? Icons.PAUSE : Icons.PLAY; return ( - + /> ); } diff --git a/src/components/video/controls/ProgressControl.tsx b/src/components/video/controls/ProgressControl.tsx index 4d423845..2b6c6777 100644 --- a/src/components/video/controls/ProgressControl.tsx +++ b/src/components/video/controls/ProgressControl.tsx @@ -1,74 +1,68 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + makePercentage, + makePercentageString, + useProgressBar, +} from "@/hooks/useProgressBar"; +import { useCallback, useRef } from "react"; import { useVideoPlayerState } from "../VideoContext"; export function ProgressControl() { const { videoState } = useVideoPlayerState(); const ref = useRef(null); - const [mouseDown, setMouseDown] = useState(false); - const [progress, setProgress] = useState(0); - let watchProgress = `${( - (videoState.time / videoState.duration) * - 100 - ).toFixed(2)}%`; - if (mouseDown) watchProgress = `${progress}%`; + const commitTime = useCallback( + (percentage) => { + videoState.setTime(percentage * videoState.duration); + }, + [videoState] + ); + const { dragging, dragPercentage, dragMouseDown } = useProgressBar( + ref, + commitTime + ); - const bufferProgress = `${( - (videoState.buffered / videoState.duration) * - 100 - ).toFixed(2)}%`; + let watchProgress = makePercentageString( + makePercentage((videoState.time / videoState.duration) * 100) + ); + if (dragging) + watchProgress = makePercentageString(makePercentage(dragPercentage)); - useEffect(() => { - function mouseMove(ev: MouseEvent) { - if (!mouseDown || !ref.current) return; - const rect = ref.current.getBoundingClientRect(); - const pos = ((ev.pageX - rect.left) / ref.current.offsetWidth) * 100; - setProgress(pos); - } - - function mouseUp(ev: MouseEvent) { - if (!mouseDown) return; - setMouseDown(false); - document.body.removeAttribute("data-no-select"); - - if (!ref.current) return; - const rect = ref.current.getBoundingClientRect(); - const pos = (ev.pageX - rect.left) / ref.current.offsetWidth; - videoState.setTime(pos * videoState.duration); - } - - document.addEventListener("mousemove", mouseMove); - document.addEventListener("mouseup", mouseUp); - - return () => { - document.removeEventListener("mousemove", mouseMove); - document.removeEventListener("mouseup", mouseUp); - }; - }, [mouseDown, videoState]); - - const handleMouseDown = useCallback(() => { - setMouseDown(true); - document.body.setAttribute("data-no-select", "true"); - }, []); + const bufferProgress = makePercentageString( + makePercentage((videoState.buffered / videoState.duration) * 100) + ); return ( -
+
-
+ ref={ref} + className="-my-3 flex h-8 items-center" + onMouseDown={dragMouseDown} + > +
+
+
+
+
+
+
); } diff --git a/src/components/video/controls/TimeControl.tsx b/src/components/video/controls/TimeControl.tsx index 3cf151ea..35d86351 100644 --- a/src/components/video/controls/TimeControl.tsx +++ b/src/components/video/controls/TimeControl.tsx @@ -22,15 +22,21 @@ function formatSeconds(secs: number, showHours = false): string { return `${Math.round(hours).toString()}:${minuteString}`; } -export function TimeControl() { +interface Props { + className?: string; +} + +export function TimeControl(props: Props) { const { videoState } = useVideoPlayerState(); const hasHours = durationExceedsHour(videoState.duration); const time = formatSeconds(videoState.time, hasHours); const duration = formatSeconds(videoState.duration, hasHours); return ( -

- {time} / {duration} -

+
+

+ {time} / {duration} +

+
); } diff --git a/src/components/video/controls/VolumeControl.tsx b/src/components/video/controls/VolumeControl.tsx index 92499ee6..ea30e536 100644 --- a/src/components/video/controls/VolumeControl.tsx +++ b/src/components/video/controls/VolumeControl.tsx @@ -1,34 +1,86 @@ -import { useCallback, useRef } from "react"; +import { Icon, Icons } from "@/components/Icon"; +import { + makePercentage, + makePercentageString, + useProgressBar, +} from "@/hooks/useProgressBar"; +import { useCallback, useRef, useState } from "react"; import { useVideoPlayerState } from "../VideoContext"; -export function VolumeControl() { +interface Props { + className?: string; +} + +// TODO make hoveredOnce false when control bar appears + +export function VolumeControl(props: Props) { const { videoState } = useVideoPlayerState(); const ref = useRef(null); + const [storedVolume, setStoredVolume] = useState(1); + const [hoveredOnce, setHoveredOnce] = useState(false); - const percentage = `${(videoState.volume * 100).toFixed(2)}%`; - - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!ref.current) return; - const rect = ref.current.getBoundingClientRect(); - const pos = (e.pageX - rect.left) / ref.current.offsetWidth; - videoState.setVolume(pos); + const commitVolume = useCallback( + (percentage) => { + videoState.setVolume(percentage); + setStoredVolume(percentage); }, - [videoState, ref] + [videoState, setStoredVolume] + ); + const { dragging, dragPercentage, dragMouseDown } = useProgressBar( + ref, + commitVolume, + true ); + const handleClick = useCallback(() => { + if (videoState.volume > 0) { + videoState.setVolume(0); + setStoredVolume(videoState.volume); + } else { + videoState.setVolume(storedVolume > 0 ? storedVolume : 1); + } + }, [videoState, setStoredVolume, storedVolume]); + + const handleMouseEnter = useCallback(() => { + setHoveredOnce(true); + }, [setHoveredOnce]); + + let percentage = makePercentage(videoState.volume * 100); + if (dragging) percentage = makePercentage(dragPercentage); + const percentageString = makePercentageString(percentage); + return ( -
+
+ className="pointer-events-auto flex cursor-pointer items-center" + onMouseEnter={handleMouseEnter} + > +
+ 0 ? Icons.VOLUME : Icons.VOLUME_X} /> +
+
+
+
+
+
+
+
+
+
+
); } diff --git a/src/components/video/parts/VideoPlayerIconButton.tsx b/src/components/video/parts/VideoPlayerIconButton.tsx new file mode 100644 index 00000000..610550bb --- /dev/null +++ b/src/components/video/parts/VideoPlayerIconButton.tsx @@ -0,0 +1,26 @@ +import { Icon, Icons } from "@/components/Icon"; +import React from "react"; + +export interface VideoPlayerIconButtonProps { + onClick?: (e: React.MouseEvent) => void; + icon: Icons; + text?: string; + className?: string; +} + +export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) { + return ( +
+ +
+ ); +} diff --git a/src/hooks/useProgressBar.ts b/src/hooks/useProgressBar.ts new file mode 100644 index 00000000..d0fee788 --- /dev/null +++ b/src/hooks/useProgressBar.ts @@ -0,0 +1,66 @@ +import React, { RefObject, useCallback, useEffect, useState } from "react"; + +export function makePercentageString(num: number) { + return `${num.toFixed(2)}%`; +} + +export function makePercentage(num: number) { + return Number(Math.max(0, Math.min(num, 100)).toFixed(2)); +} + +export function useProgressBar( + barRef: RefObject, + commit: (percentage: number) => void, + commitImmediately = false +) { + const [mouseDown, setMouseDown] = useState(false); + const [progress, setProgress] = useState(0); + + useEffect(() => { + function mouseMove(ev: MouseEvent) { + if (!mouseDown || !barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = ((ev.pageX - rect.left) / barRef.current.offsetWidth) * 100; + setProgress(pos); + if (commitImmediately) commit(pos); + } + + function mouseUp(ev: MouseEvent) { + if (!mouseDown) return; + setMouseDown(false); + document.body.removeAttribute("data-no-select"); + + if (!barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = (ev.pageX - rect.left) / barRef.current.offsetWidth; + commit(pos); + } + + document.addEventListener("mousemove", mouseMove); + document.addEventListener("mouseup", mouseUp); + + return () => { + document.removeEventListener("mousemove", mouseMove); + document.removeEventListener("mouseup", mouseUp); + }; + }, [mouseDown, barRef, commit, commitImmediately]); + + const dragMouseDown = useCallback( + (ev: React.MouseEvent) => { + setMouseDown(true); + document.body.setAttribute("data-no-select", "true"); + + if (!barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = ((ev.pageX - rect.left) / barRef.current.offsetWidth) * 100; + setProgress(pos); + }, + [setProgress, barRef] + ); + + return { + dragging: mouseDown, + dragPercentage: progress, + dragMouseDown, + }; +}