diff --git a/lyrics.go b/lyrics.go index 6a517b9..d5ad22c 100644 --- a/lyrics.go +++ b/lyrics.go @@ -9,6 +9,8 @@ import ( "net/http" "net/url" "regexp" + "sort" + "strconv" "strings" "time" ) @@ -49,6 +51,51 @@ var lyricsClient = &http.Client{Timeout: lyricsTimout} // 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*)+`) +// 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 { if !strings.HasPrefix(strings.TrimSpace(s), "[") { return s diff --git a/lyrics_test.go b/lyrics_test.go index 14b0b89..fa9cb9d 100644 --- a/lyrics_test.go +++ b/lyrics_test.go @@ -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 // synced hit always beats a plain one. func TestFetchLyrics(t *testing.T) { diff --git a/songs.go b/songs.go index a2fd035..c7bd8eb 100644 --- a/songs.go +++ b/songs.go @@ -160,6 +160,10 @@ type songDetail struct { 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) { var d songDetail err := a.pool.QueryRow(ctx, `select`+songColumns+`, diff --git a/static/lyrics.js b/static/lyrics.js new file mode 100644 index 0000000..15ae7da --- /dev/null +++ b/static/lyrics.js @@ -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) + }) +})() diff --git a/static/style.css b/static/style.css index 795b23b..108e4fc 100644 --- a/static/style.css +++ b/static/style.css @@ -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 --- */ diff --git a/templates/layout.html b/templates/layout.html index 5231d49..4d3220f 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -9,6 +9,7 @@ +
diff --git a/templates/partials/player.html b/templates/partials/player.html index 1c83017..f95b6e9 100644 --- a/templates/partials/player.html +++ b/templates/partials/player.html @@ -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"}} +Sanoitukset +{{$lines := .LyricLines}} +{{if $lines}} +
+ {{range $lines}}

{{if .Text}}{{.Text}}{{else}} {{end}}

{{end}} +
+ +{{else}} +
+
{{lyricstext .Lyrics}}
+
+ +{{end}} +{{end}} + {{define "player"}} diff --git a/templates/song.html b/templates/song.html index 42f581a..f89176c 100644 --- a/templates/song.html +++ b/templates/song.html @@ -53,11 +53,8 @@
- {{with $s.Lyrics}} -
- Sanoitukset -
{{lyricstext .}}
-
+ {{if $s.Lyrics}} +
{{template "lyricsview" $s}}
{{end}}