From 9dafff4311bb8a866ad8f84fd1adad5e4db0db37 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 11:44:20 -0500 Subject: [PATCH 1/7] If artwork retrival fails, gracefully fallback to placeholder image --- assets/js/player.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/assets/js/player.js b/assets/js/player.js index 6ead57c..2f98688 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -52,6 +52,7 @@ }; var currentKey = null; + var currentTrack = null; var siteName = CFG.siteName || 'Radio'; /* ---------------------------------------------------------------- utils */ @@ -362,6 +363,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; } @@ -474,6 +484,7 @@ /* --------------------------------------------------------------- poll */ function onNewTrack(t, providerArt) { + currentTrack = t; 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 || ''; -- 2.34.1 From 28d2ede47f14f33f2028b9f46f7037c4c6053193 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 11:52:23 -0500 Subject: [PATCH 2/7] Make LRCLIB as primary lyrics source --- .env.example | 8 ++++++-- api/lyrics.php | 45 +++++++++++++++++++++++++++++++++++++-------- config.php | 2 +- index.php | 2 +- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index c2a9cc8..49d95b5 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). diff --git a/api/lyrics.php b/api/lyrics.php index 3ba2ac1..75f5984 100644 --- a/api/lyrics.php +++ b/api/lyrics.php @@ -2,23 +2,25 @@ /** * 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": "…" } * - * 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. * - * 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' => '']; -if (!cfg('lyrics.enabled') || cfg('genius.token') === '') { +if (!cfg('lyrics.enabled')) { json_out($empty); } @@ -27,8 +29,35 @@ if ($artist === '' && $title === '') { json_out($empty); } -$token = cfg('genius.token'); $timeout = 5; + +/* -- 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; + + $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'])); + } + return $lyrics === '' ? null : $lyrics; +} + +$lrcLyrics = lrclib_lyrics($artist, $title, $timeout); +if ($lrcLyrics !== null) { + json_out(['found' => true, 'lyrics' => $lrcLyrics, 'url' => '']); +} + +/* -- Genius (fallback) ------------------------------------------------------- */ +if (cfg('genius.token') === '') json_out($empty); + +$token = cfg('genius.token'); $headers = ['Authorization: Bearer ' . $token]; /** diff --git a/config.php b/config.php index 97397d4..a665fc7 100644 --- a/config.php +++ b/config.php @@ -317,7 +317,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..04e3171 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'; -- 2.34.1 From d2e7b7e97da28e696f249b0f39322bc1a2b2dd78 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 12:06:04 -0500 Subject: [PATCH 3/7] 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=""/> - + -- 2.34.1 From 9993e9927f106af2c05209cf40283e0461293d78 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 12:12:42 -0500 Subject: [PATCH 4/7] Be able to click on lyric line to start karaoke from that line --- assets/css/radio.css | 8 +++++++- assets/js/player.js | 26 +++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/assets/css/radio.css b/assets/css/radio.css index 15bdcfe..5c631f4 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -187,7 +187,13 @@ 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 { + 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 { diff --git a/assets/js/player.js b/assets/js/player.js index 4e66cec..fe2031e 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -442,6 +442,21 @@ }); } + // 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; @@ -460,11 +475,12 @@ 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 }; + 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 }; }); if (el.lyricsSync) el.lyricsSync.hidden = false; if (el.lyrics && !el.lyrics.hidden) startKaraoke(); -- 2.34.1 From 6180d2f638a3df009aae09a7e51f70b2ac39f1ca Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 12:27:42 -0500 Subject: [PATCH 5/7] ENV to change UA sent to outbound calls (Genius, Last.fm, LRCLIB) --- .env.example | 5 +++++ api/_bootstrap.php | 4 ++-- config.php | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 49d95b5..a75f854 100644 --- a/.env.example +++ b/.env.example @@ -217,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/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/config.php b/config.php index a665fc7..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)'), ], ]; -- 2.34.1 From 6be2f16e4a664481d92b017ff7b89801fa147ecd Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 13:14:50 -0500 Subject: [PATCH 6/7] +/- lyric sync speed pill --- assets/css/radio.css | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/assets/css/radio.css b/assets/css/radio.css index 5c631f4..e9041b4 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -197,16 +197,19 @@ audio[controls] { width: 100%; outline: 0; } .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; + 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: 26px; height: 26px; border-radius: 50%; + 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; + 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; } -- 2.34.1 From a31df1ab1229e4c6faa737007ae2974b46e99398 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 27 Jul 2026 15:44:40 -0500 Subject: [PATCH 7/7] Fixed a bug where the lyrics sync +/- control disappeared when scrolling --- assets/js/player.js | 13 ++++++++++--- index.php | 10 +++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/assets/js/player.js b/assets/js/player.js index fe2031e..45f9f9e 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -482,8 +482,9 @@ el.lyricsLines.appendChild(btn); return { time: l.time, el: btn }; }); - if (el.lyricsSync) el.lyricsSync.hidden = false; - if (el.lyrics && !el.lyrics.hidden) startKaraoke(); + var panelOpen = el.lyrics && !el.lyrics.hidden; + if (el.lyricsSync) el.lyricsSync.hidden = !panelOpen; + if (panelOpen) startKaraoke(); } else { el.lyricsLines.textContent = res.lyrics; } @@ -498,7 +499,13 @@ var show = el.lyrics.hidden; el.lyrics.hidden = !show; el.lyricsToggle.setAttribute('aria-pressed', show ? 'true' : 'false'); - if (show && syncedLines.length) startKaraoke(); else stopKaraoke(); + if (show && syncedLines.length) { + startKaraoke(); + if (el.lyricsSync) el.lyricsSync.hidden = false; + } else { + stopKaraoke(); + if (el.lyricsSync) el.lyricsSync.hidden = true; + } }); } diff --git a/index.php b/index.php index 94e3756..edbe023 100644 --- a/index.php +++ b/index.php @@ -28,12 +28,12 @@ $waveActive = radio_waveform_active(); alt="Album art" title=""/> + -- 2.34.1