Files
party-player/public/player/player.css
T
simonandClaude Opus 5 db672b8b32 Rotate the background with every track
The poster behind the frame was one of two, picked by hand and then left
there all night. It now turns over on every track change the way the
animations do, and never as the same picture twice: a shuffle bag over
whatever images sit in assets/, each shown at a random zoom between 1.15
and 1.9 and cropped around a randomly chosen point of the poster.

The list is read from the directory rather than written out in config.js.
The old one named two files that are no longer there, which is the failure
mode a hand-kept list has; now a poster joins the rotation, with its own
button on the queue page, by being dropped in the folder.

Zoom never goes below 1.15. These are portrait prints and the screen is
wide, so cover fits the poster's full width and crops vertically — at 1
the paper margin shows as a grey stripe down both sides. 1.15 cuts at
least 13% off the width of every crop a 1920x1080 screen can produce.

The crop is drawn into a screen-sized canvas rather than handed to CSS as
an object-fit rule on a full-size <img>. The scans are 3508x4950, which is
a 70MB texture per layer and two of them composited for the length of the
fade; instead each pick is fetched, resized off the main thread to roughly
screen resolution, drawn once, and closed. A swap costs about 400ms of
async work, all of it before the fade begins, and no full-size bitmap is
held anywhere. Nothing about the posters' resolution reaches the
compositor, so assets/ can keep the 300 dpi originals.

Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the
auto cycle, the queue page's row leads with Auto, and isBackground()
guards the socket handler and the restored queue.json against a key that
no longer names a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 20:54:10 +02:00

321 lines
6.8 KiB
CSS

