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