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
@@ -32,10 +32,16 @@ interface ControllerLike {
dispose?(): void;
}
interface SlideNotesTarget {
sceneId?: string;
}
type PlayerElement = HTMLElement & {
seek(t: number): void;
play(): void;
pause(): void;
stopMedia?(): void;
muted?: boolean;
readonly currentTime: number;
readonly ready: boolean;
};
@@ -48,6 +54,8 @@ function isPlayerElement(el: HTMLElement): el is PlayerElement {
);
}
const PRESENTER_NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:";
// Injected once per document to avoid duplicating @keyframes across multiple elements.
let _keyframesInjected = false;
function injectKeyframesOnce(): void {
@@ -277,6 +285,10 @@ export class HyperframesSlideshow extends HTMLElement {
seek: (t) => playerEl.seek(t),
play: () => playerEl.play(),
pause: () => playerEl.pause(),
stopMedia: () => {
playerEl.stopMedia?.();
this.stopDocumentMedia();
},
get currentTime() {
return playerEl.currentTime;
},
@@ -551,9 +563,14 @@ export class HyperframesSlideshow extends HTMLElement {
const muteBtn = chrome.querySelector("[data-hf-mute]");
const prevBtn = chrome.querySelector("[data-hf-prev]");
const nextBtn = chrome.querySelector("[data-hf-next]");
const notesInput = chrome.querySelector("[data-hf-presenter-notes]");
if (muteBtn) muteBtn.addEventListener("click", () => this.toggleMute());
if (prevBtn) prevBtn.addEventListener("click", () => this.controller?.prev());
if (nextBtn) nextBtn.addEventListener("click", () => this.controller?.next());
if (notesInput instanceof HTMLTextAreaElement) {
const key = notesInput.getAttribute("data-hf-presenter-notes-key");
notesInput.addEventListener("input", () => this.writePresenterNotes(key, notesInput.value));
}
const fsBtn = chrome.querySelector("[data-hf-fullscreen]");
if (fsBtn) fsBtn.addEventListener("click", () => this.toggleFullscreen());
for (const btn of chrome.querySelectorAll("[data-hotspot-id]")) {
@@ -588,6 +605,7 @@ export class HyperframesSlideshow extends HTMLElement {
} else {
this.removeAttribute("data-hf-muted");
}
this.applyGlobalMute(this._muted);
this.dispatchEvent(
new CustomEvent("hf-sound", {
detail: { muted: this._muted },
@@ -599,6 +617,83 @@ export class HyperframesSlideshow extends HTMLElement {
this.render();
}
private applyGlobalMute(muted: boolean): void {
for (const player of this.querySelectorAll("hyperframes-player")) {
if (!(player instanceof HTMLElement)) continue;
const playerEl = player as Partial<PlayerElement> & HTMLElement;
if ("muted" in playerEl) {
playerEl.muted = muted;
} else if (muted) {
playerEl.setAttribute("muted", "");
} else {
playerEl.removeAttribute("muted");
}
}
const doc = this.ownerDocument;
for (const el of doc.querySelectorAll("video, audio")) {
if (el instanceof HTMLMediaElement) el.muted = muted || el.defaultMuted;
}
}
private stopDocumentMedia(): void {
const doc = this.ownerDocument;
for (const el of doc.querySelectorAll("video, audio")) {
if (el instanceof HTMLMediaElement) el.pause();
}
}
private presenterNotesDeckKey(): string {
const explicit = this.getAttribute("notes-storage-key")?.trim();
if (explicit) return explicit;
const playerSrc = this.querySelector("hyperframes-player")?.getAttribute("src") ?? "";
let resolvedPlayerSrc = playerSrc;
try {
const baseHref = typeof location !== "undefined" ? location.href : "http://localhost/";
resolvedPlayerSrc = new URL(playerSrc, baseHref).href;
} catch {
// Keep the raw src when URL construction is unavailable.
}
const locationKey =
typeof location !== "undefined" ? `${location.origin}${location.pathname}` : "";
const title = this.ownerDocument.title;
return `${locationKey}|${title}|${resolvedPlayerSrc}`;
}
private presenterNotesStorageKey(slide: SlideNotesTarget): string | null {
const pos = this.controller?.position;
if (!pos) return null;
return `${PRESENTER_NOTES_STORAGE_PREFIX}${JSON.stringify([
this.presenterNotesDeckKey(),
pos.sequenceId,
pos.slideIndex,
slide.sceneId ?? "",
])}`;
}
private readPresenterNotes(key: string | null): string | null {
if (!key) return null;
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
private writePresenterNotes(key: string | null, notes: string): void {
if (!key) return;
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, notes);
} catch {
// localStorage may be disabled or quota-limited; editing still works for
// the current render even when persistence is unavailable.
}
}
private renderPresenter(): void {
if (!this.controller) return;
const { counter, currentSlide, nextSlide } = this.controller;
@@ -620,9 +715,12 @@ export class HyperframesSlideshow extends HTMLElement {
// Full-overlay chrome (pointer-events:none); the notes panel and nav cluster
// are the only interactive children.
const notesStorageKey = this.presenterNotesStorageKey(currentSlide);
const notes = this.readPresenterNotes(notesStorageKey) ?? currentSlide.notes ?? "";
this.paintChrome(
buildPresenterLayout({
notes: currentSlide.notes ?? "",
notes,
notesStorageKey,
nextText: nextPanelText(nextSlide),
counterText: `${counter.index} / ${counter.total}`,
elapsedText: formatElapsed(elapsedSec),