mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-02 11:40:39 +00:00
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:
1 parent
d29afaea8c
commit
9fe574c41d
1 file changed
+67
-11
+67
-11
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user