Frontend (vanilla JS/CSS, no framework)
The page is plain ES5-ish JavaScript and hand-written CSS — no framework, no bundler, no build step, no npm dependencies. Two vendored libraries in static/lib/ (fast-average-color, vibrant) are loaded via <script> tags. Keep it that way: don't introduce a framework, a bundler, or a package.json.
static/nowplaying.js — all page behaviour (~1300 lines, one file).
static/style.css — all styling.
templates/nowplaying.html — the Jinja page. templates/_icons.html — reusable inline-SVG icon macros ({% import "_icons.html" as icons %}).
DEV=1 python app.py live-reloads templates and disables static caching, so HTML/CSS/JS edits show on a plain refresh (see config.py).
The control that resumes the karaoke follow wears lyrics_sync_icon, freed when the auto-search switch went: a circular-arrow glyph there read as a second refresh button next to the real one.
Every icon on the page is an inline SVG macro from _icons.html, drawn in the same language: a 24px viewBox, fill: none, a 2px currentColor stroke with round caps, shared by the private _frame macro, and a pixel size passed by the caller. Icons are never emoji or characters from an icon font — a Raspberry Pi kiosk running Raspberry Pi OS has no emoji font installed and renders them as tofu boxes.
Lint gate
ESLint (flat config eslint.config.mjs, dependency-free) runs in CI on static/*.js (.github/workflows/web-ci.yml):
npx --yes eslint@9 static/*.js
Match the existing style: var, small named functions, try/catch around localStorage, defensive null checks (if (!el.retry) { return; }).
How the page gets its data
Nothing is hardcoded in the JS that the server already knows:
- i18n — the template serializes the chosen-language dict into a
<script id="i18n-data" type="application/json"> block; the JS reads it once as I18N and uses I18N.some_key for every dynamic string. Never hardcode a display string in JS — add a key in i18n.py (both fr and en) and read it from I18N. See the i18n skill.
- Server host —
document.body.dataset.lyrionHost (LYRION_HOST).
- Live state — the JSON endpoints:
/now-playing.json (polled), /stats.json, /lyrics.json, /mosaic-covers.json, /recent-covers.json.
- DOM handles — collected once into the
el = { ... } object by id; reuse those, don't re-query.
The polling loop
poll() fetches /now-playing.json every POLL_INTERVAL_MS (2000ms) and calls render(data). Two efficiency contracts to preserve when editing:
- The page sends
?known=<track key> (the id|title|artist|album it already shows); the server omits lyrics when it matches, so a steady-state poll skips the DB. Keep the key format in sync with the route.
?player=<id> pins the switcher's pick (persisted in localStorage); a malformed id is dropped server-side.
catchUp() re-syncs after the tab was backgrounded. Stats poll separately (pollStats).
paintProgress runs on its own 250ms interval, extrapolating the position between network polls (and driving syncLyrics while there are lyrics). Both progress bars are animated with transform: scaleX(), never width: a width transition invalidates layout and paint on every frame of its 0.4s run, so the tick repainted the card continuously; scaleX is compositor-only and costs nothing.
Accent color from the cover
The signature visual: the page samples the cover art (served same-origin via /cover/... precisely so the canvas isn't tainted) to derive two colors — a tint (the average color, FastAverageColor) and an accent (the dominant vibrant swatch, Vibrant, normalized in HSV via rgb2Hsv/hsv2Rgb: fixed brightness ACCENT_V, saturation clamped into [ACCENT_SAT_FLOOR, ACCENT_SAT_MAX], and swatches under ACCENT_SAT_MIN — greyscale covers — falling back to ACCENT_DEFAULT). SWATCH_ORDER sets swatch preference. Vibrant reads its input at its layout size (width/height, not the natural size) and samples one pixel in five, so it is never handed the <img> itself — the same cover would yield a different accent on every screen size. It reads a canvas copy at COVER_SIZE (swatchSource), the size library covers are fetched at (?size=): those are read pixel for pixel, oversized remote artwork is scaled down. setTint/setAccent/resetColors push them into CSS custom properties. If you change how covers are served, keep them same-origin or the tint breaks.
The body.in-app header is the one place held out of that tinting: .lyrion-link fills the two round buttons with translucent white rather than the accent, so they lift whatever is behind them by a constant amount and drift with the page's own tint. Only the Android app ever shows that bar, and there it is framed by native chrome — status bar, navigation bar, the settings screen, all pinned to colors.xml — which cannot follow the cover, so an accent-filled button was the only thing in the frame changing hue per track. Anything visible on the web keeps the accent; the accent's other landing spots (progress, the active line, the switch when on, focus outlines, the stats figures) are unaffected.
.now-playing and .stats-panel carry no backdrop-filter, and should not get one back: the only thing painted behind them is the body's gradient, so blurring it is a visual no-op (flat fills differ by at most 1/255 with the blur removed), while the blur puts each on its own composited layer. That costs a Raspberry Pi its GPU compositing path — where a window covering the output is handed straight to the driver and Chromium's partial updates flash on screen — and makes the software fallback (--disable-gpu-compositing) too slow to scroll lyrics. The blur that does real work is .cover-zoom-frost, which blurs artwork rather than a gradient.
html declares color-scheme: dark. Without it the UA paints its light widgets — the page and lyrics scrollbars above all — over the dark page, which is what a Raspberry Pi kiosk shows. scrollbar-color tints them from --tint-color alongside everything else, and inherits, so the one declaration covers every scroller on the page.
Fitting the viewport
Two regimes, split at 861px wide. Above it the page itself is the viewport (html/body at 100dvh, overflow: hidden) and every overflowing region scrolls internally. Below it the page scrolls, because the stats panel wraps under the card — but the card is still fitted to the visible viewport in landscape (.left-panel at 100dvh minus the body gutter), so the lyrics box and the mode row under it are never left off screen. Both work the same way: the card is a grid of auto 1fr rows, .np-lyrics-block stretches into the 1fr one, and .np-lyrics takes what is left with flex: 1 1 0 — a basis of auto would size it from the synced lyrics' full content height and push the row below out of view. 100dvh, never 100vh, which is iOS Safari's large viewport.
Every cover on the page keeps its artwork's ratio rather than a fixed square; coverRatio reads it from the image's naturalWidth/naturalHeight once loaded, clamped to [COVER_R_MIN, COVER_R_MAX] (0.5–2). The card's fitCardCover sets it as --np-cover-r on .np-cover, and the box is as wide as its slot for a landscape picture, as tall as the slot is wide for a portrait one, centred across the slot — so the slot sizes below are its upper bound. On the stacked layouts (≤1080px) .np-side is centred vertically in the first row, so a landscape cover shorter than the meta sits level with its middle; above that the cover keeps its column's top edge, which the pile hangs from. object-fit: cover only crops past the clamp. The box hugging the picture is also what keeps the recent-plays pile tucked under its lower edge and the enlarged-cover FLIP starting from the picture itself.
On the stacked layouts the cover is the lever on a short screen: it sets the card's first row, so the ladder shrinks it by viewport height (200px under 1081px wide, 160px under 600px tall, 100px under 500px) and the lyrics take what it gives back. Its floor is the meta beside it, ~94px — shrink past that and the row stops moving. Above 1080px wide the cover spans both rows in a column of its own and the meta sets row one, so it stays full size however short the screen.
Karaoke lyrics sync
parseLRC turns timestamped LRC into [{time, text}]; syncLyrics (driven off the aged playback time from the poll) highlights the current line via paintLine and auto-scrolls, unless the user scrolled away (setAutoFollow, updateScrollReset, the resume-scroll button). Plain (un-timed) lyrics render as static text. The glide between lines is scroll-behavior: smooth on the box plus a transition on .lrc-line, both dropped under prefers-reduced-motion: reduce. In synced mode the box also carries will-change: transform, which is what makes that glide affordable: held on its own layer it is rasterised once and the scroll moves the layer, instead of re-rasterising every line on each frame of the animation. Without it the glide costs 481ms of raster over eight scrolls under a software compositor, against 2ms with it — the difference between a Raspberry Pi that can run this page and one that cannot. The web search runs for every track and has no on-screen control: an operator who wants the app to stop calling out sets LYRICS_PROVIDERS to an empty value. The display still picks synced over plain on its own, but which version ends up on screen is the user's to change — see the version cycler below.
Lyrics version cycler
versions holds the lyrics available for the current track — the library's own text when it has any, and every upload the web search returned (webVersions reads the response's versions list, falling back to its single lyrics/synced pair) — in order of preference: rankVersions puts synced versions first, then the library's text, then plain web versions, and remembered-hidden ones last, so the version the page lands on is always 1/n. versionIdx is versionIdx is the one on screen (-1 when none is). Both reset on every track. The source line under the lyrics box (#np-lyrics-source) is the control: updateSource() renders it, showVersion() moves between entries, and a tap advances the index. A retry keeps only the library's entry before searching again, so the new answer never stacks a second copy of the previous one.
MAX_VERSIONS (5) is the whole cycle's budget and mirrors the server constant of that name, since only the page knows whether a library text takes one of the places. rankVersions trims the tail to fit but never sheds the library's text, so the least useful web version is the one dropped.
A version whose text matches one already in the cycle, to the whitespace, never joins it (textKey, offeredAs): a provider repeating what the tags already hold, or two uploads of one transcription, would spend a place on a second copy of the same reading. Timestamps count in that comparison, which is what keeps an upload's two forms apart. The provider it came from is named beside the kept version's own source instead (noteEcho, sourceLabel: Library + LRCLIB), so a text the web confirmed never reads like one it could not find; a retry clears those names along with the previous answer.
The chip floats over the lyrics box's lower-left corner — the right edge is where a classic scrollbar puts its gutter, which Chromium on Linux reserves and Android does not, so the left side spares the page from measuring it. The box owes the chip --source-clearance of bottom padding — including in lrc-mode, which otherwise zeroes it. Without that, scrollTop clamps with the last line flush to the bottom edge and the chip covers the line being sung at the end of a song; the clearance is a shade wider than the chip's height plus its inset, so a full line of lyrics never rests under it.
One upload often carries both forms, and webVersions emits both: an LRC whose timings fit the recording badly is unreadable as karaoke, and its plain text is then the one that reads, so it stays one tap away rather than being weighed against the synced words first. Synced entries are emitted before plain ones, matching the server's order, so the cap keeps trimming the least useful end.
Two rules decide what lands on screen by itself (landOnVersions). Synced lyrics take it, since the library's own text is always plain; a plain web version only joins the cycle, as a second opinion on a library text that may be the wrong one, and the text on screen keeps it wherever ranking moved it. The control is a <button> that disableds itself into a passive label only while a search runs. Where the box sizes to its text (the scrolling phone layout), a tap holds its height as an inline min-height (holdLyricsHeight), so a shorter version or the hidden stop never pulls the chip out from under the finger; the hold is released on a track change and on a width change — not a height change, which the mobile URL bar fires on scroll — and skipped in the fitted layouts, whose box takes its height from the viewport (flex-basis: 0).
The cycle ends on one more stop past the last version (lyricsHidden, showHidden), where the chip reads None (0/n) — no source, ranked as the stop before the first: the box shows lyrics_hidden as an empty state, and the next tap starts over from the first version. It is how a text that matches nothing is cleared without a control of its own, so even a single version can be cycled. Stopping there remembers every version in the cycle, per device, in localStorage (lyrion.hiddenLyrics, the newest HIDDEN_LYRICS_MAX), keyed by artist|title plus a hash of the version's textKey rather than the track id, which a rescan renumbers. A track whose versions are all remembered opens hidden (allHidden), and ranking puts remembered ones last, so a version found later takes the top and shows by itself. Leaving the stop forgets them.
That label carries four things at once — provenance, length, rank (1/2) and the synced/plain indicator (.is-synced, the accent tint) — so the chevron, not a colour, is what marks it tappable: on this line the accent already means "synced" and cannot also mean "hover". The length is what tells two uploads of one song apart, album names being too long for the chip. While a search runs the chip says so in place of the source, and the spinner lives in the retry segment beside it rather than inside the chip. Its vertical padding is capped by the row: the switch beside it sets the row's height, and anything taller takes that height off the lyrics box above.
Enlarged cover
Both lyrics controls share .np-lyrics-tools, one pill floating over the box's lower-left corner: the row draws the surface and border, retry and the version chip are segments inside it, parted by a divider that only appears when both show. The row carries a min-height of the chip's own 25px, and retry alone drops its side padding for a 23px width (:has(+ .np-lyrics-source[hidden])), so on the tracks where it is the whole pill it is a circle rather than a capsule. Retry leads: it is the fixed-width one, so it stays under the finger while the chip's label changes length — the other way round it moved up to 134px between states, and furthest exactly when it had just been tapped on an empty panel. The row hides itself when neither does, or an empty outline is left over the lyrics. Retry is icon-only — a word beside the chip made the pair read as two objects — and its square padding is what centres the glyph. Retry shows whenever a track is playing — an unconvincing text on screen is as good a reason to search again as an empty panel, and with the bar gone this row is the only place left to ask from. It greys out while the server's per-track cooldown would refuse the search, and while a search runs it stays in place, disabled, its icon spinning (.is-busy, still under prefers-reduced-motion), so the pill never loses a segment mid-search. A search that fails behind the library's text (throttled, unreachable, network error) has no empty panel to say so, so retry turns --text-error (.is-failed) and puts the reason in front of its title and aria-label until the next search starts or the track changes; a search that ran and found nothing is not a failure.
Enlarged cover
The card's artwork is a button (#np-cover-button) opening #cover-zoom, an overlay holding the artwork with the track's title/artist/album over its lower edge. It covers .left-panel — the now-playing card only, leaving the stats panel readable — and is a sibling of the card rather than a child, because opening fades out the card's own children and the overlay must not fade with them.
There is no close button: a click anywhere on the overlay closes it, as does Escape, and render() closes it when playback stops. Focus never leaves the trigger, which carries aria-expanded.
The overlay has no surface of its own — the panel keeps its card background, and the card's content is what clears out under it (.left-panel.is-zoomed .now-playing > *, faded by animateCardContent). On the stacked layouts the overlay drops its padding so the picture runs edge to edge, the same width as the stats panel under it; --cover-zoom-radius keeps the picture's corners on the card's.
.cover-zoom-figure is sized as the largest box of the artwork's ratio that fits the panel — --cover-r (set from the card image's naturalWidth/naturalHeight, already decoded when the view opens, and settled again when the enlarged image loads — on a track change the card's copy still carries the previous artwork's dimensions) plus a 100cqh width off the overlay's container query. The picture fills that box, upscaled when the panel is bigger than the artwork, and the rounded edge, shadow and caption hug the picture rather than a letterboxed box. On the stacked layouts the card grows with the lyrics far past its own width, so .left-panel.is-zoomed squares it off.
The caption (.cover-zoom-meta) is a plaque hugging its text near the picture's lower edge, not a band across it: absolutely positioned with width: fit-content and auto margins. Its tint and its frosting are two layers, stacked under the text by z-index: .cover-zoom-frost (a span in the template) carries the backdrop-filter, and ::before carries the tint as a rounded box that its own filter: blur() feathers, so the plaque has no outline. They stay apart because an engine may drop a filter on an element that also has a backdrop-filter — the Android WebView does — which cost the plaque its soft edge when one layer carried both. For the same reason the frosting cannot be feathered by a filter, so a radial-gradient mask fades it out inside the tint: unmasked, it shows as a smeared rectangle wherever the artwork under the plaque is lighter or darker than its surroundings.
paintProgress paints the playback position into the bar on the artwork's bottom edge along with the card's own, so the two never drift.
Opening is a FLIP: animateZoom measures the card cover's box and the enlarged figure's box at run time — the panel's height follows the lyrics, so neither is fixed — and animates the figure from one to the other while the card's content fades out and the caption arrives late; animateCard animates the card's height over the same beat so the stats below slide rather than jump. Closing plays it backwards and only then sets hidden. Opening fills backwards only (zoomOpts): once it ends the enlarged state comes from the stylesheet rather than an animation holding its last frame. Everything is skipped under prefers-reduced-motion.
The card shows a 512px thumbnail; the overlay paints that cached thumbnail first and swaps in the original artwork (the same /cover/ URL without ?size=) once it has loaded, so it never shows a blank frame.
Stats panel
Each .stat-group folds on a click of its title; collapsed, it hides its .stat-rows but keeps its title total and its .stat-bar. The completion groups (tracks, albums, both artist counts) carry that bar: one .stat-bar-seg per sub row, sized by flex-grow from the row's percentage. The CSP refuses inline style attributes, so the template ships each percentage as data-pct and paintStatBars applies it through the CSSOM, on load and after every /stats.json poll; a zero segment is hidden so it leaves no gap. Each segment's title is copied from its legend row (statBarTitle), so hovering the bar of a collapsed group still gives the figures. A stat-level-1…stat-level-4 class on both the segment and its row sets --level-color, the row's square marker doubling as the bar's legend: the accent for the most played, two lighter mixes toward --surface, and near-black for never played.
Mosaic and recent-plays pile
The empty-state background mosaic (loadMosaic/layoutMosaic/stepMosaic) and the recent-plays sleeve pile (loadRecent/renderRecent) are decorative, desktop-driven layouts. They pull cover ids from /mosaic-covers.json and /recent-covers.json.
Both lay covers out in square slots and fit each artwork's ratio inside its slot: a mosaic tile is centred in its belt slot (--mosaic-r), and a sleeve is centred across its slot while stackRecent re-hangs the whole pile on every load, each sleeve overlapping the one above by RECENT_OVERLAP of that one's real height. The pile's fit loop sizes it for squares, the tallest case, so a pile of wide covers ends shorter rather than overflowing.
The mosaic's belt steps rather than flows: one cover every MOSAIC_STEP_MS, glided by the tiles' CSS transform transition, and nothing scheduled in between. Continuous motion is what costs — every frame recomposites the whole backdrop, a full core against 2.7% for a still collage — and the cost scales with the step rate, so that constant is the only knob worth turning. A cover only ever glides one slot along its row; anywhere the belt is discontinuous it is placed outright, which stays invisible because those breaks are all off the card.
Measuring the page
Two traps, both of which quietly yield wrong numbers rather than an error:
- The app's
CSP default-src 'self' blocks a <style> injected at runtime, so a probe that overrides CSS that way measures nothing at all — the override never applies. CSSOM writes (el.style.foo = …) and the Web Animations API are unaffected.
getBoundingClientRect() on a mosaic tile returns the bounding box of the 3°-rotated square, ~10px wider than the cover itself. Read the CSS width when the number matters.
Android bridge
Inside the Android WebView a native object window.LyrionApp is injected. The JS detects it (kept in APP_BRIDGE), adds body.in-app, reveals the header menu button, and wires it to bridge.openMenu() / openSettings(). Pull-to-refresh is app-only too: a downward drag starting in the card's cover/meta zone (PULL_ZONE, so never the scrolling lyrics box) rides the #np-pull badge down from the card's top edge and calls bridge.reload() past PULL_TRIGGER, which reloads through the shell so a server that has gone away lands on the native error view. The gesture only starts with the page and the card both at their top, and the touchmove listener is non-passive because suppressing the WebView's overscroll needs preventDefault(). On Android, the "open in Lyrion" links become intent:// URLs targeting the LMS Material app. Guard any app-only behaviour behind the presence of the bridge, as the existing code does.
Regenerate the screenshots after a visual change
The images (docs/screenshots/) are checked in and embedded in the READMEs: the header pair (dashboard-en.png in EN, dashboard-fr.png in FR, plus dashboard-app.png) and the row of thumbnails below it (demo-*.png and dashboard-mobile.png, shared by both languages). If your change alters what the dashboard looks like — layout, styling, colors, the empty state, the lyrics/stats panels, an icon, anything a user would see — regenerate them so the docs don't drift from the app:
pip install -r requirements.txt playwright
playwright install chromium # once
python scripts/generate_screenshots.py
The script runs the real app with the Lyrion/DB layers mocked (fake track, synced LRC, generated cover art, canned stats) and captures every image with headless Chromium — desktop in both languages, the mobile view, the Android app view, and the demo gallery (enlarged cover, karaoke lyrics, recent-plays pile, statistics panel, empty-state mosaic). Each capture is one Shot entry in the script's SHOTS map, so a new one is a single declarative line. No Lyrion server or database is needed. Commit the updated PNGs alongside the code change, and keep both language shots in sync (they're regenerated together). Skip this only for changes with no visual effect (pure refactors, endpoint-only tweaks).
Checklist
- No new framework/bundler/npm dep; stay vanilla and edit the single JS file.
- Every user-facing string comes from
I18N (key added to i18n.py FR+EN).
- Read server data from the existing endpoints /
data-* attributes.
- Keep covers same-origin so tinting works.
- Run
npx --yes eslint@9 static/*.js — it gates CI.
- Visual change? Regenerate
docs/screenshots/ (see above) and commit the PNGs.