[FEAT] Karaoke style lyrics #2

Merged
iamdoubz merged 7 commits from feat_karaoke_lyrics into main 2026-07-27 15:46:57 -05:00
9 changed files with 240 additions and 29 deletions
+11 -2
View File
@@ -131,13 +131,17 @@ FALLBACK_ART="assets/img/placeholder.svg"
# Get a key at https://www.last.fm/api/account/create
LASTFM_API_KEY=""
# Show a "Show lyrics" button (requires a Genius token below).
# Show a "Show lyrics" button. Lyrics are looked up on LRCLIB (free, no key)
# first, falling back to Genius below only if LRCLIB has nothing.
LYRICS_ENABLED=true
# Prefer the first English-language match on Genius (costs a few extra API
# calls per song). Useful if your library has many non-English duplicates.
# Only applies to the Genius fallback.
LYRICS_PREFER_ENGLISH=false
# Genius API token. Get one at https://genius.com/api-clients (Client Access Token).
# Genius API token — optional, only used as a lyrics fallback when LRCLIB has
# nothing (still used as the primary/only source for album art lookups).
# Get one at https://genius.com/api-clients (Client Access Token).
# Provide EITHER the plain token...
GENIUS_TOKEN=""
# ...OR a base64-encoded token (kept for compatibility with the original project).
@@ -213,3 +217,8 @@ MATOMO_SITE_ID=""
TIMEZONE="UTC"
DEBUG=false
SHOW_ERRORS=false
# User-Agent sent with every outbound request this server makes (Genius,
# Last.fm, LRCLIB, custom now-playing feeds). Override this per deployment if
# you run more than one station and want each to identify itself distinctly.
OUTBOUND_USER_AGENT="radio/2026.07 (+https://git.dou.bet/iamdoubz/radio)"
+7 -5
View File
@@ -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
+2 -2
View File
@@ -51,7 +51,7 @@ function http_get($url, $timeout = 5, $headers = []) {
CURLOPT_TIMEOUT => (int) $timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_USERAGENT => 'radio-site/1.0 (+https://github.com)',
CURLOPT_USERAGENT => cfg('runtime.user_agent'),
]);
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
@@ -67,7 +67,7 @@ function http_get($url, $timeout = 5, $headers = []) {
$ctx = stream_context_create([
'http' => [
'timeout' => (int) $timeout,
'header' => implode("\r\n", $headers),
'header' => implode("\r\n", array_merge(['User-Agent: ' . cfg('runtime.user_agent')], $headers)),
'ignore_errors' => true,
],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
+69 -13
View File
@@ -2,23 +2,32 @@
/**
* api/lyrics.php
* --------------
* Optional lyrics lookup. Finds the song on Genius, then scrapes the lyric
* containers from the public song page. Returns JSON:
* Lyrics lookup. Tries LRCLIB first (free, no key, has synced lyrics), then
* 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 stripped) so the front-end can render
* them with textContent — no HTML injection from a third-party page.
* 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. `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: this scrapes Genius' HTML, which can change at any time. If Genius
* restructures their page the extraction may need updating.
* Note: the Genius fallback scrapes HTML, which can change at any time. If
* Genius restructures their page the extraction may need updating.
*/
require_once __DIR__ . '/_bootstrap.php';
$empty = ['found' => false, 'lyrics' => '', 'url' => ''];
$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => ''];
if (!cfg('lyrics.enabled') || cfg('genius.token') === '') {
if (!cfg('lyrics.enabled')) {
json_out($empty);
}
@@ -27,8 +36,55 @@ if ($artist === '' && $title === '') {
json_out($empty);
}
$token = cfg('genius.token');
$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)
. '&artist_name=' . rawurlencode($artist);
$raw = http_get($url, $timeout);
if ($raw === null) return null;
$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($synced)) {
$lyrics = implode("\n", array_map(function ($l) { return $l['text']; }, $synced));
}
return $lyrics === '' ? null : ['lyrics' => $lyrics, 'synced' => $synced];
}
$lrc = lrclib_lyrics($artist, $title, $timeout);
if ($lrc !== null) {
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '']);
}
/* -- Genius (fallback) ------------------------------------------------------- */
if (cfg('genius.token') === '') json_out($empty);
$token = cfg('genius.token');
$headers = ['Authorization: Bearer ' . $token];
/**
@@ -103,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"';
@@ -125,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]);
+4
View File
@@ -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;
+27
View File
@@ -187,6 +187,33 @@ audio[controls] { width: 100%; outline: 0; }
}
.lyrics[hidden] { display: none; } /* [hidden] must beat position:absolute rule specificity */
.lyrics-line {
display: block; width: 100%; text-align: left;
background: none; border: none; margin: 0; padding: 4px 0;
font: inherit; color: inherit; cursor: pointer;
opacity: .55; transition: opacity .2s ease, color .2s ease;
}
.lyrics-line:hover, .lyrics-line:focus-visible { opacity: .85; outline: 0; }
.lyrics-line.current { opacity: 1; font-weight: 700; }
.lyrics-sync {
position: absolute; top: 48px; right: 10px; z-index: 2;
display: flex; align-items: center; gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 999px;
backdrop-filter: blur(4px);
font-variant-numeric: tabular-nums; font-size: 0.75rem;
}
.lyrics-sync button {
width: 20px; height: 20px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff; cursor: pointer; font-size: 0.8rem; line-height: 1; padding: 0;
}
.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 */
+106 -4
View File
@@ -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'),
@@ -52,8 +55,16 @@
};
var currentKey = null;
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) {
@@ -362,6 +373,15 @@
if (track) updateMediaSession(track, src);
}
// A provider can return a URL that 404s (Last.fm in particular). Fall back
// to the placeholder once, instead of leaving a broken image on screen.
if (el.art) {
el.art.addEventListener('error', function () {
if (el.art.getAttribute('src') === CFG.fallbackArt) return;
setArt('', '', currentTrack);
});
}
function resolveArtwork(track, providerArt) {
var source = CFG.artworkSource || 'auto';
if (source === 'none') { setArt('', '', track); return; }
@@ -381,21 +401,94 @@
}
/* -------------------------------------------------------------- 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();
});
}
// Click a line to say "this is playing right now" — re-anchors the karaoke
// clock to that line, which is how we recover from loading mid-song (we
// have no reliable elapsed time on most providers) or from drift building
// up over a long track. The buffering-delay nudge above is left as-is.
if (el.lyricsLines) {
el.lyricsLines.addEventListener('click', function (e) {
var btn = e.target.closest('.lyrics-line');
if (!btn) return;
var line = syncedLines.filter(function (l) { return l.el === btn; })[0];
if (!line) return;
trackStartedAt = Date.now() + lyricsOffsetMs - line.time * 1000;
startKaraoke();
});
}
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 btn = document.createElement('button');
btn.type = 'button';
btn.className = 'lyrics-line';
btn.textContent = l.text;
el.lyricsLines.appendChild(btn);
return { time: l.time, el: btn };
});
var panelOpen = el.lyrics && !el.lyrics.hidden;
if (el.lyricsSync) el.lyricsSync.hidden = !panelOpen;
if (panelOpen) startKaraoke();
} else {
el.lyricsLines.textContent = res.lyrics;
}
el.lyricsToggle.hidden = false;
})
.catch(function () {});
}
@@ -406,6 +499,13 @@
var show = el.lyrics.hidden;
el.lyrics.hidden = !show;
el.lyricsToggle.setAttribute('aria-pressed', show ? 'true' : 'false');
if (show && syncedLines.length) {
startKaraoke();
if (el.lyricsSync) el.lyricsSync.hidden = false;
} else {
stopKaraoke();
if (el.lyricsSync) el.lyricsSync.hidden = true;
}
});
}
@@ -474,6 +574,8 @@
/* --------------------------------------------------------------- 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 || '';
+5 -1
View File
@@ -257,6 +257,10 @@ if (!defined('RADIO_CONFIG_LOADED')) {
'timezone' => radio_env('TIMEZONE', 'UTC'),
'debug' => radio_env_bool('DEBUG', false),
'show_errors' => radio_env_bool('SHOW_ERRORS', false),
// Sent with every outbound request this server makes (Genius,
// Last.fm, LRCLIB, custom now-playing feeds). Override per
// deployment so multiple stations identify themselves distinctly.
'user_agent' => radio_env('OUTBOUND_USER_AGENT', 'radio/2026.07 (+https://git.dou.bet/iamdoubz/radio)'),
],
];
@@ -317,7 +321,7 @@ function radio_client_config() {
'artworkSource' => cfg('artwork.source'),
'artworkLookup' => $artworkAvailable && !in_array(cfg('artwork.source'), ['none', 'provider'], true),
'fallbackArt' => cfg('artwork.fallback'),
'lyricsEnabled' => cfg('lyrics.enabled') && cfg('genius.token') !== '',
'lyricsEnabled' => cfg('lyrics.enabled'),
'showListeners' => cfg('features.show_listeners'),
'showHistory' => cfg('features.show_history'),
'listenersLink' => cfg('features.listeners_link'),
+9 -2
View File
@@ -6,7 +6,7 @@
require_once __DIR__ . '/config.php';
$fallbackArt = cfg('artwork.fallback');
$lyricsEnabled = cfg('lyrics.enabled') && cfg('genius.token') !== '';
$lyricsEnabled = cfg('lyrics.enabled');
$showHistory = cfg('features.show_history');
$showListeners = cfg('features.show_listeners');
$rich = cfg('player.style') !== 'simple';
@@ -28,7 +28,14 @@ $waveActive = radio_waveform_active();
alt="Album art" title="<?= e(cfg('site.name')) ?>"/>
<?php if ($lyricsEnabled): ?>
<button id="lyrics-toggle" class="lyrics-toggle" type="button" hidden aria-pressed="false">Lyrics</button>
<div id="lyrics" class="lyrics" hidden></div>
<div id="lyrics-sync" class="lyrics-sync" hidden>
<button type="button" data-adj="-0.5" aria-label="Lyrics earlier">&minus;</button>
<span id="lyrics-offset">0.0s</span>
<button type="button" data-adj="0.5" aria-label="Lyrics later">+</button>
</div>
<div id="lyrics" class="lyrics" hidden>
<div id="lyrics-lines" class="lyrics-lines"></div>
</div>
<?php endif; ?>
</div>