From 9fe574c41da154c53c2d0aacb5f74e89df44fe23 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:28:09 -0400 Subject: [PATCH] External embed Phase 5: pixel-art play/pause + fps + reduced-motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/index.html | 78 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 11 deletions(-) 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);