Follow the song in the lyrics
Synced LRC highlights the playing line, keeps it centred and seeks on click. Plain text scrolls continuously with a nudge knob instead — a highlight on guessed timings turns guaranteed drift into what looks like a bug. Seuraa kappaletta turns following off without losing the highlight, and scrolling by hand turns it off too. Fixes the scroll landing in the wrong place (offsetTop measured from a different coordinate space than the box it was applied to) and the fader shifting the deck sideways at score 100 (auto-sized grid columns plus a readout spanning both).
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)
|
||||
})
|
||||
})()
|
||||
+39
-3
@@ -485,11 +485,45 @@ button.link:hover { background: none; color: var(--primary-hover); }
|
||||
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;
|
||||
@@ -548,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 --- */
|
||||
|
||||
Reference in New Issue
Block a user