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:
Vance Ingalls
2026-06-19 17:17:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent cd832f01ac
commit f0c4dee705
23 changed files with 959 additions and 371 deletions
+41 -58
View File
@@ -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