From 9da97f7149c304b48d976e1777ef280358da5f0d Mon Sep 17 00:00:00 2001 From: sphinxrave <62570796+sphinxrave@users.noreply.github.com> Date: Sat, 23 Nov 2024 21:29:44 -0800 Subject: [PATCH] minor update to waveform --- packages/react/package-lock.json | 10 ++ packages/react/package.json | 1 + .../tldex/new-editor/atoms/waveformAtoms.ts | 39 +++++++ .../new-editor/components/RndSubtitle.tsx | 106 ++++++++++++++++++ .../new-editor/components/WaveformEditor.tsx | 91 +-------------- .../src/components/tldex/new-editor/frame.tsx | 20 ++-- packages/react/src/hooks/useBlock.ts | 63 +++++++++++ 7 files changed, 233 insertions(+), 97 deletions(-) create mode 100644 packages/react/src/components/tldex/new-editor/components/RndSubtitle.tsx create mode 100644 packages/react/src/hooks/useBlock.ts diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index f434192d3..aaa7a57e3 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -49,6 +49,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "dayjs": "^1.11.10", + "history": "^5.3.0", "i18next": "^23.7.6", "i18next-browser-languagedetector": "^7.2.0", "i18next-chained-backend": "^4.6.2", @@ -7380,6 +7381,15 @@ "node": ">=12.0.0" } }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, "node_modules/hpagent": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", diff --git a/packages/react/package.json b/packages/react/package.json index cbe7333dd..a94c59c10 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -56,6 +56,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "dayjs": "^1.11.10", + "history": "^5.3.0", "i18next": "^23.7.6", "i18next-browser-languagedetector": "^7.2.0", "i18next-chained-backend": "^4.6.2", diff --git a/packages/react/src/components/tldex/new-editor/atoms/waveformAtoms.ts b/packages/react/src/components/tldex/new-editor/atoms/waveformAtoms.ts index 364855b2d..ef72a05f1 100644 --- a/packages/react/src/components/tldex/new-editor/atoms/waveformAtoms.ts +++ b/packages/react/src/components/tldex/new-editor/atoms/waveformAtoms.ts @@ -158,8 +158,47 @@ export const waveformGeneratorStateAtom = atom({ format: null, }); +// Utility function to get min value from array of tuples +const getMin = (data: [number, number][]) => { + return Math.min(...data.map(([_, value]) => value)); +}; + +// Utility function to get max value from array of tuples +const getMax = (data: [number, number][]) => { + return Math.max(...data.map(([_, value]) => value)); +}; + +// export const waveformAtom = atom<[number, number][]>([]); +// +export const normalizedLoudnessAtom = atom<[number, number][]>((get) => { + const waveform = get(waveformAtom); + + // If no data, return empty array + if (waveform.length === 0) return []; + + const EPSILON = 1e-10; + + // Convert each RMS dB value to amplitude + // amplitude = 10^((dB + epsilon) / 20) + const amplitudes = waveform.map(([time, db]) => { + const amplitude = Math.pow(10, (db + EPSILON) / 20); + return [time, amplitude] as [number, number]; + }); + + // Get min and max for normalization + const minAmplitude = getMin(amplitudes); + const maxAmplitude = getMax(amplitudes); + const range = maxAmplitude - minAmplitude + EPSILON; // Add epsilon to prevent division by zero + + // Normalize values to 0-1 range + return amplitudes.map(([time, amplitude]) => { + const normalized = (amplitude - minAmplitude) / range; + return [time, normalized] as [number, number]; + }); +}); + // Action atom to trigger waveform generation export const generateWaveformAtom = atom( diff --git a/packages/react/src/components/tldex/new-editor/components/RndSubtitle.tsx b/packages/react/src/components/tldex/new-editor/components/RndSubtitle.tsx new file mode 100644 index 000000000..b94eba412 --- /dev/null +++ b/packages/react/src/components/tldex/new-editor/components/RndSubtitle.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Rnd } from "react-rnd"; +import { useSpecificSubtitle } from "../hooks/subtitles"; + +/** + * Properties for the RndSubtitle component. + * + * @property {string} subtitleId Unique identifier for the subtitle. + * @property {number} startTime Start time of the timeline range for the subtitle. + * @property {number} endTime End time of the timeline range for the subtitle. + * @property {number} containerWidth Width of the container in which the subtitle is displayed. + */ +interface RndSubtitleProps { + subtitleId: string; + startTime: number; + endTime: number; + containerWidth: number; +} +/** + * RndSubtitle is a resizable and draggable subtitle component for editing video subtitles. + * It utilizes the react-rnd library to allow users to adjust the position + * and duration of a subtitle by dragging or resizing the subtitle box. + * + * Props: + * - subtitleId: Unique identifier for the subtitle. + * - startTime: Start time of the timeline range for the subtitle. + * - endTime: End time of the timeline range for the subtitle. + * - containerWidth: Width of the container in which the subtitle is displayed. + * + * The component provides internal functions to convert time to position and vice versa, + * and handlers to update subtitle start time and duration upon dragging or resizing. + */ +export const RndSubtitle: React.FC = ({ + subtitleId, + startTime, + endTime, + containerWidth, +}) => { + const [subtitle, edit] = useSpecificSubtitle(subtitleId); + + const timeToPosition = (time: number) => { + const timeRange = endTime - startTime; + const position = ((time - startTime) / timeRange) * containerWidth; + return position; + }; + + const positionToTime = (position: number) => { + const timeRange = endTime - startTime; + const time = (position / containerWidth) * timeRange + startTime; + return Math.max(startTime, Math.min(time, endTime)); + }; + + const handleDrag = (_: unknown, d: { x: number; y: number }) => { + const newStartTime = positionToTime(d.x); + edit({ + type: "UPDATE_SUBTITLE", + payload: { + ...subtitle, + video_offset: newStartTime, + end: newStartTime + subtitle.duration / 1000, + }, + }); + }; + + const handleResize = (_: unknown, _direction: string, ref: HTMLElement) => { + const newWidth = ref.offsetWidth; + const newDuration = + (newWidth / containerWidth) * (endTime - startTime) * 1000; + edit({ + type: "UPDATE_SUBTITLE", + payload: { + ...subtitle, + duration: newDuration, + end: subtitle.video_offset + newDuration / 1000, + }, + }); + }; + + return ( + +
+ {subtitle.message} +
+
+ ); +}; diff --git a/packages/react/src/components/tldex/new-editor/components/WaveformEditor.tsx b/packages/react/src/components/tldex/new-editor/components/WaveformEditor.tsx index f504dfc67..8d5fc3694 100644 --- a/packages/react/src/components/tldex/new-editor/components/WaveformEditor.tsx +++ b/packages/react/src/components/tldex/new-editor/components/WaveformEditor.tsx @@ -4,15 +4,14 @@ import { useTimelineRendererBase } from "../hooks/timeline"; import { playerRefAtom, videoStatusAtomFamily } from "@/store/player"; import { useAtomValue } from "jotai"; -import { waveformAtom } from "../atoms/waveformAtoms"; +import { normalizedLoudnessAtom } from "../atoms/waveformAtoms"; import "./WaveformEditor.scss"; -import React from "react"; -import { Rnd } from "react-rnd"; -import { subtitleManagerAtom, useSpecificSubtitle } from "../hooks/subtitles"; +import { subtitleManagerAtom } from "../hooks/subtitles"; +import { RndSubtitle } from "./RndSubtitle"; export const WaveformEditor = ({ videoId }: { videoId: string }) => { - const waveform = useAtomValue(waveformAtom); + const waveform = useAtomValue(normalizedLoudnessAtom); const player = useAtomValue(playerRefAtom); const videoStatusAtom = videoStatusAtomFamily(videoId); const videoStatus = useAtomValue(videoStatusAtom); @@ -71,85 +70,3 @@ export const WaveformEditor = ({ videoId }: { videoId: string }) => { ); }; - -interface RndSubtitleProps { - subtitleId: string; - startTime: number; - endTime: number; - containerWidth: number; -} - -export const RndSubtitle: React.FC = ({ - subtitleId, - startTime, - endTime, - containerWidth, -}) => { - const [subtitle, edit] = useSpecificSubtitle(subtitleId); - - const timeToPosition = (time: number) => { - const timeRange = endTime - startTime; - const position = ((time - startTime) / timeRange) * containerWidth; - return position; - }; - - const positionToTime = (position: number) => { - const timeRange = endTime - startTime; - const time = (position / containerWidth) * timeRange + startTime; - return Math.max(startTime, Math.min(time, endTime)); - }; - - const handleDrag = (_: unknown, d: { x: number; y: number }) => { - const newStartTime = positionToTime(d.x); - edit({ - type: "UPDATE_SUBTITLE", - payload: { - ...subtitle, - video_offset: newStartTime, - end: newStartTime + subtitle.duration / 1000, - }, - }); - }; - - const handleResize = (_: unknown, _direction: string, ref: HTMLElement) => { - const newWidth = ref.offsetWidth; - const newDuration = - (newWidth / containerWidth) * (endTime - startTime) * 1000; - edit({ - type: "UPDATE_SUBTITLE", - payload: { - ...subtitle, - duration: newDuration, - end: subtitle.video_offset + newDuration / 1000, - }, - }); - }; - - return ( - -
- {subtitle.message} -
-
- ); -}; diff --git a/packages/react/src/components/tldex/new-editor/frame.tsx b/packages/react/src/components/tldex/new-editor/frame.tsx index 1aaaa396b..3d18169c7 100644 --- a/packages/react/src/components/tldex/new-editor/frame.tsx +++ b/packages/react/src/components/tldex/new-editor/frame.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import "./frame.css"; -import { useBeforeUnload, useBlocker, useNavigate } from "react-router-dom"; +import { useBeforeUnload, useNavigate } from "react-router-dom"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { PlayerWrapper } from "@/components/layout/PlayerWrapper"; import { idToVideoURL } from "@/lib/utils"; @@ -17,12 +17,13 @@ import { clientAtom } from "@/hooks/useClient"; import { useQuery } from "@tanstack/react-query"; import { getSubtitlesForVideo, subtitleManagerAtom } from "./hooks/subtitles"; import { TLEditorHeader } from "./TLEditorHeader"; +import { useNavBlocker } from "@/hooks/useBlock"; export function TLEditorFrame() { const { id, currentVideo, - isPending: isVideoPending, + isPending: isVideoPending, // download progress pending editorLanguage, } = useScriptEditorParams(); const makeHeaderHide = useSetAtom(headerHiddenAtom); @@ -60,20 +61,19 @@ export function TLEditorFrame() { }, [isSuccess, script]); // Prevent soft navigation within the SPA - const blocker = useBlocker(isBlocking); - - useEffect(() => { - if (blocker.state === "blocked") { + useNavBlocker({ + enabled: isBlocking, + onBlock(control) { const proceed = window.confirm( "Are you sure you want to leave? You may lose unsaved changes.", ); if (proceed) { - blocker.proceed(); + control.confirm(); } else { - blocker.reset(); + control.cancel(); } - } - }, [blocker]); + }, + }); // Prevent hard refresh and closing tab const handleBeforeUnload = useCallback( diff --git a/packages/react/src/hooks/useBlock.ts b/packages/react/src/hooks/useBlock.ts new file mode 100644 index 000000000..572a8d730 --- /dev/null +++ b/packages/react/src/hooks/useBlock.ts @@ -0,0 +1,63 @@ +import { History } from "history"; +import { useContext, useEffect, useLayoutEffect, useRef } from "react"; +import { UNSAFE_NavigationContext } from "react-router-dom"; + +interface NavBlockerControl { + confirm: () => void; + cancel: () => void; +} + +interface NavBlocker { + onBlock: (control: NavBlockerControl) => void; + enabled?: boolean; +} + +/** + * Hook to block navigation based on a condition. + * + * @param {Object} params - Parameters for the hook. + * @param {function} params.onBlock - Function to be called when navigation is blocked. + * @param {boolean} [params.enabled] - Flag to enable or disable the navigation blocker. + * + * This hook uses the `UNSAFE_NavigationContext` from `react-router-dom` to block navigation. + */ +export const useNavBlocker = ({ onBlock, enabled }: NavBlocker) => { + const { block } = useContext(UNSAFE_NavigationContext).navigator as History; + + // Latest ref pattern + // Latest version of the function stored to the onBlockRef + const onBlockRef = useRef(onBlock); + useLayoutEffect(() => { + onBlockRef.current = onBlock; + }); + + useEffect(() => { + if (!enabled) { + return; + } + + let isActive = false; + + const unblock = block(({ retry }) => { + if (isActive) { + unblock(); + // Retry method handles navigation for us 🎉 + // Allows to simplify code even more. + return retry(); + } + + // This doesn't need to be included in dependencies + // and won't trigger useEffect + onBlockRef.current({ + confirm: retry, + cancel: () => { + isActive = false; + }, + }); + + isActive = true; + }); + + return unblock; + }, [block, enabled]); +};