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:
159
frontend/app.js
159
frontend/app.js
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user