Add a lyrics-only service mode view with the sung line centred and auto-sized, and a free local faster-whisper script to transcribe saved songs

This commit is contained in:
Jonathan Sykes
2026-09-19 08:33:00 +08:00
parent b82f17cd46
commit 71fc51fb50
8 changed files with 457 additions and 2 deletions

View File

@@ -2283,7 +2283,7 @@ function updateNowPlayingActions() {
const Notes = (() => {
const LC = window.LyricsCore;
const panel = $('notesPanel');
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; } };
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {} };
if (!panel || !LC) return noop;
const CACHE_KEY = 'ytpNotesCache';
@@ -2343,8 +2343,10 @@ const Notes = (() => {
endSync();
if (!supported(meta)) {
st.videoId = null;
st.lyrics = null;
panel.classList.add('hidden');
renderSeekMarkers();
renderService(true);
return;
}
const id = meta.id;
@@ -2384,6 +2386,7 @@ const Notes = (() => {
// ---- rendering ----
function renderAll() {
renderLyrics();
renderService(true);
renderMarkers();
renderSeekMarkers();
renderOffset();
@@ -2496,6 +2499,7 @@ const Notes = (() => {
}
function tick(cur, force = false) {
tickService(cur, force);
if (!st.videoId || panel.classList.contains('hidden')) return;
const dur = (Player.master && Player.master.duration) || 0;
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
@@ -2910,6 +2914,153 @@ const Notes = (() => {
]);
}
// ---- service mode: lyrics only ----
// The sung line sits in the middle of the screen at the largest size that
// still fits on ONE row (measured per line with a canvas), highlighted; its
// neighbours shrink and fade with distance. Auto-scroll keeps it centred.
const sv = { key: '', idx: -2, timer: null };
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
// Measured with a hidden row styled exactly like .sl-line, so the size
// comes from the same font resolution the screen uses (a canvas can
// measure with a fallback face while the web font's 800 weight loads).
function fitSizes(list, texts) {
const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack
const maxPx = Math.max(30, Math.min(list.clientHeight * 0.17, 160));
const minPx = 18;
// A real .sl-line (same font rules) forced onto one unpadded row.
const probe = document.createElement('div');
probe.className = 'sl-line';
probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap;width:auto;padding:0;font-size:100px;transition:none';
list.appendChild(probe);
const clamp = (px) => Math.max(minPx, Math.min(maxPx, px));
const widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; };
// Two passes: the display font has an optical-size axis, so glyphs get
// relatively WIDER as the size drops — a single measurement at 100px
// overestimates what fits at phone sizes.
const out = texts.map((text) => {
probe.textContent = text;
const w100 = widthAt(100);
if (!w100) return maxPx;
const est = clamp((width / w100) * 100);
const wEst = widthAt(est);
return Math.floor(clamp(wEst ? est * (width / wEst) : est));
});
probe.remove();
return out;
}
// Re-measure every row in place (no rebuild, so the scroll position and
// the highlight are untouched).
function applyFits() {
const list = $('serviceLyricsList');
const rows = [...list.querySelectorAll('.sl-line')];
if (!rows.length || !list.clientWidth) return;
const fits = fitSizes(list, rows.map((el) => el.textContent));
rows.forEach((el, k) => el.style.setProperty('--fit', fits[k] + 'px'));
}
function renderServiceButtons() {
const on = !!data.settings.serviceLyrics;
const lb = $('serviceLyricsBtn');
lb.textContent = on ? '🎵 Lyrics on' : '🎵 Lyrics off';
lb.classList.toggle('on', on);
lb.setAttribute('aria-pressed', String(on));
const ab = $('serviceAutoscrollBtn');
ab.classList.toggle('hidden', !on);
ab.textContent = serviceAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
ab.classList.toggle('on', serviceAuto());
ab.setAttribute('aria-pressed', String(serviceAuto()));
}
function renderService(force = false) {
renderServiceButtons();
const on = serviceOn();
$('serviceMode').classList.toggle('lyrics-on', on);
$('serviceLyrics').classList.toggle('hidden', !on);
if (!on) { sv.key = ''; return; }
const list = $('serviceLyricsList');
const items = sungLines();
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
if (key === sv.key && !force) return;
sv.key = key;
sv.idx = -2;
// Half-height spacers let the first and last line reach the centre.
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
if (!items.length) {
const title = (current && current.meta && current.meta.title) || '';
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
applyFits();
tickService(now(), true);
// One more fit once the view has settled (fonts, first layout).
clearTimeout(sv.refit);
sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900);
}
function centreOn(list, el) {
const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' });
go();
// Font sizes animate, so re-centre once the line has grown.
clearTimeout(sv.timer);
sv.timer = setTimeout(go, 380);
}
function tickService(cur, force = false) {
if (!serviceOn()) return;
const list = $('serviceLyricsList');
const idx = LC.activeIndex(lines(), cur, offset());
if (idx === sv.idx && !force) return;
sv.idx = idx;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
if (serviceAuto() && rows.length) centreOn(list, pos >= 0 ? rows[pos] : rows[0]);
}
function serviceRefresh() { renderService(true); }
$('serviceLyricsBtn').addEventListener('click', () => {
data.settings.serviceLyrics = !data.settings.serviceLyrics;
// Lyrics fill the screen; the inline video panel would squeeze them.
if (data.settings.serviceLyrics && data.settings.serviceVideo) {
data.settings.serviceVideo = false;
updateServiceVideoUI();
}
persist();
renderService(true);
});
$('serviceAutoscrollBtn').addEventListener('click', () => {
data.settings.serviceLyricsAutoscroll = !serviceAuto();
persist();
renderServiceButtons();
if (serviceAuto()) tickService(now(), true);
});
$('serviceLyricsList').addEventListener('click', (e) => {
const row = e.target.closest('.sl-line');
const l = row && lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
});
window.addEventListener('resize', () => { if (serviceOn()) renderService(); });
// Sizes are measured with the web font; a measurement taken while it was
// still loading used the narrower fallback, so re-fit once fonts land.
if (document.fonts) {
document.fonts.ready.then(() => { if (serviceOn()) applyFits(); });
document.fonts.addEventListener('loadingdone', () => { if (serviceOn()) applyFits(); });
}
// ---- tabs + events ----
function showTab(tab) {
st.tab = tab;
@@ -2984,7 +3135,7 @@ const Notes = (() => {
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
});
return { load, tick, addBookmark, handleSyncKey };
return { load, tick, addBookmark, handleSyncKey, serviceRefresh };
})();
// ============================================================================
@@ -4046,8 +4197,11 @@ function toggleServiceVideo() {
return;
}
data.settings.serviceVideo = !data.settings.serviceVideo;
// Video and lyrics-only share the screen; turning one on turns the other off.
if (data.settings.serviceVideo && data.settings.serviceLyrics) data.settings.serviceLyrics = false;
persist();
updateServiceVideoUI();
Notes.serviceRefresh();
}
function enterServiceMode(pl) {
@@ -4072,6 +4226,7 @@ function enterServiceMode(pl) {
document.body.classList.add('service-open');
updateServiceVideoUI();
updateServiceMode();
Notes.serviceRefresh();
acquireWakeLock();
}