mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user