Every control on the phone has a name now, in the app's own words, and the desktop's word where it already has one; the nav is a tab set whose badge count is part of the tab's name — "You, 3 transfers" rather than a stray 3 read out before it. The two sheets are dialogs with somewhere sensible for the focus to land, and the half sheet's grab handle is finally a real Close — the first dismissal a screen reader could reach, because the scrim never was one. A sheet that is down is inert rather than sitting off-screen in the accessibility tree with the hidden mini bar and the nav under it, and one live region is mounted for the life of the app so that an undo offer is actually announced; switches say on or off, chips say pressed, and the waveform is a slider where it can seek. Nothing visible changed. The walk counts the controls with no name, with a junk name, in a hidden layer, or with unsaid state — all four held at zero on ten screens, and the nameless count on a room and on both prompts besides, which is where the six fields named here actually live.
78 lines
3.5 KiB
Svelte
78 lines
3.5 KiB
Svelte
<script>
|
|
/* The artwork viewer — the desktop's lightbox: the cover alone, as large
|
|
as the screen allows, at the full resolution of the canonical cover
|
|
for a library record (the thumbnail shows first, the full one lands
|
|
over it). Two fingers zoom and pan; one finger pans a zoomed cover;
|
|
a tap on the unzoomed cover, or the back gesture, closes it. */
|
|
import { nav, closeViewer } from "./nav.svelte.js";
|
|
import { art } from "./art.svelte.js";
|
|
import * as api from "../lib/api.js";
|
|
|
|
const key = $derived(nav.viewerKey);
|
|
let full = $state("");
|
|
$effect(() => {
|
|
const k = key;
|
|
full = "";
|
|
scale = 1; tx = 0; ty = 0;
|
|
if (!k) return;
|
|
if (/^https?:/i.test(k)) { full = k; return; }
|
|
if (/^data:/i.test(k)) { full = k; return; } /* a peer's own picture: the bytes are already here */
|
|
if (/^stream:/.test(k)) return; /* a stream's tag art: the thumbnail is all there is */
|
|
let live = true;
|
|
api.albumArt(k, 2000).then((u) => { if (live && u) full = u; }, () => { /* the thumbnail stays */ });
|
|
return () => { live = false; };
|
|
});
|
|
const src = $derived(full || (key ? art[key] : "") || "");
|
|
|
|
/* pinch to zoom, pan while zoomed — pointer events, both fingers tracked */
|
|
let scale = $state(1);
|
|
let tx = $state(0);
|
|
let ty = $state(0);
|
|
const pts = new Map();
|
|
let pinch = null; /* { d, s, cx, cy, tx, ty } at the pinch's start */
|
|
let pan = null; /* { x, y, tx, ty } at a one-finger pan's start */
|
|
let moved = false;
|
|
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
|
|
const center = () => { const [a, b] = [...pts.values()]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
|
|
function down(e) {
|
|
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
moved = false;
|
|
if (pts.size === 2) { const c = center(); pinch = { d: dist(), s: scale, cx: c.x, cy: c.y, tx, ty }; pan = null; }
|
|
else if (pts.size === 1 && scale > 1) pan = { x: e.clientX, y: e.clientY, tx, ty };
|
|
}
|
|
function move(e) {
|
|
if (!pts.has(e.pointerId)) return;
|
|
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
if (pts.size === 2 && pinch) {
|
|
moved = true;
|
|
scale = Math.min(6, Math.max(1, pinch.s * (dist() / pinch.d)));
|
|
const c = center();
|
|
tx = pinch.tx + (c.x - pinch.cx);
|
|
ty = pinch.ty + (c.y - pinch.cy);
|
|
} else if (pts.size === 1 && pan) {
|
|
const dx = e.clientX - pan.x;
|
|
const dy = e.clientY - pan.y;
|
|
if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
|
tx = pan.tx + dx;
|
|
ty = pan.ty + dy;
|
|
}
|
|
}
|
|
function up(e) {
|
|
pts.delete(e.pointerId);
|
|
if (pts.size < 2) pinch = null;
|
|
if (pts.size === 0) pan = null;
|
|
if (scale <= 1.04) { scale = 1; tx = 0; ty = 0; }
|
|
}
|
|
function tap() {
|
|
if (!moved && scale === 1) closeViewer();
|
|
}
|
|
</script>
|
|
|
|
{#if nav.viewer}
|
|
<div class="viewer" onclick={tap} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}
|
|
role="button" tabindex="0" onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") closeViewer(); }} aria-label="Close artwork viewer">
|
|
{#if src}<img src={src} alt="" draggable="false" style:transform={scale === 1 ? "" : `translate(${tx}px, ${ty}px) scale(${scale})`} />{/if}
|
|
</div>
|
|
{/if}
|