Clickable previews: every still links to its exact app frame

New shared component src/ui/preview.js (appFrameUrl + previewLink): song + seed + frame + venue/venueSel deep-links. App honors ?frame= (warm seek on bank-song load) and ?venueSel= via shared pinVenueShell helper (src/venues/pin.js), which filmstrip and venue-gallery now also use. Filmstrip figures and venue-gallery cards wrap canvases; gallery cells stay unlinked (isolated scene probes, not reproducible in-app).
This commit is contained in:
Dejvino
2026-09-20 17:08:03 +02:00
parent cd7dfcd63d
commit 6c7e314a3e
6 changed files with 182 additions and 31 deletions
+26 -3
View File
@@ -63,6 +63,8 @@
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
.strip figure { margin: 0; flex: 0 0 auto; width: 168px; }
.strip canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
a.preview-link canvas { cursor: zoom-in; }
a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
.strip figcaption { font-size: 10px; color: #565f70; margin-top: 3px; line-height: 1.35; }
.strip .t { color: #8b94a7; }
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -107,6 +109,7 @@ import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } fro
import { venues } from '/src/venues/registry.js';
import { blit } from '/src/checks/blit.js';
import { hashString } from '/src/engine/rng.js';
import { previewLink } from '/src/ui/preview.js';
import {
sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas,
@@ -156,8 +159,26 @@ const grade = (row) => {
// video. The app takes the bank's wav straight from `?song=`, and the seed is
// the one renderSong() uses — hashed off the name — so what plays is the take
// the strip measured rather than a fresh draw that happens to share a title.
const watchLink = (name) =>
`/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
// The venue flag and pin ride along too, so a venue strip replays its room.
const watchLink = (name) => {
const p = new URLSearchParams();
p.set('song', name);
p.set('seed', String(hashString(name)));
if (venueSel.value || venueEnabled) p.set('venue', '1');
if (venueSel.value) p.set('venueSel', venueSel.value);
return `/?${p}`;
};
// Deep link for one figure: song + seed + the frame it shows, so clicking a
// still opens the exact image in the app. Built by the shared preview
// component (src/ui/preview.js) — every preview page links the same way.
const shotSpec = (rowName, shot) => ({
song: rowName,
seed: hashString(rowName),
frame: shot.frame,
venue: venueEnabled || !!venueSel.value,
venueSel: venueSel.value || null,
});
/**
* One song's row.
@@ -192,6 +213,8 @@ function renderRow(row) {
if (shot.moment === 'climax') fig.className = 'climax';
const canvas = document.createElement('canvas');
fig.appendChild(canvas);
// Clicking a still opens the exact frame in the app.
if (Number.isFinite(shot.frame)) previewLink(canvas, shotSpec(row.name, shot));
const cap = document.createElement('figcaption');
const mm = Math.floor(shot.time / 60);
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
@@ -282,7 +305,7 @@ async function build() {
...row,
frames: undefined,
shots: await Promise.all((row.frames || []).map(async (f) => ({
time: f.time, kind: f.kind, scene: f.scene, act: f.act,
time: f.time, frame: f.frame, kind: f.kind, scene: f.scene, act: f.act,
tension: f.tension, journey: f.journey, moment: f.moment,
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
}))),