:root {
--accent: #ff4fc3;
--ink: #14060f;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
cursor: none;
}
/* --- background ------------------------------------------------------- */
#backgrounds {
position: fixed;
inset: 0;
}
/* Two canvases, cross-faded. The zoom and the crop are baked into the pixels
by backdrop.js, so these are always exactly screen-sized. */
#backgrounds canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 900ms ease;
/* Knock the poster back so the video frame stays the focal point — the
posters are pale and read far brighter on a projector than on a desk. */
filter: brightness(0.45) saturate(1.15);
}
#backgrounds canvas.active {
opacity: 1;
}
/* Built and removed by the wash animation. Sits between the poster and the
stills, and blends rather than covers, so the poster stays legible through
whatever colour is passing over it. The gradient itself is written per frame
from JS. */
#wash {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
/* Takes the poster's luminance and only replaces its hue, so the wash reads
as a colour change rather than as a sheet of colour laid over the top. */
mix-blend-mode: color;
}
/* --- frozen screenshots ------------------------------------------------ */
#stills {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
}
#stills img {
position: absolute;
top: 0;
left: 0;
object-fit: cover;
opacity: 0;
/* Matches #frameSkin, so a still reads as a frozen copy of the frame. */
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.85);
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.55),
0 24px 70px rgba(0, 0, 0, 0.7);
transition: opacity 500ms ease;
will-change: transform;
}
#stills img.visible {
opacity: 1;
}
/* The trail ramps opacity frame-by-frame as a still ages, so the CSS transition
above would only fight it. */
#stills img.trail {
transition: none;
}
/* --- the moving frame -------------------------------------------------- */
#frameMover {
position: fixed;
top: 0;
left: 0;
/* Above the stills, so the live video always wins if anything ever overlaps. */
z-index: 2;
will-change: transform;
}
#frameSkin {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.85);
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.55),
0 24px 70px rgba(0, 0, 0, 0.7);
background: #000;
}
/* Lift the frame while the drag animation is holding it, the way a dragged
window separates from the desktop. */
#frameMover.dragging #frameSkin {
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.75),
0 40px 100px rgba(0, 0, 0, 0.85);
}
/* The wash animation hands the frame the colour currently passing behind it. */
#frameMover.washing #frameSkin {
box-shadow:
0 0 0 4px var(--wash, rgba(255, 79, 195, 0.55)),
0 24px 70px rgba(0, 0, 0, 0.7);
}
/* --- the drawn pointer -------------------------------------------------- */
/* Built and removed by the drag animation; nothing else on the page has one. */
#pointer {
position: fixed;
top: 0;
left: 0;
z-index: 3;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
}
#pointer svg {
display: block;
transition: transform 90ms ease;
}
/* The click itself: the arrow dips, so a grab is visible even mid-pause. */
#pointer.down svg {
transform: scale(0.82);
}
/* --- pong --------------------------------------------------------------- */
/* Built and removed by the pong animation. Above the frame, so the ball is
never hidden behind the paddle it is bouncing off. */
#pong {
position: fixed;
inset: 0;
z-index: 3;
pointer-events: none;
}
#pongBall {
position: absolute;
top: 0;
left: 0;
background: #fff;
/* Square, and no smoothing: the original had no room for a round ball. */
box-shadow: 0 0 18px rgba(255, 79, 195, 0.7);
will-change: transform;
}
/* Out of play between a goal and the next serve. */
#pongBall.out {
opacity: 0;
}
#pongScore {
position: absolute;
top: clamp(16px, 4vh, 56px);
right: clamp(16px, 4vw, 72px);
display: flex;
gap: 0.35em;
font-size: clamp(2.5rem, 7vh, 6rem);
}
#pongScore .digit {
display: block;
height: 1em;
/* The viewBox is 24 wide by 44 tall. */
width: calc(1em * 24 / 44);
}
/* An unlit segment stays visible, just barely — that faint ghost of the whole
figure eight is what makes the display read as a seven-segment one. */
#pongScore .segment {
fill: rgba(255, 255, 255, 0.07);
}
#pongScore .segment.lit {
fill: #fff;
filter: drop-shadow(0 0 10px rgba(255, 79, 195, 0.8));
}
/* The paddle: a hard-edged slab, so the ball has something square to hit. */
#frameMover.paddle #frameSkin {
border-radius: 0;
box-shadow:
0 0 0 3px #fff,
0 0 34px rgba(255, 79, 195, 0.6);
}
.deck {
position: absolute;
inset: 0;
opacity: 0;
/* Opacity is driven frame-by-frame during a crossfade, so no CSS transition
here — it would fight the JS ramp. */
}
.deck iframe {
width: 100%;
height: 100%;
border: 0;
/* The iframe would otherwise swallow nothing useful, but keeping it inert
avoids stray clicks pausing playback. */
pointer-events: none;
}
/* The screen-capture stream's sink. Has to be in the document to keep getting
frames (see capture.js), but must not be visible or catch clicks. */
#captureSink {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
opacity: 0.01;
pointer-events: none;
z-index: -1;
}
#probe {
position: fixed;
left: -400px;
top: -400px;
width: 320px;
height: 180px;
opacity: 0;
pointer-events: none;
}
/* --- start gate -------------------------------------------------------- */
#gate {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(20, 6, 15, 0.92);
backdrop-filter: blur(6px);
z-index: 10;
cursor: pointer;
transition: opacity 400ms ease;
}
#gate.hidden {
opacity: 0;
pointer-events: none;
}
.gate-inner {
text-align: center;
color: #fff;
}
.gate-inner h1 {
font-size: clamp(2rem, 7vw, 5rem);
margin: 0 0 0.3em;
letter-spacing: -0.03em;
color: var(--accent);
}
.gate-inner p {
margin: 0 0 2em;
opacity: 0.7;
font-size: 1.05rem;
}
#start {
font: inherit;
font-size: 1.3rem;
padding: 0.7em 3em;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--ink);
font-weight: 700;
cursor: pointer;
}
#start:disabled {
opacity: 0.35;
cursor: default;
}
#status {
position: fixed;
bottom: 12px;
right: 16px;
z-index: 20;
color: var(--accent);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}