From d2e7b7e97da28e696f249b0f39322bc1a2b2dd78 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 12:06:04 -0500 Subject: [PATCH] Create karoke style lyrics using LRCLIB library with manual sync ability --- README.md | 12 ++++--- api/lyrics.php | 53 ++++++++++++++++++++++-------- api/nowplaying.php | 4 +++ assets/css/radio.css | 18 +++++++++++ assets/js/player.js | 76 +++++++++++++++++++++++++++++++++++++++++--- index.php | 9 +++++- 6 files changed, 149 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e9f3c19..87cb495 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,13 @@ treat each entry as a case-insensitive regex. If Genius has no cover, set `LASTFM_API_KEY` to fall back to Last.fm artwork. (`ARTWORK_SOURCE=lastfm` uses Last.fm only; `auto` tries Genius then Last.fm.) -Lyrics require `LYRICS_ENABLED=true` **and** a Genius token. Lyrics are scraped -from the public Genius song page and returned as plain text; if Genius changes -their page layout, `api/lyrics.php` may need a tweak. Set -`LYRICS_PREFER_ENGLISH=true` to prefer the first English-language match when a -song has several Genius entries. +Lyrics require `LYRICS_ENABLED=true`. They're looked up on +[LRCLIB](https://lrclib.net/) first — free, no key needed — and only fall back +to scraping the public Genius song page if LRCLIB has nothing **and** a Genius +token is configured. If Genius changes their page layout, `api/lyrics.php`'s +scraper may need a tweak. Set `LYRICS_PREFER_ENGLISH=true` to prefer the first +English-language match when a song has several Genius entries (only applies +to the Genius fallback). ### Player & appearance diff --git a/api/lyrics.php b/api/lyrics.php index 75f5984..d78b731 100644 --- a/api/lyrics.php +++ b/api/lyrics.php @@ -6,11 +6,18 @@ * falls back to scraping the public Genius song page if LRCLIB has nothing * and a Genius token is configured. Returns JSON: * - * { "found": true|false, "lyrics": "plain text\nwith newlines", "url": "…" } + * { + * "found": true|false, + * "lyrics": "plain text\nwith newlines", + * "synced": [{ "time": 12.34, "text": "…" }, …], // [] when unavailable + * "url": "…" + * } * * Lyrics are returned as plain text (tags/timestamps stripped) so the * front-end can render them with textContent — no HTML injection from a - * third-party page. + * third-party page. `synced` (line-level LRC timestamps, seconds) is only + * populated when LRCLIB has synced lyrics; the Genius fallback has no timing + * data so it's always [] there. * * Note: the Genius fallback scrapes HTML, which can change at any time. If * Genius restructures their page the extraction may need updating. @@ -18,7 +25,7 @@ require_once __DIR__ . '/_bootstrap.php'; -$empty = ['found' => false, 'lyrics' => '', 'url' => '']; +$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => '']; if (!cfg('lyrics.enabled')) { json_out($empty); @@ -31,6 +38,26 @@ if ($artist === '' && $title === '') { $timeout = 5; +/** + * Parse an LRC string into [{time, text}, …] sorted by time. A line can carry + * more than one leading "[mm:ss.xx]" tag (repeated text, e.g. a chorus reused + * at several timestamps) — each tag becomes its own entry. Non-timestamp + * metadata lines ("[ar:...]", "[ti:...]", …) don't match and are skipped. + */ +function parse_lrc($lrc) { + $out = []; + foreach (explode("\n", $lrc) as $line) { + if (!preg_match_all('/\[(\d+):(\d+(?:\.\d+)?)\]/', $line, $tags, PREG_SET_ORDER)) continue; + $text = trim((string) preg_replace('/\[\d+:\d+(?:\.\d+)?\]/', '', $line)); + if ($text === '') continue; + foreach ($tags as $tag) { + $out[] = ['time' => ((int) $tag[1]) * 60 + (float) $tag[2], 'text' => $text]; + } + } + usort($out, function ($a, $b) { return $a['time'] <=> $b['time']; }); + return $out; +} + /* -- LRCLIB (primary, no key required) -------------------------------------- */ function lrclib_lyrics($artist, $title, $timeout) { $url = 'https://lrclib.net/api/search?track_name=' . rawurlencode($title) @@ -41,17 +68,17 @@ function lrclib_lyrics($artist, $title, $timeout) { $hits = json_decode($raw, true); if (!is_array($hits) || count($hits) === 0) return null; + $synced = !empty($hits[0]['syncedLyrics']) ? parse_lrc((string) $hits[0]['syncedLyrics']) : []; $lyrics = trim((string) ($hits[0]['plainLyrics'] ?? '')); - if ($lyrics === '' && !empty($hits[0]['syncedLyrics'])) { - // No plain text given — strip the leading "[mm:ss.xx] " timestamps. - $lyrics = trim((string) preg_replace('/^\[\d+:\d+\.\d+\]\s*/m', '', (string) $hits[0]['syncedLyrics'])); + if ($lyrics === '' && !empty($synced)) { + $lyrics = implode("\n", array_map(function ($l) { return $l['text']; }, $synced)); } - return $lyrics === '' ? null : $lyrics; + return $lyrics === '' ? null : ['lyrics' => $lyrics, 'synced' => $synced]; } -$lrcLyrics = lrclib_lyrics($artist, $title, $timeout); -if ($lrcLyrics !== null) { - json_out(['found' => true, 'lyrics' => $lrcLyrics, 'url' => '']); +$lrc = lrclib_lyrics($artist, $title, $timeout); +if ($lrc !== null) { + json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '']); } /* -- Genius (fallback) ------------------------------------------------------- */ @@ -132,7 +159,7 @@ if ($pageUrl === '') json_out($empty); /* -- Scrape the lyric containers ------------------------------------------- */ $page = http_get($pageUrl, $timeout); if ($page === null) { - json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]); + json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl]); } $marker = 'data-lyrics-container="true"'; @@ -154,7 +181,7 @@ $lyrics = implode("\n", array_map('trim', explode("\n", $lyrics))); $lyrics = preg_replace("/\n{3,}/", "\n\n", $lyrics); if ($lyrics === '') { - json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]); + json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl]); } -json_out(['found' => true, 'lyrics' => $lyrics, 'url' => $pageUrl]); +json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl]); diff --git a/api/nowplaying.php b/api/nowplaying.php index ac7aead..4e899eb 100644 --- a/api/nowplaying.php +++ b/api/nowplaying.php @@ -35,6 +35,8 @@ $out = [ 'art' => '', 'listeners' => null, 'listeners_peak' => null, + 'elapsed' => null, // seconds into the current song, if the provider exposes it + 'duration' => null, ]; /** Remove configured junk (e.g. " (Remastered)", ".mp3") from a string. */ @@ -90,6 +92,8 @@ switch ($provider) { } $out['listeners'] = isset($data['listeners']['current']) ? (int) $data['listeners']['current'] : null; $out['listeners_peak'] = isset($data['listeners']['total']) ? (int) $data['listeners']['total'] : null; + $out['elapsed'] = isset($np['elapsed']) ? (int) $np['elapsed'] : null; + $out['duration'] = isset($np['duration']) ? (int) $np['duration'] : null; $out['online'] = !empty($data['is_online']) && ($out['title'] !== '' || $out['artist'] !== ''); break; diff --git a/assets/css/radio.css b/assets/css/radio.css index d8a0afa..15bdcfe 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -187,6 +187,24 @@ audio[controls] { width: 100%; outline: 0; } } .lyrics[hidden] { display: none; } /* [hidden] must beat position:absolute rule specificity */ +.lyrics-line { padding: 4px 0; opacity: .55; transition: opacity .2s ease, color .2s ease; } +.lyrics-line.current { opacity: 1; font-weight: 700; } + +.lyrics-sync { + position: sticky; top: 0; z-index: 1; + display: flex; align-items: center; justify-content: center; gap: 10px; + margin: -52px -20px 10px; padding: 10px 20px; + background: rgba(0, 0, 0, 0.85); + font-variant-numeric: tabular-nums; font-size: 0.85rem; +} +.lyrics-sync button { + width: 26px; height: 26px; border-radius: 50%; + background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); + color: #fff; cursor: pointer; +} +.lyrics-sync button:hover, .lyrics-sync button:focus { background: rgba(255, 255, 255, 0.3); outline: 0; } +.lyrics-sync[hidden] { display: none; } + /* Hide the scrollbar but keep wheel / touch / drag scrolling. */ .no-scrollbar, .lyrics, .history { scrollbar-width: none; /* Firefox */ diff --git a/assets/js/player.js b/assets/js/player.js index 2f98688..4e66cec 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -36,6 +36,9 @@ volume: $('volume'), lyricsToggle: $('lyrics-toggle'), lyrics: $('lyrics'), + lyricsLines: $('lyrics-lines'), + lyricsSync: $('lyrics-sync'), + lyricsOffset: $('lyrics-offset'), historyToggle:$('history-toggle'), historyBody: $('history-body'), history: $('history'), @@ -55,6 +58,13 @@ var currentTrack = null; var siteName = CFG.siteName || 'Radio'; + /* ------------------------------------------------------- karaoke lyrics */ + var trackStartedAt = null; // Date.now() ms estimate of when the track began + var syncedLines = []; // [{time, el}] for the current track, time-sorted + var activeLine = -1; + var karaokeTimer = null; + var lyricsOffsetMs = Number(localStorage.getItem('radio.lyricsOffsetMs')) || 0; + /* ---------------------------------------------------------------- utils */ function trackKey(t) { return (t.artist || '') + '' + (t.title || ''); } function fmtClock(secs) { @@ -391,21 +401,77 @@ } /* -------------------------------------------------------------- lyrics */ + function tickKaraoke() { + if (!syncedLines.length || trackStartedAt === null) return; + var elapsed = (Date.now() - trackStartedAt + lyricsOffsetMs) / 1000; + var idx = -1; + for (var i = 0; i < syncedLines.length; i++) { + if (syncedLines[i].time <= elapsed) idx = i; else break; + } + if (idx === activeLine) return; + if (activeLine >= 0) syncedLines[activeLine].el.classList.remove('current'); + activeLine = idx; + if (idx >= 0) { + syncedLines[idx].el.classList.add('current'); + syncedLines[idx].el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } + function startKaraoke() { + stopKaraoke(); + activeLine = -1; + tickKaraoke(); + karaokeTimer = setInterval(tickKaraoke, 300); + } + function stopKaraoke() { + if (karaokeTimer) clearInterval(karaokeTimer); + karaokeTimer = null; + } + + function updateOffsetLabel() { + if (el.lyricsOffset) el.lyricsOffset.textContent = (lyricsOffsetMs / 1000).toFixed(1) + 's'; + } + updateOffsetLabel(); + + if (el.lyricsSync) { + el.lyricsSync.addEventListener('click', function (e) { + var adj = e.target.getAttribute('data-adj'); + if (!adj) return; + lyricsOffsetMs += Number(adj) * 1000; + localStorage.setItem('radio.lyricsOffsetMs', lyricsOffsetMs); + updateOffsetLabel(); + }); + } + function loadLyrics(track) { if (!CFG.lyricsEnabled || !el.lyricsToggle) return; el.lyricsToggle.hidden = true; el.lyricsToggle.setAttribute('aria-pressed', 'false'); - if (el.lyrics) { el.lyrics.hidden = true; el.lyrics.textContent = ''; } + stopKaraoke(); + syncedLines = []; + if (el.lyrics) el.lyrics.hidden = true; + if (el.lyricsLines) el.lyricsLines.textContent = ''; + if (el.lyricsSync) el.lyricsSync.hidden = true; var body = new URLSearchParams(); body.set('artist', track.artist || ''); body.set('title', track.title || ''); api('api/lyrics.php', { method: 'POST', body: body }) .then(function (res) { - if (res.found && res.lyrics) { - if (el.lyrics) el.lyrics.textContent = res.lyrics; - el.lyricsToggle.hidden = false; + if (!res.found || !res.lyrics || !el.lyricsLines) return; + if (res.synced && res.synced.length) { + syncedLines = res.synced.map(function (l) { + var div = document.createElement('div'); + div.className = 'lyrics-line'; + div.textContent = l.text; + el.lyricsLines.appendChild(div); + return { time: l.time, el: div }; + }); + if (el.lyricsSync) el.lyricsSync.hidden = false; + if (el.lyrics && !el.lyrics.hidden) startKaraoke(); + } else { + el.lyricsLines.textContent = res.lyrics; } + el.lyricsToggle.hidden = false; }) .catch(function () {}); } @@ -416,6 +482,7 @@ var show = el.lyrics.hidden; el.lyrics.hidden = !show; el.lyricsToggle.setAttribute('aria-pressed', show ? 'true' : 'false'); + if (show && syncedLines.length) startKaraoke(); else stopKaraoke(); }); } @@ -485,6 +552,7 @@ /* --------------------------------------------------------------- poll */ function onNewTrack(t, providerArt) { currentTrack = t; + trackStartedAt = Date.now() - (Number(t.elapsed) > 0 ? Number(t.elapsed) * 1000 : 0); var joined = (t.artist && t.title) ? (t.artist + ' – ' + t.title) : (t.title || t.artist || ''); if (el.name) el.name.textContent = t.title || siteName; if (el.artist) el.artist.textContent = t.artist || ''; diff --git a/index.php b/index.php index 04e3171..94e3756 100644 --- a/index.php +++ b/index.php @@ -28,7 +28,14 @@ $waveActive = radio_waveform_active(); alt="Album art" title="= e(cfg('site.name')) ?>"/> -
+