Files
Levyraati26_go/static/lyrics.js
T
Esa Kataja a9776c6dde 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).
2026-08-01 00:45:40 +03:00

136 lines
5.1 KiB
JavaScript

// 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)
})
})()