diff --git a/.env.example b/.env.example index c2a9cc8..a75f854 100644 --- a/.env.example +++ b/.env.example @@ -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)" 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/_bootstrap.php b/api/_bootstrap.php index df2a24f..e142d20 100644 --- a/api/_bootstrap.php +++ b/api/_bootstrap.php @@ -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], diff --git a/api/lyrics.php b/api/lyrics.php index 3ba2ac1..d78b731 100644 --- a/api/lyrics.php +++ b/api/lyrics.php @@ -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]); 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..e9041b4 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -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 */ diff --git a/assets/js/player.js b/assets/js/player.js index 6ead57c..45f9f9e 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'), @@ -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 || ''; diff --git a/config.php b/config.php index 97397d4..274d064 100644 --- a/config.php +++ b/config.php @@ -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'), diff --git a/index.php b/index.php index 8ee4dd4..edbe023 100644 --- a/index.php +++ b/index.php @@ -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=""/> - + +