diff --git a/src/index.html b/src/index.html
index 91ae091..6f30222 100644
--- a/src/index.html
+++ b/src/index.html
@@ -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);