diff --git a/api/_bootstrap.php b/api/_bootstrap.php index f77e83f..e197b36 100644 --- a/api/_bootstrap.php +++ b/api/_bootstrap.php @@ -165,11 +165,33 @@ function dumb_search($artist, $title, $timeout) { return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); } +/** + * Extract Genius annotation ("referent") links out of a raw lyrics HTML + * fragment. Returns an ordered list of ['text' => plain annotated lyric + * text, 'path' => dumb path to fetch the annotation JSON from]. Matched + * structurally — href = "/{id}/{song-slug}/{verse-slug}" — rather than by + * CSS class, since Genius's class names are build-hashed (styled-components) + * and change on their end without notice. + */ +function dumb_extract_annotations($fragment) { + $out = []; + if (!preg_match_all('#]*>(.*?)#is', $fragment, $matches, PREG_SET_ORDER)) { + return $out; + } + foreach ($matches as $m) { + $text = trim(container_to_text($m[2])); + if ($text === '') continue; + $out[] = ['text' => $text, 'path' => $m[1] . '/annotations']; + } + return $out; +} + /** * Fetch + parse a dumb song page ($path from dumb_search()). Returns - * ['lyrics' => plain text, 'album' => name, 'art' => absolute album art url, - * 'song_art' => absolute song thumbnail url] (any field may be ''), or null - * if the page couldn't be fetched. + * ['lyrics' => plain text, 'annotations' => [{text, path}, …] (see + * dumb_extract_annotations), 'album' => name, 'art' => absolute album art + * url, 'song_art' => absolute song thumbnail url] (any field may be ''/[]), + * or null if the page couldn't be fetched. */ function dumb_song($path, $timeout) { $base = cfg('dumb.url'); @@ -177,14 +199,18 @@ function dumb_song($path, $timeout) { $html = http_get($base . $path, $timeout); if ($html === null) return null; - $out = ['lyrics' => '', 'album' => '', 'art' => '', 'song_art' => '']; + $out = ['lyrics' => '', 'annotations' => [], 'album' => '', 'art' => '', 'song_art' => '']; //
wraps the raw Genius lyrics HTML that dumb already // extracted server-side (no data-lyrics-container markers left in it). $idPos = strpos($html, 'id="lyrics"'); if ($idPos !== false) { $lt = strrpos(substr($html, 0, $idPos), ' tags can appear: the song thumbnail diff --git a/api/artwork.php b/api/artwork.php index 4131ad9..e0ab207 100644 --- a/api/artwork.php +++ b/api/artwork.php @@ -4,7 +4,9 @@ * --------------- * Optional album-art lookup. The browser calls this only when the stream * provider itself did not supply artwork and ARTWORK_SOURCE allows a lookup. - * Tries Genius first, then Last.fm (whichever have keys configured). Returns: + * Tries dumb (if USE_DUMB is on), then the official Genius API (if a token + * is configured), then Last.fm — each tier only runs if the previous one + * found nothing, so dumb and Genius can both be configured at once. Returns: * * { "art": "https://…" | "", "album": "…", "url": "…" } * @@ -73,7 +75,11 @@ function dumb_artwork($artist, $title) { /* -- Genius --------------------------------------------------------------- */ function genius_artwork($artist, $title) { - if (cfg('dumb.enabled')) return dumb_artwork($artist, $title); + if (cfg('dumb.enabled')) { + $res = dumb_artwork($artist, $title); + if ($res !== null) return $res; + // Nothing on dumb — fall through to the official API below. + } $token = cfg('genius.token'); if ($token === '') return null; diff --git a/api/lyrics.php b/api/lyrics.php index c50a223..e488597 100644 --- a/api/lyrics.php +++ b/api/lyrics.php @@ -2,17 +2,19 @@ /** * api/lyrics.php * -------------- - * Lyrics lookup. Tries LRCLIB first (free, no key, has synced lyrics), then - * falls back to dumb (if USE_DUMB is on) or scraping the public Genius song - * page (if a Genius token is configured) when LRCLIB has nothing. Returns + * Lyrics lookup, tried in order: LRCLIB (free, no key, has synced lyrics), + * then dumb (if USE_DUMB is on), then scraping the public Genius song page + * (if a Genius token is configured). Each tier only runs if the previous one + * found nothing, so dumb and Genius can both be configured at once. Returns * JSON: * * { - * "found": true|false, - * "lyrics": "plain text\nwith newlines", - * "synced": [{ "time": 12.34, "text": "…" }, …], // [] when unavailable - * "url": "…", - * "source": "lrclib" | "dumb" | "genius" + * "found": true|false, + * "lyrics": "plain text\nwith newlines", + * "synced": [{ "time": 12.34, "text": "…" }, …], // [] when unavailable + * "url": "…", + * "source": "lrclib" | "dumb" | "genius", + * "annotations": [{ "text": "…", "path": "…" }, …] // [] unless source is "dumb" * } * * Lyrics are returned as plain text (tags/timestamps stripped) so the @@ -20,7 +22,10 @@ * 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. `url` is '' for LRCLIB (the front-end only - * shows a "source" link for dumb/genius). + * shows a "source" link for dumb/genius). `annotations` lists Genius + * annotation links found within the lyrics (dumb only — the front-end + * splices a 📝 marker into each matching span and fetches the annotation + * body from api/annotation.php on click). * * Note: the Genius fallback scrapes HTML, which can change at any time. If * Genius restructures their page the extraction may need updating. @@ -28,7 +33,7 @@ require_once __DIR__ . '/_bootstrap.php'; -$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => '']; +$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => '', 'annotations' => []]; if (!cfg('lyrics.enabled')) { json_out($empty); @@ -81,19 +86,23 @@ function lrclib_lyrics($artist, $title, $timeout) { $lrc = lrclib_lyrics($artist, $title, $timeout); if ($lrc !== null) { - json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib']); + json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib', 'annotations' => []]); } -/* -- dumb (Genius-API-free fallback) ----------------------------------------- */ +/* -- dumb (tried before Genius; falls through to it if nothing found) ------- */ if (cfg('dumb.enabled')) { $path = dumb_search($artist, $title, $timeout); - if ($path === '') json_out($empty); - $song = dumb_song($path, $timeout); - $url = cfg('dumb.url') . $path; - if ($song === null || $song['lyrics'] === '') { - json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $url, 'source' => 'dumb']); + if ($path !== '') { + $song = dumb_song($path, $timeout); + if ($song !== null && $song['lyrics'] !== '') { + json_out([ + 'found' => true, 'lyrics' => $song['lyrics'], 'synced' => [], + 'url' => cfg('dumb.url') . $path, 'source' => 'dumb', + 'annotations' => $song['annotations'], + ]); + } } - json_out(['found' => true, 'lyrics' => $song['lyrics'], 'synced' => [], 'url' => $url, 'source' => 'dumb']); + // Nothing on dumb — fall through to the Genius API below. } /* -- Genius (fallback) ------------------------------------------------------- */ @@ -133,7 +142,7 @@ if ($pageUrl === '') json_out($empty); /* -- Scrape the lyric containers ------------------------------------------- */ $page = http_get($pageUrl, $timeout); if ($page === null) { - json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius']); + json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]); } $marker = 'data-lyrics-container="true"'; @@ -155,7 +164,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' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius']); + json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]); } -json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius']); +json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]); diff --git a/assets/css/radio.css b/assets/css/radio.css index c4e2f6a..b969d60 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -234,6 +234,22 @@ audio[controls] { width: 100%; outline: 0; } .lyrics-source:hover, .lyrics-source:focus-visible { opacity: 1; text-decoration: underline; } .lyrics-source[hidden] { display: none; } +/* dumb-only: Genius annotations found in the lyrics (see renderAnnotatedLyrics). */ +.lyrics-annotated { text-decoration: underline dotted; text-underline-offset: 3px; } +.annotation-toggle { + background: none; border: none; cursor: pointer; padding: 0 2px; + font-size: 0.85em; line-height: 1; vertical-align: baseline; + opacity: .75; +} +.annotation-toggle:hover, .annotation-toggle:focus-visible { opacity: 1; outline: 0; } +.annotation-body { + display: block; white-space: normal; /* .lyrics has white-space: pre-line; this is prose */ + margin: 4px 0 8px; padding: 8px 10px; + background: rgba(255, 255, 255, 0.08); border-radius: 8px; + font-size: 0.85rem; opacity: .85; +} +.annotation-body[hidden] { display: none; } + .lyrics-sync { position: absolute; top: 48px; right: 10px; z-index: 2; display: flex; align-items: center; gap: 6px; diff --git a/assets/js/player.js b/assets/js/player.js index 09eefeb..26068a8 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -700,17 +700,76 @@ // 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. + // Same delegated listener also handles the 📝 annotation toggles dumb + // lyrics can have (see renderAnnotatedLyrics below). 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(); + var lineBtn = e.target.closest('.lyrics-line'); + if (lineBtn) { + var line = syncedLines.filter(function (l) { return l.el === lineBtn; })[0]; + if (!line) return; + trackStartedAt = Date.now() + lyricsOffsetMs - line.time * 1000; + startKaraoke(); + return; + } + var noteBtn = e.target.closest('.annotation-toggle'); + if (noteBtn) toggleAnnotation(noteBtn); }); } + // Splice a 📝 marker after each annotated span instead of one flat text + // blob, so a listener can open the annotation right under that lyric. + // `text` is the full plain-text lyrics; annotations are matched against it + // in order (moving the search cursor forward each time) rather than by + // index, since dumb only gives us the annotated substrings + their paths. + function renderAnnotatedLyrics(text, annotations) { + var cursor = 0; + annotations.forEach(function (a) { + var idx = text.indexOf(a.text, cursor); + if (idx === -1) return; // shouldn't happen; leave it out of the markup rather than misplace it + if (idx > cursor) el.lyricsLines.appendChild(document.createTextNode(text.slice(cursor, idx))); + var mark = document.createElement('span'); + mark.className = 'lyrics-annotated'; + mark.textContent = a.text; + el.lyricsLines.appendChild(mark); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'annotation-toggle'; + btn.textContent = '📝'; + btn.setAttribute('aria-label', 'Show annotation'); + btn.setAttribute('aria-expanded', 'false'); + btn.dataset.path = a.path; + el.lyricsLines.appendChild(btn); + cursor = idx + a.text.length; + }); + if (cursor < text.length) el.lyricsLines.appendChild(document.createTextNode(text.slice(cursor))); + } + + function toggleAnnotation(btn) { + var body = btn.nextElementSibling; + if (body && body.classList.contains('annotation-body')) { + var show = body.hidden; + body.hidden = !show; + btn.setAttribute('aria-expanded', show ? 'true' : 'false'); + return; + } + body = document.createElement('div'); + body.className = 'annotation-body'; + body.textContent = 'Loading…'; + btn.insertAdjacentElement('afterend', body); + btn.setAttribute('aria-expanded', 'true'); + + var params = new URLSearchParams(); + params.set('path', btn.dataset.path); + api('api/annotation.php', { method: 'POST', body: params }) + .then(function (res) { + body.textContent = (res.found && res.text) ? res.text : 'No annotation found.'; + }) + .catch(function () { + body.textContent = 'Could not load annotation.'; + }); + } + function loadLyrics(track) { if (!CFG.lyricsEnabled || !el.lyricsToggle) return; el.lyricsToggle.hidden = true; @@ -742,6 +801,8 @@ var panelOpen = el.lyrics && !el.lyrics.hidden; if (el.lyricsSync) el.lyricsSync.hidden = !panelOpen; if (panelOpen) startKaraoke(); + } else if (res.annotations && res.annotations.length) { + renderAnnotatedLyrics(res.lyrics, res.annotations); } else { el.lyricsLines.textContent = res.lyrics; }