External embed Phase 5: pixel-art play/pause + fps + reduced-motion

Animated pixel-art embeds (frames > 1) now respect three controls
per docs/EXTERNAL_EMBED_FEATURE.md §6.3 / §11.4:

- ?play=1 / ?play=0 — explicit auto-play toggle. If the param
  isn't set, autoplay follows the visitor's prefers-reduced-motion
  preference: motion-OK users get auto-play, reduced-motion users
  get start-paused with a play CTA. ?play=1 forces auto-play even
  in reduced-motion mode (the spec's "user opt-in" wording).
- ?fps=N — override frame rate, clamped to 1-60. Falls back to the
  payload's stored fps (default 8) when not set.
- A play/pause chip in the embed footer's left side that toggles
  on click. Re-renders the artwork with the new autoplay state;
  the renderer's RAF self-stops when the canvas leaves the DOM, so
  the swap is leak-free.

Renderer changes (renderPixelArtPreviewDOM):
- New opts.autoplay (default true) gates the requestAnimationFrame.
  When false, frame 0 is drawn but no animation loop starts.
- New opts.fps (number, optional) overrides pa.fps. Defaults
  unchanged for all existing callers (linked-design renderer,
  feed cards, modal preview) because they don't pass either opt.

Single-frame pixel art is unaffected — the play/pause chip only
appears for frames.length > 1, and the renderer ignores autoplay
for the still-image path.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-01 15:28:09 -04:00
1 parent d29afaea8c
commit 9fe574c41d
1 file changed
+67 -11
+67 -11
View File
@@ -21588,6 +21588,30 @@
const stage = root.querySelector('.embedStage');
const footer = root.querySelector('.embedFooter');
if (!stage || !footer) return;
const params = route.params || new URLSearchParams();
// Pixel-art animation controls (Phase 5). ?play=0 starts
// paused; ?fps=N overrides frame rate (clamped 1-60). Also
// start paused if the visitor prefers reduced motion, unless
// they've explicitly opted in via play=1.
const isAnimatedPixel = mode === 'pixelart'
&& Array.isArray(payload?.pixelArt?.frames)
&& payload.pixelArt.frames.length > 1;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
const playParam = params.get('play');
const autoplay = isAnimatedPixel
? (playParam === '1' ? true
: playParam === '0' ? false
: !prefersReducedMotion)
: true;
const fpsParam = parseInt(params.get('fps') || '', 10);
const fpsOverride = Number.isFinite(fpsParam) ? Math.max(1, Math.min(60, fpsParam)) : null;
const renderOpts = {
compact: true,
transparentPixelArt: true,
autoplay,
...(fpsOverride ? { fps: fpsOverride } : {}),
};
stage.innerHTML = '';
const wrap = document.createElement('div');
// Reuse the .designObjectFitWrap class — its `> * { flex: 0 0
@@ -21598,20 +21622,25 @@
wrap.className = 'designObjectFitWrap';
wrap.style.cssText = 'position:relative;width:100%;height:100%;overflow:hidden;display:flex;align-items:center;justify-content:center';
stage.appendChild(wrap);
try {
renderDesignPayloadPreview(wrap, payload, { compact: true, transparentPixelArt: true });
fitMiniPreview(wrap);
} catch (err) {
console.warn('[embed] render failed', err);
_setEmbedStatus(root, 'error', 'Design preview unavailable.');
return;
}
const paint = (playing) => {
wrap.innerHTML = '';
try {
renderDesignPayloadPreview(wrap, payload, { ...renderOpts, autoplay: playing });
fitMiniPreview(wrap);
return true;
} catch (err) {
console.warn('[embed] render failed', err);
_setEmbedStatus(root, 'error', 'Design preview unavailable.');
return false;
}
};
if (!paint(autoplay)) return;
// Footer: chip-style controls float over the bottom edge of
// the stage with no background band — so the embed blends
// into any host theme. Each chip carries its own dark blur
// for cross-theme legibility.
footer.innerHTML = '';
const params = route.params || new URLSearchParams();
const showAttr = params.get('attr') !== '0';
const showOpen = params.get('open') !== '0';
const showControls = params.get('controls') !== '0';
@@ -21620,6 +21649,24 @@
const right = document.createElement('div');
right.className = 'embedSide';
right.style.justifyContent = 'flex-end';
// Multi-frame pixel art gets a play/pause chip so viewers can
// pause an animation that's distracting on a host page — and
// play if it started paused due to prefers-reduced-motion.
if (isAnimatedPixel && showControls) {
let playing = autoplay;
const pp = document.createElement('button');
pp.type = 'button';
pp.className = 'embedChip';
pp.textContent = playing ? '⏸' : '▶';
pp.title = playing ? 'Pause' : 'Play';
pp.addEventListener('click', () => {
playing = !playing;
pp.textContent = playing ? '⏸' : '▶';
pp.title = playing ? 'Pause' : 'Play';
paint(playing);
});
left.appendChild(pp);
}
if (showAttr && event?.pubkey) {
left.appendChild(_makeAuthorChip(event.pubkey));
}
@@ -25927,6 +25974,11 @@
function renderPixelArtPreviewDOM(container, payload, opts = {}) {
const compact = !!opts.compact;
const transparentBackground = !!opts.transparentPixelArt;
// Embed-mode overrides for animated pixel art. Defaults preserve
// existing behavior in every other caller (autoplay=true, fps
// from payload).
const autoplay = opts.autoplay !== false;
const fpsOverride = Number.isFinite(opts.fps) ? opts.fps : null;
const pa = (payload && payload.pixelArt) || {};
const w = (pa.width | 0) || 32, h = (pa.height | 0) || 32;
const naturalW = PREVIEW_BASE_PX;
@@ -25947,7 +25999,7 @@
// Pre-render every frame to its own native-size canvas, then animate
// by blitting from the cached canvases onto the display canvas. Much
// cheaper than re-decoding RLE each tick.
const fps = Math.max(1, Math.min(60, pa.fps | 0 || 8));
const fps = Math.max(1, Math.min(60, fpsOverride || pa.fps | 0 || 8));
const loop = pa.loop !== false;
const cached = frames.map((_, i) => renderPixelGridToCanvas(pa, i));
const cv = document.createElement('canvas');
@@ -25978,7 +26030,11 @@
ctx.drawImage(cached[frameIdx], 0, 0);
if (!ended) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
// Only start the RAF when autoplay is on. Otherwise we
// leave the canvas showing frame 0 — the embed paint path
// will re-call this renderer with autoplay:true if the
// viewer clicks the play chip.
if (autoplay) requestAnimationFrame(tick);
box.appendChild(cv);
} else {
const cv = renderPixelGridToCanvas(pa);