styling of video player controls

This commit is contained in:
Jelle van Snik 2023-01-09 21:51:24 +01:00
parent 098f6af0ae
commit 024325f640
11 changed files with 302 additions and 120 deletions

View File

@ -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<Icons, string> = {
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" style="transform: translateX(5%)" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>`,
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>`,
expand: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"/></svg>`,
compress: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/></svg>`,
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
};
export function Icon(props: IconProps) {

View File

@ -11,12 +11,19 @@ export function DecoratedVideoPlayer(props: VideoPlayerProps) {
return (
<VideoPlayer autoPlay={props.autoPlay}>
<BackdropControl>
<PauseControl />
<FullscreenControl />
<ProgressControl />
<VolumeControl />
<LoadingControl />
<TimeControl />
<div className="absolute inset-0 flex items-center justify-center">
<LoadingControl />
</div>
<div className="pointer-events-auto absolute inset-x-0 bottom-0 mb-4 flex flex-col px-6">
<ProgressControl />
<div className="flex items-center">
<PauseControl />
<VolumeControl className="mr-2" />
<TimeControl />
<div className="flex-1" />
<FullscreenControl />
</div>
</div>
</BackdropControl>
{props.children}
</VideoPlayer>

View File

@ -31,7 +31,7 @@ export function VideoPlayer(props: VideoPlayerProps) {
return (
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}>
<div
className="relative aspect-video w-full bg-black"
className="relative aspect-video w-full select-none bg-black"
ref={playerWrapperRef}
>
<VideoPlayerInternals

View File

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import { useVideoPlayerState } from "../VideoContext";
interface BackdropControlProps {
@ -9,6 +9,7 @@ export function BackdropControl(props: BackdropControlProps) {
const { videoState } = useVideoPlayerState();
const [moved, setMoved] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const clickareaRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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) {
<div
className={`absolute inset-0 ${!showUI ? "cursor-none" : ""}`}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
ref={clickareaRef}
onClick={handleClick}
>
<div
className={`absolute inset-0 bg-black bg-opacity-20 transition-opacity duration-200 ${
className={`pointer-events-none absolute inset-0 bg-black bg-opacity-20 transition-opacity duration-200 ${
!showUI ? "!opacity-0" : ""
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 h-[30%] bg-gradient-to-t from-black to-transparent opacity-75 transition-opacity duration-200 ${
className={`pointer-events-none absolute inset-x-0 bottom-0 h-[30%] bg-gradient-to-t from-black to-transparent opacity-75 transition-opacity duration-200 ${
!showUI ? "!opacity-0" : ""
}`}
/>
<div
className={`absolute inset-x-0 top-0 h-[30%] bg-gradient-to-b from-black to-transparent opacity-75 transition-opacity duration-200 ${
className={`pointer-events-none absolute inset-x-0 top-0 h-[30%] bg-gradient-to-b from-black to-transparent opacity-75 transition-opacity duration-200 ${
!showUI ? "!opacity-0" : ""
}`}
/>
<div className="absolute inset-0">{showUI ? props.children : null}</div>
<div className="pointer-events-none absolute inset-0">
{showUI ? props.children : null}
</div>
</div>
);
}

View File

@ -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 (
<button
className="m-1 rounded bg-denim-100 p-1 text-white hover:opacity-75"
type="button"
<VideoPlayerIconButton
className={props.className}
onClick={handleClick}
>
{text}
</button>
icon={videoState.isFullscreen ? Icons.COMPRESS : Icons.EXPAND}
/>
);
}

View File

@ -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 (
<button
className="m-1 rounded bg-denim-100 p-1 text-white hover:opacity-75"
type="button"
<VideoPlayerIconButton
className={props.className}
icon={icon}
onClick={handleClick}
>
{text}
</button>
/>
);
}

View File

@ -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<HTMLDivElement>(null);
const [mouseDown, setMouseDown] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(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 (
<div
ref={ref}
className="relative m-1 my-4 h-4 w-48 overflow-hidden rounded-full border border-white bg-denim-100"
onMouseDown={handleMouseDown}
>
<div className="group pointer-events-auto cursor-pointer rounded-full px-2">
<div
className="absolute inset-y-0 left-0 bg-denim-700 opacity-50"
style={{
width: bufferProgress,
}}
/>
<div
className="absolute inset-y-0 left-0 bg-denim-700"
style={{
width: watchProgress,
}}
/>
ref={ref}
className="-my-3 flex h-8 items-center"
onMouseDown={dragMouseDown}
>
<div
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${
dragging ? "!h-2" : ""
}`}
>
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-gray-300 bg-opacity-50"
style={{
width: bufferProgress,
}}
/>
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-500"
style={{
width: watchProgress,
}}
>
<div
className={`absolute h-1 w-1 translate-x-1/2 rounded-full bg-white opacity-0 transition-[transform,opacity] group-hover:scale-[400%] group-hover:opacity-100 ${
dragging ? "!scale-[400%] !opacity-100" : ""
}`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<p>
{time} / {duration}
</p>
<div className={props.className}>
<p className="select-none text-white">
{time} / {duration}
</p>
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
const [storedVolume, setStoredVolume] = useState(1);
const [hoveredOnce, setHoveredOnce] = useState(false);
const percentage = `${(videoState.volume * 100).toFixed(2)}%`;
const handleClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
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 (
<div
ref={ref}
className="relative m-1 my-4 h-4 w-48 overflow-hidden rounded-full border border-white bg-bink-300"
onClick={handleClick}
>
<div className={props.className}>
<div
className="absolute inset-y-0 left-0 bg-bink-700"
style={{
width: percentage,
}}
/>
className="pointer-events-auto flex cursor-pointer items-center"
onMouseEnter={handleMouseEnter}
>
<div className="px-4 text-2xl text-white" onClick={handleClick}>
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} />
</div>
<div
className={`-ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in ${
hoveredOnce ? "!w-20 opacity-100" : "w-4 opacity-0"
}`}
>
<div
ref={ref}
className="flex h-10 w-16 items-center px-2"
onMouseDown={dragMouseDown}
>
<div className="relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50">
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-500"
style={{
width: percentageString,
}}
>
<div className="absolute h-3 w-3 translate-x-1/2 rounded-full bg-white" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { Icon, Icons } from "@/components/Icon";
import React from "react";
export interface VideoPlayerIconButtonProps {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
icon: Icons;
text?: string;
className?: string;
}
export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) {
return (
<div className={props.className}>
<button
type="button"
onClick={props.onClick}
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110"
>
<div className="flex items-center justify-center rounded-full bg-white bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-20">
<Icon icon={props.icon} className="text-2xl" />
{props.text ? <span className="ml-2">{props.text}</span> : null}
</div>
</button>
</div>
);
}

View File

@ -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<HTMLElement>,
commit: (percentage: number) => void,
commitImmediately = false
) {
const [mouseDown, setMouseDown] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(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<HTMLElement>) => {
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,
};
}