mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cd832f01ac
commit
f0c4dee705
@@ -5,6 +5,7 @@ function createMockDeps() {
|
||||
return {
|
||||
onPlay: vi.fn(),
|
||||
onPause: vi.fn(),
|
||||
onStopMedia: vi.fn(),
|
||||
onSeek: vi.fn(),
|
||||
onTick: vi.fn(),
|
||||
onSetMuted: vi.fn(),
|
||||
@@ -39,6 +40,13 @@ describe("installRuntimeControlBridge", () => {
|
||||
expect(deps.onPause).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("dispatches stop-media command", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(makeControlMessage("stop-media"));
|
||||
expect(deps.onStopMedia).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("dispatches seek command with frame and mode", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RuntimeBridgeControlMessage, RuntimeOutboundMessage } from "./type
|
||||
type BridgeDeps = {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStopMedia: () => void;
|
||||
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
|
||||
onTick: () => void;
|
||||
onSetMuted: (muted: boolean) => void;
|
||||
@@ -29,68 +30,50 @@ export function postRuntimeMessage(payload: RuntimeOutboundMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
type BridgeControlData = Partial<RuntimeBridgeControlMessage>;
|
||||
type ControlHandler = (data: BridgeControlData, deps: BridgeDeps) => void;
|
||||
|
||||
// Per-action dispatchers. Splitting the handler into a lookup table keeps the
|
||||
// top-level message listener trivial (one map lookup), and each action's logic
|
||||
// becomes individually testable / inheritable for fallow's CRAP analysis.
|
||||
const CONTROL_HANDLERS: Record<string, ControlHandler> = {
|
||||
play: (_d, deps) => deps.onPlay(),
|
||||
pause: (_d, deps) => deps.onPause(),
|
||||
"stop-media": (_d, deps) => deps.onStopMedia(),
|
||||
seek: (data, deps) => deps.onSeek(Number(data.frame ?? 0), data.seekMode ?? "commit"),
|
||||
tick: (_d, deps) => deps.onTick(),
|
||||
"set-muted": (data, deps) => deps.onSetMuted(Boolean(data.muted)),
|
||||
"set-volume": (data, deps) =>
|
||||
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
|
||||
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
|
||||
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
||||
"set-color-grading": (data, deps) =>
|
||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
|
||||
"set-color-grading-compare": (data, deps) =>
|
||||
deps.onSetColorGradingCompare(data.target ?? null, data.compare ?? null),
|
||||
"enable-pick-mode": (_d, deps) => deps.onEnablePickMode(),
|
||||
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
|
||||
"flash-elements": (data) => handleFlashElements(data),
|
||||
};
|
||||
|
||||
function handleFlashElements(data: BridgeControlData): void {
|
||||
// Briefly highlight elements — used by the chat-canvas bridge
|
||||
// to show what changed after an agent edit
|
||||
const selectors = (data as Record<string, unknown>).selectors as string[] | undefined;
|
||||
const duration = ((data as Record<string, unknown>).duration as number) || 800;
|
||||
if (selectors) {
|
||||
flashElements(selectors, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEvent) => void {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const data = event.data as Partial<RuntimeBridgeControlMessage> | null;
|
||||
const data = event.data as BridgeControlData | null;
|
||||
if (!data || data.source !== "hf-parent" || data.type !== "control") return;
|
||||
const action = data.action;
|
||||
if (action === "play") {
|
||||
deps.onPlay();
|
||||
return;
|
||||
}
|
||||
if (action === "pause") {
|
||||
deps.onPause();
|
||||
return;
|
||||
}
|
||||
if (action === "seek") {
|
||||
deps.onSeek(Number(data.frame ?? 0), data.seekMode ?? "commit");
|
||||
return;
|
||||
}
|
||||
if (action === "tick") {
|
||||
deps.onTick();
|
||||
return;
|
||||
}
|
||||
if (action === "set-muted") {
|
||||
deps.onSetMuted(Boolean(data.muted));
|
||||
return;
|
||||
}
|
||||
if (action === "set-volume") {
|
||||
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1))));
|
||||
return;
|
||||
}
|
||||
if (action === "set-media-output-muted") {
|
||||
deps.onSetMediaOutputMuted(Boolean(data.muted));
|
||||
return;
|
||||
}
|
||||
if (action === "set-playback-rate") {
|
||||
deps.onSetPlaybackRate(Number(data.playbackRate ?? 1));
|
||||
return;
|
||||
}
|
||||
if (action === "set-color-grading") {
|
||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null);
|
||||
return;
|
||||
}
|
||||
if (action === "set-color-grading-compare") {
|
||||
deps.onSetColorGradingCompare(data.target ?? null, data.compare ?? null);
|
||||
return;
|
||||
}
|
||||
if (action === "enable-pick-mode") {
|
||||
deps.onEnablePickMode();
|
||||
return;
|
||||
}
|
||||
if (action === "disable-pick-mode") {
|
||||
deps.onDisablePickMode();
|
||||
return;
|
||||
}
|
||||
if (action === "flash-elements") {
|
||||
// Briefly highlight elements — used by the chat-canvas bridge
|
||||
// to show what changed after an agent edit
|
||||
const selectors = (data as Record<string, unknown>).selectors as string[] | undefined;
|
||||
const duration = ((data as Record<string, unknown>).duration as number) || 800;
|
||||
if (selectors) {
|
||||
flashElements(selectors, duration);
|
||||
}
|
||||
}
|
||||
if (typeof action !== "string") return;
|
||||
const fn = CONTROL_HANDLERS[action];
|
||||
if (fn) fn(data, deps);
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
// Announce that the bridge listener is installed so the parent can replay
|
||||
|
||||
@@ -1839,6 +1839,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
player.pause();
|
||||
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
|
||||
},
|
||||
onStopMedia: () => {
|
||||
webAudio.stopAll();
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (el instanceof HTMLMediaElement && !el.paused) el.pause();
|
||||
}
|
||||
},
|
||||
onSeek: (frame, _seekMode) => {
|
||||
const time = Math.max(0, frame) / state.canonicalFps;
|
||||
player.seek(time);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type RuntimeBridgeControlAction =
|
||||
| "tick"
|
||||
| "set-volume"
|
||||
| "set-media-output-muted"
|
||||
| "stop-media"
|
||||
| "flash-elements";
|
||||
|
||||
export type RuntimeBridgeControlMessage = {
|
||||
|
||||
Reference in New Issue
Block a user