diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index b20222a67..958d265da 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -129,3 +129,118 @@ describe("FlatMediaSection — cutout", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — volume/rate/media-start", () => { + it("renders volume at its stored percentage and commits a new value on drag", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("50%"); + act(() => root.unmount()); + }); + + it("commits a new volume value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0]; + Object.defineProperty(volumeTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + // min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" + expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); + act(() => root.unmount()); + }); + + it("commits a new rate value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1]; + Object.defineProperty(rateTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + }); + // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3" + expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3"); + act(() => root.unmount()); + }); + + it("commits a new media-start value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2]; + Object.defineProperty(mediaStartTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + }); + // no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000 + // ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00" + expect(onSetAttribute).toHaveBeenCalledWith("media-start", "30.00"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 4daf973ba..9949489e8 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -4,9 +4,12 @@ import type { DomEditSelection } from "./domEditing"; import { type BackgroundRemovalProgress, type BackgroundRemovalResult, + formatNumericValue, + formatTimingValue, + parseNumericValue, stripQueryAndHash, } from "./propertyPanelHelpers"; -import { FlatSelectRow, FlatToggle } from "./propertyPanelFlatPrimitives"; +import { FlatSelectRow, FlatSlider, FlatToggle } from "./propertyPanelFlatPrimitives"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -36,12 +39,24 @@ export function FlatMediaSection({ ) => Promise; }) { const isVideo = element.tagName === "video"; - // oxlint-disable-next-line no-unused-vars -- wired into the Volume/Rate/Muted gate in Task 4 const isAudio = element.tagName === "audio"; const isImage = element.tagName === "img"; const isVisualMedia = isVideo || isImage; const el = element.element; + const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; + const volumePercent = Math.round(volume * 100); + const mediaStart = + Number.parseFloat( + element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", + ) || 0; + const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1; + const sourceDuration = + Number.parseFloat(element.dataAttributes["source-duration"] ?? "") || + (el as HTMLMediaElement).duration || + 0; + const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); const [removeBusy, setRemoveBusy] = useState(false); @@ -172,6 +187,39 @@ export function FlatMediaSection({ )} )} + {(isVideo || isAudio) && ( + <> + void onSetAttribute("volume", formatNumericValue(next / 100))} + /> + + void onSetAttribute("playback-rate", formatNumericValue(next / 100)) + } + /> + void onSetAttribute("media-start", (next / 100).toFixed(2))} + /> + + )} ); }