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))}
+ />
+ >
+ )}
);
}