[FEAT] Karaoke style lyrics #2
+11
-2
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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">−</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user