Release 2026.08.01-1
Lyrics, versioning, and the song page's fact line. - Lyrics are suggested at submission and never imposed: the worker makes one LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist are typed. Neither overwrites what the submitter wrote. They live on the submission, travel to the song at publish, and stay editable after the song locks — the lock freezes what a song claims to be, and nobody reviewed the lyrics - The review strip reads and writes side by side: lyrics left, review right. Synced LRC highlights the playing line and seeks on click; plain text scrolls continuously with a nudge knob. Following can be turned off - CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the footer, the startup log and /healthz - The song page's metadata became four labelled cells instead of one flat run of five different kinds of fact Fixes: publishing wiped lyrics the worker had just fetched (a request that omitted a field cleared it), lyric auto-scroll landed in the wrong place, and the fader shifted the deck sideways at score 100.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Lyrics that follow the audio. Two behaviours, because the two kinds of lyrics deserve different
|
||||
// treatment: real LRC timestamps get a line highlight, guessed timings get a continuous scroll and
|
||||
// a nudge knob. Progressive enhancement — without this file the lyrics are still readable text.
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
// The audio element belonging to the same strip, falling back to the only one on the page.
|
||||
const audioFor = (box) => {
|
||||
const scope = box.closest('.strip') || box.closest('section') || document
|
||||
return scope.querySelector('audio') || document.querySelector('audio')
|
||||
}
|
||||
|
||||
// --- synced: highlight the line that is playing ---
|
||||
|
||||
function enhanceSynced(box) {
|
||||
const audio = audioFor(box)
|
||||
if (!audio) return
|
||||
const lines = [...box.querySelectorAll('.lline')]
|
||||
if (!lines.length) return
|
||||
const times = lines.map((l) => Number(l.dataset.t))
|
||||
let current = -1
|
||||
|
||||
// Following is what moves the box. Timestamps are somebody else's guess at where a line
|
||||
// starts, so when they are off, the scrolling is the part that fights you — the highlight can
|
||||
// stay. Remembered per song.
|
||||
const follow = box.parentElement.querySelector('.follow input')
|
||||
const key = 'lyricsfollow:' + box.dataset.song
|
||||
if (follow && localStorage.getItem(key) === 'off') follow.checked = false
|
||||
if (follow) {
|
||||
follow.addEventListener('change', () => {
|
||||
localStorage.setItem(key, follow.checked ? 'on' : 'off')
|
||||
})
|
||||
}
|
||||
|
||||
// Scrolling the box by hand turns following off: reading somewhere else is a clear statement
|
||||
// that you do not want to be dragged back.
|
||||
let selfScroll = false
|
||||
box.addEventListener('scroll', () => {
|
||||
if (selfScroll || !follow || !follow.checked) return
|
||||
follow.checked = false
|
||||
localStorage.setItem(key, 'off')
|
||||
})
|
||||
|
||||
const show = (i) => {
|
||||
if (i === current) return
|
||||
if (lines[current]) lines[current].classList.remove('on')
|
||||
current = i
|
||||
const line = lines[i]
|
||||
if (!line) return
|
||||
line.classList.add('on')
|
||||
if (follow && !follow.checked) return
|
||||
// Measured against the box itself. offsetTop is relative to the nearest positioned ancestor,
|
||||
// which is not this box, so using it scrolls to a position from a different coordinate space.
|
||||
const boxRect = box.getBoundingClientRect()
|
||||
const lineRect = line.getBoundingClientRect()
|
||||
const target = box.scrollTop + (lineRect.top - boxRect.top)
|
||||
- box.clientHeight / 2 + lineRect.height / 2
|
||||
selfScroll = true
|
||||
box.scrollTo({ top: target, behavior: quiet ? 'auto' : 'smooth' })
|
||||
// Long enough for the smooth scroll to finish, so our own movement is not mistaken for the
|
||||
// reader's.
|
||||
setTimeout(() => { selfScroll = false }, 700)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const t = audio.currentTime
|
||||
let i = current
|
||||
// Usually one step forward; a seek walks from wherever it lands.
|
||||
if (i < 0 || times[i] > t) i = 0
|
||||
while (i + 1 < times.length && times[i + 1] <= t) i++
|
||||
if (times[i] <= t) show(i)
|
||||
})
|
||||
|
||||
audio.addEventListener('seeked', () => {
|
||||
current = -1
|
||||
})
|
||||
|
||||
// Clicking a line seeks to it: the lyrics become a way to navigate the song.
|
||||
lines.forEach((line, i) => {
|
||||
line.addEventListener('click', () => {
|
||||
audio.currentTime = times[i]
|
||||
if (audio.paused) audio.play()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- plain: scroll the block in step with the audio ---
|
||||
|
||||
function enhancePlain(box) {
|
||||
const audio = audioFor(box)
|
||||
const inner = box.querySelector('.lscroll')
|
||||
if (!audio || !inner) return
|
||||
|
||||
const nudge = box.parentElement.querySelector('.nudge input')
|
||||
const readout = box.parentElement.querySelector('.nudge output')
|
||||
const key = 'lyricsoffset:' + box.dataset.song
|
||||
let offset = Number(localStorage.getItem(key) || 0)
|
||||
if (nudge) {
|
||||
nudge.value = offset
|
||||
readout.value = offset + ' s'
|
||||
}
|
||||
|
||||
const duration = () => Number(audio.duration) || Number(box.dataset.duration) || 0
|
||||
|
||||
// Position is a pure function of time, so a seek needs no bookkeeping and drift cannot
|
||||
// accumulate the way it would with a timer.
|
||||
const place = () => {
|
||||
const total = duration()
|
||||
const travel = inner.scrollHeight - box.clientHeight
|
||||
if (total <= 0 || travel <= 0) return
|
||||
const at = (audio.currentTime + offset) / total
|
||||
box.scrollTop = Math.max(0, Math.min(travel, at * travel))
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', place)
|
||||
audio.addEventListener('seeked', place)
|
||||
audio.addEventListener('loadedmetadata', place)
|
||||
|
||||
if (nudge) {
|
||||
nudge.addEventListener('input', () => {
|
||||
offset = Number(nudge.value)
|
||||
readout.value = (offset > 0 ? '+' : '') + offset + ' s'
|
||||
localStorage.setItem(key, offset)
|
||||
place()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.lyricsbox.synced').forEach(enhanceSynced)
|
||||
document.querySelectorAll('.lyricsbox.plain').forEach(enhancePlain)
|
||||
})
|
||||
})()
|
||||
+107
-3
@@ -330,6 +330,35 @@ footer.sitefooter {
|
||||
|
||||
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
|
||||
|
||||
.lyrics {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.lyrics > summary {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Lyrics are typed with intent: line breaks and indentation are the content. */
|
||||
.lyricstext {
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
margin: var(--space-4) 0 0;
|
||||
max-height: 26rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lyrics form { margin-top: var(--space-4); }
|
||||
|
||||
.review {
|
||||
padding: var(--space-4);
|
||||
border-left: 3px solid var(--input-border);
|
||||
@@ -424,10 +453,77 @@ button.link:hover { background: none; color: var(--primary-hover); }
|
||||
|
||||
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||
.deck .grow { flex: 1; }
|
||||
|
||||
/* Read on the left, write on the right. One column when the song has no lyrics — the pane is
|
||||
absent rather than empty. */
|
||||
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); flex: 1; min-height: 0; }
|
||||
.panes.solo { grid-template-columns: 1fr; }
|
||||
|
||||
.lyricspane, .writepane { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||
.writepane .grow { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.writepane textarea { flex: 1; min-height: 12rem; }
|
||||
|
||||
.cap {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lyricsbox {
|
||||
/* Grows with the pane but stops before it can push the page: long lyrics scroll inside the box
|
||||
rather than stretching the strip past the screen. */
|
||||
flex: 1 1 auto;
|
||||
min-height: 10rem;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bar);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
font-size: 0.95rem;
|
||||
/* Scrolling is smoothed in JS, which also knows when to skip it. Doing it here as well makes two
|
||||
mechanisms fight over the same element. */
|
||||
}
|
||||
|
||||
/* Synced lyrics: the line that is playing is the only bright one, and clicking any line seeks. */
|
||||
.lyricsbox.synced { white-space: normal; }
|
||||
|
||||
.lline {
|
||||
margin: 0;
|
||||
padding: 0.1rem 0;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.lline:hover { color: var(--text); }
|
||||
|
||||
.lline.on {
|
||||
color: var(--gold-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Uniform distribution models a song no real song obeys, so the reader gets a knob. */
|
||||
.follow { display: flex; align-items: center; gap: var(--space-2); font-size: 0.8rem;
|
||||
color: var(--muted); cursor: pointer; }
|
||||
.follow input { width: auto; accent-color: var(--primary); }
|
||||
|
||||
.nudge { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem;
|
||||
color: var(--muted); font-family: var(--font-display); text-transform: uppercase;
|
||||
letter-spacing: 0.06em; }
|
||||
.nudge input { flex: 1; accent-color: var(--primary); }
|
||||
.nudge output { min-width: 4ch; text-align: right; color: var(--text); }
|
||||
|
||||
.deck .player { margin: 0; }
|
||||
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||
|
||||
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
|
||||
/* Fixed columns, not auto: the readout spans both, so an auto track would widen the whole fader
|
||||
when the score reaches three digits and shove the deck sideways mid-drag. */
|
||||
.fader { display: grid; grid-template-columns: 2rem 2rem; grid-template-rows: 1fr auto;
|
||||
gap: var(--space-2); align-items: stretch; }
|
||||
|
||||
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
|
||||
@@ -486,14 +582,16 @@ button.link:hover { background: none; color: var(--primary-hover); }
|
||||
.readout {
|
||||
grid-column: 1 / -1;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
/* Digits of equal width, so 99 → 100 does not shift anything inside the box either. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--gold-1);
|
||||
text-align: center;
|
||||
background: var(--bar);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 var(--space-2);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
|
||||
/* --- the reveal: one channel per reviewer --- */
|
||||
@@ -740,6 +838,9 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
|
||||
|
||||
/* A 200px fader on a phone is worse than a horizontal one. */
|
||||
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
|
||||
/* Side by side needs width it does not have here, so reading stacks above writing. */
|
||||
.panes { grid-template-columns: 1fr; }
|
||||
.lyricsbox { max-height: 14rem; }
|
||||
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
|
||||
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
|
||||
width: 100%; height: auto; min-height: 0; }
|
||||
@@ -980,3 +1081,6 @@ img.avatar { object-fit: cover; }
|
||||
|
||||
.version { color: var(--muted); font-family: var(--font-display); }
|
||||
.version::before { content: "·"; margin: 0 var(--space-2); }
|
||||
|
||||
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
||||
margin-top: var(--space-2); }
|
||||
|
||||
Reference in New Issue
Block a user