From cee1ee6e9ff19b77eaa78d53d04d67ed804a1cd1 Mon Sep 17 00:00:00 2001 From: wil Date: Mon, 14 Oct 2024 22:21:31 -0400 Subject: [PATCH] Feature/ add video 5 (#49) * add all the new buttons preliminarily, new trim scrubber ironout the positioning bugs of the Area Scrubber styling of area scrubber is now perfect trim area and trim duration toggle button works perfect now * repeat button and events works now * optimized the trimscrubber and trimduration button, fix data plumbing bug of trimdata --- .../DialogAddVideo/DialogAddVideo.tsx | 3 +- .../DialogAddVideo/QuickTrimVideoUploader.tsx | 19 +- .../TrimmerPlaybar/ButtonTrimDuration.tsx | 30 ++ .../TrimmerPlaybar/PlayProgressCursor.tsx | 2 +- .../TrimmerPlaybar/TrimAreaScrubber.tsx | 171 +++++++ .../TrimmerPlaybar/TrimmerPlaybarCore.tsx | 123 ++--- .../DialogAddVideo/TrimmerPlaybar/index.tsx | 7 +- .../TrimmerPlaybar/utilities.ts | 14 +- .../VideoControls/ButtonMute.tsx | 16 +- .../VideoControls/ButtonPlayPause.tsx | 2 +- .../VideoControls/ButtonRepeat.tsx | 75 +++ .../VideoControls/VideoControls.tsx | 9 +- src/components/ui/Button.tsx | 5 +- yarn.lock | 434 ++++++++++++------ 14 files changed, 680 insertions(+), 230 deletions(-) create mode 100644 src/components/features/DialogAddVideo/TrimmerPlaybar/ButtonTrimDuration.tsx create mode 100644 src/components/features/DialogAddVideo/TrimmerPlaybar/TrimAreaScrubber.tsx create mode 100644 src/components/features/DialogAddVideo/VideoControls/ButtonRepeat.tsx diff --git a/src/components/features/DialogAddVideo/DialogAddVideo.tsx b/src/components/features/DialogAddVideo/DialogAddVideo.tsx index 8cf21314f1..89318c3d32 100644 --- a/src/components/features/DialogAddVideo/DialogAddVideo.tsx +++ b/src/components/features/DialogAddVideo/DialogAddVideo.tsx @@ -14,7 +14,6 @@ import { ButtonSubmitAdd } from "./ButtonSumbitAdd"; import { DialogAddMediaStatuses } from "./enums"; import { LoadingScreens } from "./LoadingScreens"; -// import { useRenderCounter } from "~/hooks/useRenderCounter"; import { ApiResponse } from "~/Classes/ApiManager/ApiManager"; import { MediaFile } from "~/Classes/ApiManager/models/MediaFile"; @@ -97,7 +96,7 @@ export const DialogAddVideo = ({ onFileStaged={(file) => { setStates((curr) => ({ ...curr, file })); }} - trimData={trimData} + trimDataSignal={trimData} onTrimChange={(newTrimData: TrimData) => { trimData.value = newTrimData; }} diff --git a/src/components/features/DialogAddVideo/QuickTrimVideoUploader.tsx b/src/components/features/DialogAddVideo/QuickTrimVideoUploader.tsx index 247aee868a..d089f06397 100644 --- a/src/components/features/DialogAddVideo/QuickTrimVideoUploader.tsx +++ b/src/components/features/DialogAddVideo/QuickTrimVideoUploader.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; -import { Signal } from "@preact/signals-react"; +import { useCallback, useState, useRef } from "react"; +import { signal, Signal } from "@preact/signals-react"; import { FileUploader } from "../FileUploader"; import { VideoControls } from "./VideoControls"; @@ -14,15 +14,14 @@ export const QuickTrimVideoUploader = ({ file, onFileStaged, onTrimChange, - trimData, + trimDataSignal, }: { file: File | null; onFileStaged: (newFile: File | null) => void; onTrimChange: (trimData: TrimData) => void; - trimData?: Signal; + trimDataSignal: Signal; }) => { const [vidEl, setVidEl] = useState(undefined); - const videoRefCallback = useCallback( (node: HTMLVideoElement) => { if (node !== null) { @@ -62,10 +61,14 @@ export const QuickTrimVideoUploader = ({ src={URL.createObjectURL(file)} /> -
- +
+ void; +}) => { + const handleOnClick = () => { + if (trimDurationMs === 6000) { + onChange(3000); + return; + } + if (trimDurationMs === 3000) { + onChange(1000); + return; + } + onChange(6000); + return; + }; + return ( + + + + ); +}; diff --git a/src/components/features/DialogAddVideo/TrimmerPlaybar/PlayProgressCursor.tsx b/src/components/features/DialogAddVideo/TrimmerPlaybar/PlayProgressCursor.tsx index 048e8385bf..6c95e42b20 100644 --- a/src/components/features/DialogAddVideo/TrimmerPlaybar/PlayProgressCursor.tsx +++ b/src/components/features/DialogAddVideo/TrimmerPlaybar/PlayProgressCursor.tsx @@ -92,7 +92,7 @@ export const PlayProgressCursor = ({ className={twMerge( verticalPositionStyles, buttonStyles, - "size-5 -translate-x-1/2 rounded-full", + "z-20 size-5 -translate-x-1/2 rounded-full", isScrubbing && "cursor-grabbing", )} onMouseDown={handleScrubbingCurrentTime} diff --git a/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimAreaScrubber.tsx b/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimAreaScrubber.tsx new file mode 100644 index 0000000000..e3c604e57c --- /dev/null +++ b/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimAreaScrubber.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState, useRef } from "react"; +import { twMerge } from "tailwind-merge"; +import { faGripDots } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { formatSecondsToHHMMSSCS, ONE_MIN } from "./utilities"; +import { Tooltip } from "~/components/ui"; + +export const TrimAreaScrubber = ({ + className, + onChange, + trimStartMs, + trimDurationMs, + totalDurationMs, +}: { + className?: string; + onChange: (newPosMs: number) => void; + trimStartMs: number; + trimDurationMs: number; + totalDurationMs: number; +}) => { + const trimPosPercent = (trimStartMs / totalDurationMs) * 100; + + const [state, setState] = useState<{ + isScrubbing: boolean; + scrubPosPercent: number; + }>({ + isScrubbing: false, + scrubPosPercent: trimPosPercent, + }); + const { isScrubbing, scrubPosPercent } = state; + const containerRef = useRef(null); + const scrubberRef = useRef(null); + const mountCallback = useCallback((node: HTMLDivElement) => { + if (node) { + scrubberRef.current = node; + containerRef.current = node.parentElement; + } + }, []); + const scrubPosPercentRef = useRef(scrubPosPercent); + + const handleScrubbing = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (containerRef.current === null || scrubberRef.current === null) { + return; + } + + const calcPosition = makeTrimPositionCalculator({ + parentEl: containerRef.current, + mouseOffset: + e.clientX - + scrubberRef.current.offsetLeft - + containerRef.current.offsetLeft, + trimDurationMs: trimDurationMs, + totalDurationMs: totalDurationMs, + }); + + setState({ + isScrubbing: true, + scrubPosPercent: calcPosition(e), + }); + + const handleMouseMove = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setState({ + isScrubbing: true, + scrubPosPercent: calcPosition(e), + }); + }; + const handleMouseUp = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setState((prev) => { + scrubPosPercentRef.current = prev.scrubPosPercent; + return { + ...prev, + isScrubbing: false, + }; + }); + + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }, + [trimDurationMs, totalDurationMs], + ); + + useEffect(() => { + if (!isScrubbing) { + const newTrimMs = (scrubPosPercentRef.current / 100) * totalDurationMs; + onChange(newTrimMs); + } + }, [isScrubbing]); + + // this determines if trimmer is its taking value + //from parent's control or user's mouse movement + const positionPercent = isScrubbing ? scrubPosPercent : trimPosPercent; + const leftOffset = `${positionPercent}%`; + // the rest are values that dervied from that position + const trimStartTime = formatSecondsToHHMMSSCS( + (positionPercent / 100) * totalDurationMs, + ); + const displayedTrimStartTime = + totalDurationMs >= ONE_MIN * 60 + ? trimStartTime.substring(3, 8) + : totalDurationMs >= ONE_MIN + ? trimStartTime.substring(0, 5) + : trimStartTime.substring(3, 8); + const tip = `Trim ${Math.round(trimDurationMs / 1000)}s from ${displayedTrimStartTime}`; + + return ( + +
+ +
+
+ ); +}; + +function makeTrimPositionCalculator({ + parentEl, + mouseOffset, + trimDurationMs, + totalDurationMs, +}: { + parentEl: HTMLElement; + mouseOffset: number; + trimDurationMs: number; + totalDurationMs: number; +}) { + const parentWidth = parentEl.getBoundingClientRect().width; + const maxMs = + totalDurationMs - trimDurationMs > 0 ? totalDurationMs - trimDurationMs : 0; + const maxPercent = Math.floor((maxMs / totalDurationMs) * 10000) / 100; + + return (currE: MouseEvent | React.MouseEvent) => { + const result = + ((currE.clientX - mouseOffset - parentEl.offsetLeft) / parentWidth) * 100; + + if (result < 0) { + return 0; + } + if (result > maxPercent) { + return maxPercent; + } + return result; + }; +} diff --git a/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimmerPlaybarCore.tsx b/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimmerPlaybarCore.tsx index 7cda417477..eb833d91fa 100644 --- a/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimmerPlaybarCore.tsx +++ b/src/components/features/DialogAddVideo/TrimmerPlaybar/TrimmerPlaybarCore.tsx @@ -1,15 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { twMerge } from "tailwind-merge"; -import { - faBracketCurly, - faBracketCurlyRight, -} from "@fortawesome/pro-solid-svg-icons"; - -import { MAX_TRIM_DURATION } from "./utilities"; +import { ButtonTrimDuration } from "./ButtonTrimDuration"; import { TrimmingPlaybarLoading } from "./TrimmerPlaybarLoading"; import { PlayProgressCursor } from "./PlayProgressCursor"; -import { TrimScrubber } from "./TrimScrubber"; +import { TrimAreaScrubber } from "./TrimAreaScrubber"; + import { TrimData } from "./utilities"; export const TrimmerPlaybarCore = ({ @@ -28,18 +24,24 @@ export const TrimmerPlaybarCore = ({ currentTimeMs: number | undefined; trimStartMs: number | undefined; trimEndMs: number | undefined; + trimDurationMs: number; }>({ durationMs: undefined, currentTimeMs: undefined, trimStartMs: undefined, trimEndMs: undefined, + trimDurationMs: 6000, }); + const { durationMs, currentTimeMs, trimStartMs, trimEndMs, trimDurationMs } = + states; - const { durationMs, currentTimeMs, trimStartMs, trimEndMs } = states; + const handleChangeTrimDuration = (newTrimDurationMs: number) => { + setStates((curr) => ({ ...curr, trimDurationMs: newTrimDurationMs })); + }; - const setTrimStartMs = useCallback((newTrimMs: number) => { + const setTrimStartMs = (newTrimMs: number) => { setStates((prev) => { - if (prev.trimEndMs === undefined) { + if (prev.trimEndMs === undefined || prev.durationMs === undefined) { if (import.meta.env.DEV) { console.warn("Logical Error in Trim Start Setting"); } @@ -49,30 +51,42 @@ export const TrimmerPlaybarCore = ({ ...prev, trimStartMs: newTrimMs, trimEndMs: - prev.trimEndMs - newTrimMs >= MAX_TRIM_DURATION - ? newTrimMs + MAX_TRIM_DURATION - : prev.trimEndMs, + trimDurationMs >= prev.durationMs + ? prev.durationMs + : newTrimMs + trimDurationMs, }; }); - }, []); - const setTrimEndMs = useCallback((newTrimMs: number) => { + }; + + useEffect(() => { setStates((prev) => { - if (prev.trimStartMs === undefined) { - if (import.meta.env.DEV) { - console.warn("Logical Error in Trim End Setting"); - } + if (prev.trimStartMs === undefined || prev.durationMs === undefined) { + // video is not ready, no data return prev; } + // normal case + if (prev.trimStartMs + trimDurationMs <= prev.durationMs) { + return { + ...prev, + trimEndMs: prev.trimStartMs + trimDurationMs, + }; + } + // not enough duration to fit + if (trimDurationMs >= prev.durationMs) { + return { + ...prev, + trimStartMs: 0, + trimEndMs: prev.durationMs, + }; + } + // shimmy the area to left return { ...prev, - trimEndMs: newTrimMs, - trimStartMs: - newTrimMs - prev.trimStartMs >= MAX_TRIM_DURATION - ? newTrimMs - MAX_TRIM_DURATION - : prev.trimStartMs, + trimStartMs: prev.durationMs - trimDurationMs, + trimEndMs: prev.durationMs, }; }); - }, []); + }, [trimDurationMs]); useEffect(() => { if (trimStartMs !== undefined && trimEndMs !== undefined) { @@ -89,8 +103,8 @@ export const TrimmerPlaybarCore = ({ trimStartMs: trimData?.trimStartMs ?? 0, trimEndMs: trimData?.trimEndMs ? trimData.trimEndMs - : vidEl.duration * 1000 >= MAX_TRIM_DURATION - ? MAX_TRIM_DURATION + : vidEl.duration * 1000 >= trimDurationMs + ? trimDurationMs : vidEl.duration * 1000, })); }; @@ -120,35 +134,30 @@ export const TrimmerPlaybarCore = ({ } return ( -
-
- - - +
+
+ + trimDurationMs ? trimDurationMs : durationMs + } + totalDurationMs={durationMs} + onChange={setTrimStartMs} + /> +
+
); diff --git a/src/components/features/DialogAddVideo/TrimmerPlaybar/index.tsx b/src/components/features/DialogAddVideo/TrimmerPlaybar/index.tsx index 6f3fae4d9d..5bd6a420ba 100644 --- a/src/components/features/DialogAddVideo/TrimmerPlaybar/index.tsx +++ b/src/components/features/DialogAddVideo/TrimmerPlaybar/index.tsx @@ -1,16 +1,17 @@ +import { Signal } from "@preact/signals-react"; import { TrimmerPlaybarCore } from "./TrimmerPlaybarCore"; import { TrimmingPlaybarLoading } from "./TrimmerPlaybarLoading"; import { TrimData } from "./utilities"; export type { TrimData }; export const TrimmerPlaybar = ({ vidEl, - trimData, + trimDataSignal, className, onTrimChange, }: { vidEl: HTMLVideoElement | undefined; className?: string; - trimData?: TrimData; + trimDataSignal: Signal; onTrimChange: (trimData: TrimData) => void; }) => { if (!vidEl) { @@ -20,7 +21,7 @@ export const TrimmerPlaybar = ({ ); diff --git a/src/components/features/DialogAddVideo/TrimmerPlaybar/utilities.ts b/src/components/features/DialogAddVideo/TrimmerPlaybar/utilities.ts index 234876ab7a..9cf7773d7b 100644 --- a/src/components/features/DialogAddVideo/TrimmerPlaybar/utilities.ts +++ b/src/components/features/DialogAddVideo/TrimmerPlaybar/utilities.ts @@ -1,6 +1,7 @@ export const buttonStyles = "border border-ui-border bg-ui-panel cursor-grab"; export const verticalPositionStyles = "absolute top-1/2 -translate-y-1/2"; export const MAX_TRIM_DURATION = 6000; +export const ONE_MIN = 1000 * 60; export enum MouseState { IDLE = "idle", DRAGGING = "dragging", @@ -10,10 +11,15 @@ export type TrimData = { trimEndMs: number; }; -export function formatSecondsToHHMMSSCS(seconds: number) { - //example of the ISO String: 1970-01-01T00:01:40.774Z - const isoString = new Date(Math.round(seconds * 1000)).toISOString(); - if (seconds > 3600) +export function formatSecondsToHHMMSSCS(msArg: number) { + //example of the ISO String: 1970-01-01T 00:01:40.77 4Z + // index count=01234567890 12345678901 23 + const milisecond = isNaN(msArg) ? 0 : msArg; + if (isNaN(milisecond) && import.meta.env.DEV) { + console.warn("formatSecondsToHHMMSSCS recieved a NaN"); + } + const isoString = new Date(Math.round(milisecond)).toISOString(); + if (milisecond >= ONE_MIN * 60) return isoString.substring(11, 19) + "." + isoString.substring(20, 22); else return isoString.substring(14, 19) + "." + isoString.substring(20, 22); } diff --git a/src/components/features/DialogAddVideo/VideoControls/ButtonMute.tsx b/src/components/features/DialogAddVideo/VideoControls/ButtonMute.tsx index 70490f2129..e4ec3ff977 100644 --- a/src/components/features/DialogAddVideo/VideoControls/ButtonMute.tsx +++ b/src/components/features/DialogAddVideo/VideoControls/ButtonMute.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { faVolumeSlash, faVolume } from "@fortawesome/pro-solid-svg-icons"; import { Button } from "~/components/ui"; - +import { Tooltip } from "~/components/ui"; export function ButtonMute({ vidEl }: { vidEl: HTMLVideoElement }) { const [isMuted, setIsMuted] = useState(vidEl.muted || false); @@ -26,11 +26,13 @@ export function ButtonMute({ vidEl }: { vidEl: HTMLVideoElement }) { }, [vidEl]); return ( -