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:
Esa Kataja
2026-08-01 00:45:40 +03:00
parent 4f337b6202
commit a9776c6dde
8 changed files with 281 additions and 8 deletions
+47
View File
@@ -9,6 +9,8 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"sort"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -49,6 +51,51 @@ var lyricsClient = &http.Client{Timeout: lyricsTimout}
// nobody wants to read [00:11.74] at the start of every line. // nobody wants to read [00:11.74] at the start of every line.
var lrcStamp = regexp.MustCompile(`^(\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]\s*)+`) var lrcStamp = regexp.MustCompile(`^(\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]\s*)+`)
// A line of synced lyrics: the seconds it starts at, and the words.
type lyricLine struct {
At float64
Text string
}
var lrcOne = regexp.MustCompile(`\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]`)
// parseLRC returns nil for plain text, which is the signal to scroll continuously instead of
// highlighting: a line-by-line highlight on guessed timings makes every second of drift read as a
// bug.
func parseLRC(s string) []lyricLine {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return nil
}
var out []lyricLine
for _, raw := range strings.Split(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 {
continue
}
text := strings.TrimSpace(lrcStamp.ReplaceAllString(raw, ""))
// One line can carry several timestamps when a refrain repeats.
for _, m := range stamps {
min, _ := strconv.Atoi(m[1])
sec, _ := strconv.Atoi(m[2])
at := float64(min*60 + sec)
if m[3] != "" {
frac, _ := strconv.Atoi(m[3])
switch len(m[3]) {
case 1:
at += float64(frac) / 10
case 2:
at += float64(frac) / 100
default:
at += float64(frac) / 1000
}
}
out = append(out, lyricLine{At: at, Text: text})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
return out
}
func stripLRC(s string) string { func stripLRC(s string) string {
if !strings.HasPrefix(strings.TrimSpace(s), "[") { if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return s return s
+26
View File
@@ -21,6 +21,32 @@ func TestCleanLyrics(t *testing.T) {
} }
} }
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
// lines on timings nobody measured.
func TestParseLRC(t *testing.T) {
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
t.Fatalf("plain text parsed as synced: %v", got)
}
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
}
// Sorted by time, whatever order the file had.
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
}
if lines[1].At != 11.74 || lines[2].At != 62 {
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
}
// A refrain can carry several timestamps on one line, and each is its own occurrence.
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
}
}
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a // The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
// synced hit always beats a plain one. // synced hit always beats a plain one.
func TestFetchLyrics(t *testing.T) { func TestFetchLyrics(t *testing.T) {
+4
View File
@@ -160,6 +160,10 @@ type songDetail struct {
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 } func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
// Synced lyrics get a line-by-line highlight; plain text scrolls continuously instead, because a
// highlight on guessed timings makes every second of drift look like a bug.
func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) { func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
var d songDetail var d songDetail
err := a.pool.QueryRow(ctx, `select`+songColumns+`, err := a.pool.QueryRow(ctx, `select`+songColumns+`,
+135
View File
@@ -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
View File
@@ -485,11 +485,45 @@ button.link:hover { background: none; color: var(--primary-hover); }
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.7; line-height: 1.7;
font-size: 0.95rem; 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; } .deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; } .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; } gap: var(--space-2); align-items: stretch; }
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right; .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 { .readout {
grid-column: 1 / -1; grid-column: 1 / -1;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2rem; font-size: 1.8rem;
font-weight: 700; 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); color: var(--gold-1);
text-align: center; text-align: center;
background: var(--bar); background: var(--bar);
border: 1px solid var(--input-border); border: 1px solid var(--input-border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0 var(--space-2); padding: 0 var(--space-1);
} }
/* --- the reveal: one channel per reviewer --- */ /* --- the reveal: one channel per reviewer --- */
+1
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script> <script src="/static/player.js" defer></script>
<script src="/static/lyrics.js" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
+27
View File
@@ -1,3 +1,30 @@
{{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
Synced: one element per line with its own timestamp, highlighted as it comes.
Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
guessed timings turns guaranteed drift into what looks like a bug. */}}
{{define "lyricsview"}}
<span class="cap">Sanoitukset</span>
{{$lines := .LyricLines}}
{{if $lines}}
<div class="lyricsbox synced" data-song="{{.ID}}">
{{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}}&nbsp;{{end}}</p>{{end}}
</div>
<label class="follow">
<input type="checkbox" checked> Seuraa kappaletta
<span class="muted small">— korostus jatkuu, sivu ei vieri</span>
</label>
{{else}}
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
<div class="lscroll">{{lyricstext .Lyrics}}</div>
</div>
<label class="nudge">
Ajoitus
<input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
<output>0 s</output>
</label>
{{end}}
{{end}}
{{define "player"}} {{define "player"}}
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means <!-- Ships with native controls; player.js removes them and drives the same element. No JS means
the browser's own player, which is plain but complete. --> the browser's own player, which is plain but complete. -->
+2 -5
View File
@@ -53,11 +53,8 @@
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following <!-- Two panes when the song has lyrics: read on the left, write on the right, so following
the words costs no scrolling. Without lyrics the pane is absent, not empty. --> the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
<div class="panes{{if not $s.Lyrics}} solo{{end}}"> <div class="panes{{if not $s.Lyrics}} solo{{end}}">
{{with $s.Lyrics}} {{if $s.Lyrics}}
<div class="lyricspane"> <div class="lyricspane">{{template "lyricsview" $s}}</div>
<span class="cap">Sanoitukset</span>
<div class="lyricsbox">{{lyricstext .}}</div>
</div>
{{end}} {{end}}
<div class="writepane"> <div class="writepane">
<label class="grow">Arvostelu <label class="grow">Arvostelu