From 79fdff8701192e8b450e1899739d0719d8c30be1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 14:01:48 -0700 Subject: [PATCH] feat(studio): add loop/muted/has-audio-track toggles to FlatMediaSection --- .../propertyPanelFlatMediaSection.test.tsx | 120 ++++++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 28 ++++ 2 files changed, 148 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index 958d265da..d77472a88 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -244,3 +244,123 @@ describe("FlatMediaSection — volume/rate/media-start", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — loop/muted/has-audio", () => { + it("toggles loop via onSetHtmlAttribute and shows has-audio-track for video", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const loopToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Loop"]', + ); + act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true"); + + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => root.unmount()); + }); + + it("toggles muted via onSetHtmlAttribute", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mutedToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Muted"]', + ); + expect(mutedToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); + + it("enables has-audio-track and clears muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true"); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null); + act(() => root.unmount()); + }); + + it("disables has-audio-track and sets muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", ""); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 9949489e8..37f7a0c71 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -56,6 +56,9 @@ export function FlatMediaSection({ (el as HTMLMediaElement).duration || 0; const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + const hasLoop = el.hasAttribute("loop"); + const hasMuted = el.hasAttribute("muted"); + const hasAudio = element.dataAttributes["has-audio"] === "true"; const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); @@ -218,6 +221,31 @@ export function FlatMediaSection({ displayValue={formatTimingValue(mediaStart)} onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))} /> + void onSetHtmlAttribute("loop", next ? "true" : null)} + /> + void onSetHtmlAttribute("muted", next ? "true" : null)} + /> + {isVideo && ( + { + if (next) { + void onSetAttribute("has-audio", "true"); + void onSetHtmlAttribute("muted", null); + } else { + void onSetAttribute("has-audio", ""); + void onSetHtmlAttribute("muted", "true"); + } + }} + /> + )} )}