mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(slideshow): harden media controls in present decks (#1619)
This commit is contained in:
@@ -19,10 +19,12 @@ Do not treat these as the blessed authoring model. When the engine-hosted path s
|
||||
|
||||
---
|
||||
|
||||
## 2. The parent wrapper (demo.html)
|
||||
## 2. The parent wrapper (`index.html` for deliverables, `demo.html` in examples)
|
||||
|
||||
The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
|
||||
|
||||
For public or user-facing generated projects, make this wrapper the root `index.html` so opening the project in a browser runs the slideshow. Put the raw HyperFrames composition in a separate path such as `composition/index.html`. In repo examples you may still see this file called `demo.html`; that name is a reference pattern, not the preferred handoff for a standalone deck.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -67,8 +69,9 @@ The parent page hosts the two dist bundles, wraps the components, duplicates the
|
||||
style="display: block; position: relative; width: 100vw; height: 100vh"
|
||||
>
|
||||
<hyperframes-player
|
||||
interactive
|
||||
style="position: absolute; inset: 0"
|
||||
src="index.html"
|
||||
src="composition/index.html"
|
||||
></hyperframes-player>
|
||||
|
||||
<!--
|
||||
@@ -97,12 +100,265 @@ The parent page hosts the two dist bundles, wraps the components, duplicates the
|
||||
}
|
||||
</script>
|
||||
</hyperframes-slideshow>
|
||||
<!-- The built-in slideshow nav capsule renders Present; do not add a wrapper-level button. -->
|
||||
|
||||
<!-- Audio player lives here — see Section 6 -->
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
`interactive` is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction.
|
||||
|
||||
### Presenter media bridge for interactive media
|
||||
|
||||
Presenter/audience mode syncs slide position through a deck-scoped `BroadcastChannel`. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state.
|
||||
|
||||
Audible playback has one extra browser constraint: a `BroadcastChannel` message does not carry the presenter's user activation into the audience window. The audience window should try presenter-driven playback muted first, because browsers usually allow muted autoplay; it may still reject `media.play()` even while it accepts remote `currentTime` updates. If that happens, do not keep chasing presenter `timeupdate` messages; show an audience-side unlock control, store the latest play intent, and retry playback from that intent after the audience window receives a click/key gesture.
|
||||
|
||||
```js
|
||||
(function () {
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
|
||||
var sender =
|
||||
new URLSearchParams(location.search).get("mode") === "audience" ? "audience" : "presenter";
|
||||
var channel = new BroadcastChannel("hf-slideshow:" + location.pathname);
|
||||
var applyingRemote = false;
|
||||
var lastTimeBroadcast = 0;
|
||||
var pendingPlayByKey = {};
|
||||
var blockedPlayByKey = {};
|
||||
var mutedPlaybackByKey = {};
|
||||
var unlockButton = null;
|
||||
|
||||
function frameDocument() {
|
||||
var player = document.querySelector("hyperframes-player");
|
||||
var frame = player && player.iframeElement;
|
||||
try {
|
||||
return frame && frame.contentDocument ? frame.contentDocument : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function mediaNodes() {
|
||||
var doc = frameDocument();
|
||||
return doc ? Array.from(doc.querySelectorAll("video,audio")) : [];
|
||||
}
|
||||
function mediaKey(el, index) {
|
||||
return el.id ? "id:" + el.id : el.tagName.toLowerCase() + ":" + index;
|
||||
}
|
||||
function findMedia(key) {
|
||||
return mediaNodes().find(function (el, index) {
|
||||
return mediaKey(el, index) === key;
|
||||
});
|
||||
}
|
||||
function syncMediaState(el, msg, allowTimeSync) {
|
||||
if (typeof msg.playbackRate === "number") el.playbackRate = msg.playbackRate;
|
||||
if (typeof msg.volume === "number") el.volume = Math.max(0, Math.min(1, msg.volume));
|
||||
if (sender === "audience" && mutedPlaybackByKey[msg.key]) {
|
||||
el.muted = true;
|
||||
} else if (typeof msg.muted === "boolean") {
|
||||
el.muted = msg.muted;
|
||||
}
|
||||
if (
|
||||
allowTimeSync &&
|
||||
typeof msg.currentTime === "number" &&
|
||||
Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35
|
||||
) {
|
||||
el.currentTime = Math.max(0, msg.currentTime);
|
||||
}
|
||||
}
|
||||
function hasBlockedPlay() {
|
||||
return Object.keys(blockedPlayByKey).length > 0;
|
||||
}
|
||||
function hideAudienceUnlockIfClear() {
|
||||
if (hasBlockedPlay() || !unlockButton) return;
|
||||
unlockButton.remove();
|
||||
unlockButton = null;
|
||||
}
|
||||
function showAudienceUnlock() {
|
||||
if (sender !== "audience" || unlockButton) return;
|
||||
unlockButton = document.createElement("button");
|
||||
unlockButton.type = "button";
|
||||
unlockButton.textContent = "Enable audience media";
|
||||
unlockButton.style.cssText =
|
||||
"position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;";
|
||||
unlockButton.addEventListener("click", retryBlockedPlays);
|
||||
document.body.appendChild(unlockButton);
|
||||
}
|
||||
function rememberBlockedPlay(msg) {
|
||||
pendingPlayByKey[msg.key] = msg;
|
||||
blockedPlayByKey[msg.key] = true;
|
||||
showAudienceUnlock();
|
||||
}
|
||||
function clearBlockedPlay(key) {
|
||||
delete blockedPlayByKey[key];
|
||||
hideAudienceUnlockIfClear();
|
||||
}
|
||||
function tryPlay(el, msg) {
|
||||
if (sender === "audience") mutedPlaybackByKey[msg.key] = true;
|
||||
syncMediaState(el, msg, true);
|
||||
if (sender === "audience") el.muted = true;
|
||||
try {
|
||||
var playResult = el.play();
|
||||
if (playResult && typeof playResult.then === "function") {
|
||||
playResult
|
||||
.then(function () {
|
||||
clearBlockedPlay(msg.key);
|
||||
})
|
||||
.catch(function () {
|
||||
rememberBlockedPlay(msg);
|
||||
});
|
||||
} else {
|
||||
clearBlockedPlay(msg.key);
|
||||
}
|
||||
} catch (e) {
|
||||
rememberBlockedPlay(msg);
|
||||
}
|
||||
}
|
||||
function retryBlockedPlays() {
|
||||
wireMedia();
|
||||
applyingRemote = true;
|
||||
try {
|
||||
Object.keys(pendingPlayByKey).forEach(function (key) {
|
||||
var msg = pendingPlayByKey[key];
|
||||
var el = findMedia(key);
|
||||
if (el && msg) tryPlay(el, msg);
|
||||
});
|
||||
} finally {
|
||||
setTimeout(function () {
|
||||
applyingRemote = false;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
function publish(el, index, action) {
|
||||
if (sender !== "presenter") return;
|
||||
if (applyingRemote) return;
|
||||
if (action === "timeupdate") {
|
||||
var now = performance.now();
|
||||
if (now - lastTimeBroadcast < 450 && !el.paused) return;
|
||||
lastTimeBroadcast = now;
|
||||
}
|
||||
channel.postMessage({
|
||||
type: "media",
|
||||
sender,
|
||||
key: mediaKey(el, index),
|
||||
action,
|
||||
currentTime: el.currentTime || 0,
|
||||
paused: el.paused,
|
||||
ended: el.ended,
|
||||
muted: el.muted,
|
||||
volume: el.volume,
|
||||
playbackRate: el.playbackRate,
|
||||
});
|
||||
}
|
||||
function wireMedia() {
|
||||
mediaNodes().forEach(function (el, index) {
|
||||
if (el.dataset.hfPresenterMediaSync === "1") return;
|
||||
el.dataset.hfPresenterMediaSync = "1";
|
||||
[
|
||||
"play",
|
||||
"pause",
|
||||
"seeking",
|
||||
"seeked",
|
||||
"ratechange",
|
||||
"volumechange",
|
||||
"ended",
|
||||
"timeupdate",
|
||||
].forEach(function (name) {
|
||||
el.addEventListener(name, function () {
|
||||
publish(el, index, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
channel.addEventListener("message", function (event) {
|
||||
var msg = event.data;
|
||||
if (!msg || msg.type !== "media" || msg.sender === sender) return;
|
||||
if (sender === "audience" && blockedPlayByKey[msg.key] && msg.action === "timeupdate") {
|
||||
pendingPlayByKey[msg.key] = msg;
|
||||
showAudienceUnlock();
|
||||
return;
|
||||
}
|
||||
var el = findMedia(msg.key);
|
||||
if (!el) return;
|
||||
applyingRemote = true;
|
||||
try {
|
||||
if (
|
||||
msg.action === "play" ||
|
||||
(sender === "audience" && msg.action === "timeupdate" && msg.paused === false && el.paused)
|
||||
) {
|
||||
pendingPlayByKey[msg.key] = msg;
|
||||
tryPlay(el, msg);
|
||||
} else {
|
||||
syncMediaState(el, msg, true);
|
||||
}
|
||||
if (msg.action === "pause" || msg.action === "ended") {
|
||||
delete pendingPlayByKey[msg.key];
|
||||
delete mutedPlaybackByKey[msg.key];
|
||||
clearBlockedPlay(msg.key);
|
||||
el.pause();
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setTimeout(function () {
|
||||
applyingRemote = false;
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
wireMedia();
|
||||
window.addEventListener("load", wireMedia);
|
||||
window.addEventListener("keydown", retryBlockedPlays, true);
|
||||
window.addEventListener("pointerdown", retryBlockedPlays, true);
|
||||
setInterval(wireMedia, 1000);
|
||||
})();
|
||||
```
|
||||
|
||||
### Custom media visualizers
|
||||
|
||||
For waveform, beat-grid, canvas, or timeline players, wire visual state to the native media element. This keeps native controls, custom controls, presenter sync, slide-exit cleanup, and global mute in one event path.
|
||||
|
||||
```js
|
||||
function wireMediaDrivenVisualizer(media, renderFrame, fireCrossedEvents) {
|
||||
var mediaFrame = 0;
|
||||
var lastTime = media.currentTime || 0;
|
||||
|
||||
function update() {
|
||||
var time = media.currentTime || 0;
|
||||
if (Math.abs(time - lastTime) < 1.5 && time >= lastTime) {
|
||||
fireCrossedEvents(lastTime, time);
|
||||
}
|
||||
lastTime = time;
|
||||
renderFrame(time, media);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!media.requestVideoFrameCallback || mediaFrame) return;
|
||||
mediaFrame = media.requestVideoFrameCallback(function () {
|
||||
mediaFrame = 0;
|
||||
update();
|
||||
if (!media.paused && !media.ended) start();
|
||||
});
|
||||
}
|
||||
|
||||
media.addEventListener("play", start);
|
||||
media.addEventListener("playing", start);
|
||||
media.addEventListener("pause", update);
|
||||
media.addEventListener("ended", update);
|
||||
media.addEventListener("timeupdate", update);
|
||||
media.addEventListener("seeking", function () {
|
||||
lastTime = media.currentTime || 0;
|
||||
renderFrame(lastTime, media);
|
||||
});
|
||||
media.addEventListener("seeked", update);
|
||||
media.addEventListener("ratechange", update);
|
||||
media.addEventListener("volumechange", update);
|
||||
renderFrame(media.currentTime || 0, media);
|
||||
}
|
||||
```
|
||||
|
||||
Do not use `requestAnimationFrame` inside compositions for media sync; composition lint rejects wall-clock loops. Prefer `HTMLVideoElement.requestVideoFrameCallback()` for smooth video-tied updates and rely on native `timeupdate`/seek events as the fallback.
|
||||
|
||||
Use a dedicated wiring marker such as `data-media-sync-wired`. Do not reuse a marker like `data-wired` for both "timeline DOM already rendered" and "media event listeners attached"; pre-rendered timeline HTML will otherwise skip listener setup.
|
||||
|
||||
### Editable presenter notes
|
||||
|
||||
The shared `<hyperframes-slideshow>` presenter already renders speaker notes as an editable textarea and stores edits in `localStorage`. Do not add deck-specific note editors when the shared player is available.
|
||||
@@ -165,7 +421,70 @@ Without the engine, scenes are driven by a `root` GSAP timeline that the composi
|
||||
|
||||
The key insight: scene backgrounds must be `transparent` (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline `background` set the visual fill.
|
||||
|
||||
For converted source pages, port source-specific widgets exactly where practical. Custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.
|
||||
For converted source pages, preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. Port source-specific widgets exactly where practical: custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script.
|
||||
|
||||
### Navigation camera transitions for converted pages
|
||||
|
||||
When a source page uses scroll to move a translated/scaled world, slideshow navigation usually seeks directly to each slide's hold frame. That seek bypasses any in-timeline interpolation near the scene boundary, so a deck can compute the right camera positions and still appear to jump. Add an explicit standalone navigation transition for manual slide changes, while keeping normal HyperFrames timeline seeks static and deterministic.
|
||||
|
||||
Use this pattern only for direct-open/presenter slideshow UI. Do not depend on CSS transitions for rendered video output; rendered compositions must still be correct when seeking a single frame.
|
||||
|
||||
```css
|
||||
#world {
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
transition:
|
||||
transform 760ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#world.hf-camera-static {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
var currentCamera = null;
|
||||
var currentSlideIndex = null;
|
||||
|
||||
function cameraTransform(cam) {
|
||||
return "translate(" + cam.tx + "px," + cam.ty + "px) scale(" + cam.s + ")";
|
||||
}
|
||||
|
||||
function setWorldCamera(world, cam, animate) {
|
||||
if (!cam) return;
|
||||
if (!animate) world.classList.add("hf-camera-static");
|
||||
world.style.transform = cameraTransform(cam);
|
||||
world.style.opacity = "1";
|
||||
if (!animate) {
|
||||
world.getBoundingClientRect();
|
||||
world.classList.remove("hf-camera-static");
|
||||
}
|
||||
currentCamera = cam;
|
||||
}
|
||||
|
||||
function slideIndexAtTime(t, slideDuration, slideCount) {
|
||||
return Math.max(0, Math.min(slideCount - 1, Math.floor(t / slideDuration)));
|
||||
}
|
||||
|
||||
function updateCameraForTime(t, opts) {
|
||||
var nextSlideIndex = slideIndexAtTime(t, SLIDE_DURATION, SLIDES.length);
|
||||
var jumpedBetweenSlides =
|
||||
currentSlideIndex !== null &&
|
||||
nextSlideIndex !== currentSlideIndex &&
|
||||
Math.abs(t - lastTime) > 1.2;
|
||||
var animateCamera = Boolean(
|
||||
window.__hfCameraTransitionsEnabled && jumpedBetweenSlides && !(opts && opts.staticCamera),
|
||||
);
|
||||
var cam = cameraAtTime(t);
|
||||
setWorldCamera(world, cam, animateCamera);
|
||||
currentSlideIndex = nextSlideIndex;
|
||||
}
|
||||
```
|
||||
|
||||
During measurement, temporarily remove the transform with `hf-camera-static`, compute all element union rects, then restore `currentCamera` without animation. On initial load, resize, and validation-style seeks, call `updateCameraForTime(t, { staticCamera: true })`. In the standalone wrapper, set `iframe.contentWindow.__hfCameraTransitionsEnabled = true` after the player iframe is available. That keeps the exported composition seekable while letting presenter navigation glide between focal points.
|
||||
|
||||
Before validation, resolve source font variables. HyperFrames lint accepts concrete generic stacks such as `system-ui, sans-serif` and `ui-monospace, monospace`, or real `@font-face` declarations pointing at local font files. It does not accept `font-family: var(--f-body)` / `var(--f-mono)` as a render-safe family.
|
||||
|
||||
```html
|
||||
<!-- In index.html (composition) -->
|
||||
@@ -395,6 +714,22 @@ Wrapper-owned SFX should live in the parent page. Browsers enforce user-activati
|
||||
|
||||
Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling `hyperframes-player.stopMedia()`, which pauses iframe `<video>` / `<audio>`, runtime WebAudio, and parent proxies adopted from iframe media. Same-slide fragment reveals do not stop media, and global/deck-level parent audio such as `audio-src` is left alone. Do not hand-roll per-slide cleanup scripts for regular video/audio players.
|
||||
|
||||
Every copied `<video>` / `<audio>` with a `src` must be timed for HyperFrames ownership:
|
||||
|
||||
```html
|
||||
<video
|
||||
src="assets/demo.mp4"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
data-start="0"
|
||||
data-duration="96"
|
||||
data-has-audio="true"
|
||||
></video>
|
||||
```
|
||||
|
||||
Use `data-has-audio="true"` only for audible media. Muted autoplay loops can omit it. Do not leave `preload="none"` in converted compositions.
|
||||
|
||||
Implementation detail: iframe media elements belong to the iframe's DOM realm. Fallback code in the parent page/player must not use the parent page's `el instanceof HTMLMediaElement` check for iframe nodes; in real browsers that fails and leaves videos audible. Use `el.ownerDocument.defaultView.HTMLMediaElement` or a tag/duck-type guard before setting `muted` or calling `pause()`.
|
||||
|
||||
### Mute toggle — built-in chrome control
|
||||
@@ -650,14 +985,22 @@ if (!renderer) {
|
||||
|
||||
## 8. Foot-gun checklist
|
||||
|
||||
| Failure | Symptom | One-line fix |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
|
||||
| Wrapper SFX in the iframe | Click/transition sounds silent | Move SFX Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
|
||||
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
|
||||
| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
|
||||
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
|
||||
| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
|
||||
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
|
||||
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
|
||||
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
|
||||
| Failure | Symptom | One-line fix |
|
||||
| ----------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
|
||||
| Wrapper SFX in the iframe | Click/transition sounds silent | Move SFX Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
|
||||
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
|
||||
| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
|
||||
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
|
||||
| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
|
||||
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
|
||||
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
|
||||
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
|
||||
| Custom media visualizer uses its own timer | Canvas/playhead drifts from the actual video or native controls | Drive visual state from media events and `media.currentTime`; do not use an independent `setTimeout` clock |
|
||||
| One `data-wired` flag means two different things | Pre-rendered timeline HTML skips media listener setup | Use separate markers such as `data-timeline-rendered` and `data-media-sync-wired` |
|
||||
| Presenter media events are not bridged | Audience follows slides but not play/pause/seek/mute | Mirror native media events over `BroadcastChannel("hf-slideshow:" + location.pathname)` in standalone wrappers with interactive media |
|
||||
| Remote play is blocked in the audience window | Audience media time jumps but video never plays | Try muted playback first; if `media.play()` rejects, show an audience unlock button and ignore live `timeupdate` chasing until playback succeeds |
|
||||
| Audience muted autoplay publishes back to presenter | Presenter audio starts, then mutes or cuts out | Publish media events only from presenter mode; audience mute is a local browser-autoplay workaround, not shared media state |
|
||||
| Copied media lacks HyperFrames timing | Lint errors on untimed media; preview/render diverge | Add `data-start`, `data-duration`, and `data-has-audio="true"` when audible; avoid `preload="none"` |
|
||||
| Source font CSS variables kept as font-family values | StaticGuard font-family contract errors | Replace with concrete render-safe stacks or add local `@font-face` declarations |
|
||||
| Converted scroll/camera source jumps between slides | Per-slide focal points are correct but manual navigation snaps | Add a standalone navigation-camera transition hook; disable it for measurement, initial load, resize, and render/validation seeks |
|
||||
|
||||
Reference in New Issue
Block a user