Fast lyric fitting for long videos; Search tab returns to the home screen while playing

- fitLyricLines measures text on a canvas with the line's own font and writes
  every --fit after measuring (it used an in-list DOM probe and wrote inside
  the loop, re-laying-out the whole list per line). 1200 lines: 600 ms -> 28 ms;
  sizes match the old method within 1 px. Service mode on long sermons with
  hundreds of transcribed lines opened slowly enough on phones to look broken.
- Bottom-nav Search while something plays shows the home screen (playlist
  board / hero) over the player pane; the media keeps playing unseen and the
  mini bar brings the player back (its close button too, so there is always a
  way back). Starting another video restores the player.
This commit is contained in:
Jonathan Sykes
2026-10-03 01:10:15 +08:00
parent 171a0bd127
commit caf0199c11
2 changed files with 65 additions and 15 deletions

View File

@@ -1974,6 +1974,7 @@ const Player = {
// instead of hijacking playback (longest window: save-before-playing). // instead of hijacking playback (longest window: save-before-playing).
const seq = (this._loadSeq = (this._loadSeq || 0) + 1); const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
showSpinner(true); showSpinner(true);
setHomeOverPlayer(false);
els.placeholder.classList.add('hidden'); els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory // Revoke any previous OPFS blob URL to free memory
if (_currentBlobUrl) { if (_currentBlobUrl) {
@@ -2981,14 +2982,32 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack
const cap = Math.max(30, Math.min(list.clientHeight * maxShare, maxPx)); const cap = Math.max(30, Math.min(list.clientHeight * maxShare, maxPx));
const clamp = (px) => Math.max(minPx, Math.min(cap, px)); const clamp = (px) => Math.max(minPx, Math.min(cap, px));
const probe = document.createElement('div'); // Text widths come from a canvas (no page layout at all) using the line's
// own font; the in-list DOM probe stays as the fallback.
const cs = getComputedStyle(rows[0]);
const ctx2d = (fitLyricLines._c || (fitLyricLines._c = document.createElement('canvas'))).getContext('2d');
let probe = null;
let widthAt;
if (ctx2d && cs.fontFamily) {
const lsEm = (parseFloat(cs.letterSpacing) || 0) / (parseFloat(cs.fontSize) || 1); // letter-spacing in em
widthAt = (px) => {
ctx2d.font = `${cs.fontStyle} ${cs.fontWeight} ${px}px ${cs.fontFamily}`;
const t = widthAt.text;
return ctx2d.measureText(t).width + lsEm * px * Math.max(0, t.length - 1);
};
} else {
probe = document.createElement('div');
probe.className = 'sl-line'; 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'; 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); list.appendChild(probe);
const widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; }; widthAt = (px) => { probe.style.fontSize = px + 'px'; probe.textContent = widthAt.text; return probe.getBoundingClientRect().width; };
}
// Measure every line first and write the sizes afterwards: writing --fit
// inside the loop dirtied the list's layout, so each following measurement
// re-laid-out every line (O(n²) — seconds on a phone for a long sermon).
const fits = []; const fits = [];
for (const el of rows) { for (const el of rows) {
probe.textContent = el.textContent; widthAt.text = el.textContent;
const w100 = widthAt(100); const w100 = widthAt(100);
let px = cap; let px = cap;
if (w100) { if (w100) {
@@ -2996,14 +3015,11 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const wEst = widthAt(est); const wEst = widthAt(est);
px = Math.floor(clamp(wEst ? est * (width / wEst) : est)); px = Math.floor(clamp(wEst ? est * (width / wEst) : est));
} }
el.style.setProperty('--fit', px + 'px');
fits.push(px); fits.push(px);
} }
probe.remove(); if (probe) probe.remove();
if (uniform) { const same = uniform ? Math.min(...fits) : 0;
const px = Math.min(...fits); rows.forEach((el, i) => el.style.setProperty('--fit', (uniform ? same : fits[i]) + 'px'));
rows.forEach((el) => el.style.setProperty('--fit', px + 'px'));
}
} }
// ============================================================================ // ============================================================================
@@ -7032,6 +7048,26 @@ function updateProgress() {
// ============================================================================ // ============================================================================
// Mini now-playing bar // Mini now-playing bar
// ============================================================================ // ============================================================================
// Home while playing: the Search tab brings back the home screen (playlist
// board / hero) over the player pane, using the same look as "nothing playing"
// (.empty). The media keeps playing — it is only made invisible, never removed
// or paused — and the mini bar stays up to get back to it.
function setHomeOverPlayer(on) {
const pane = els.playerPane;
if (!pane) return;
on = !!on && !!(current && current.meta);
if (pane.classList.contains('home-over') === on) return;
pane.classList.toggle('home-over', on);
pane.classList.toggle('empty', on);
if (els.placeholder) els.placeholder.classList.toggle('hidden', !on);
if (on) {
renderHomePlaylists();
showMiniBar();
const body = document.querySelector('.body');
if (body) body.scrollTo({ top: 0 });
}
}
function showMiniBar() { function showMiniBar() {
if (!current || !current.meta) return; if (!current || !current.meta) return;
$('miniTitle').textContent = current.meta.title; $('miniTitle').textContent = current.meta.title;
@@ -10146,9 +10182,14 @@ function wireUI() {
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); }); b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
}); });
// Bottom-nav (portrait PWA) — same view switching as sidebar nav // Bottom-nav (portrait PWA) — same view switching as sidebar nav. Search
// while something plays also brings back the home screen (see setHomeOverPlayer).
document.querySelectorAll('.bottom-nav-btn').forEach((b) => { document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); }); b.addEventListener('click', () => {
view = { type: b.dataset.view };
if (b.dataset.view === 'search') setHomeOverPlayer(true);
render();
});
}); });
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null)); els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
@@ -10476,6 +10517,7 @@ document.querySelectorAll('.chip').forEach((c) => {
// Mini now-playing bar // Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); }); $('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
$('miniBar').addEventListener('click', () => { $('miniBar').addEventListener('click', () => {
setHomeOverPlayer(false);
hideMiniBar(); hideMiniBar();
// Scroll the player into view if needed. In portrait PWA, scroll only the // Scroll the player into view if needed. In portrait PWA, scroll only the
// .body pane (the designated scroll container) — scrollIntoView() also // .body pane (the designated scroll container) — scrollIntoView() also
@@ -10490,7 +10532,8 @@ document.querySelectorAll('.chip').forEach((c) => {
e.stopPropagation(); e.stopPropagation();
enterServiceMode(); enterServiceMode();
}); });
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); }); // On the home screen the mini bar is the way back, so ✕ returns to the player.
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); setHomeOverPlayer(false); hideMiniBar(); });
// Drag-to-reorder: prevent default on cards container // Drag-to-reorder: prevent default on cards container
els.cards.addEventListener('dragover', (e) => { els.cards.addEventListener('dragover', (e) => {

View File

@@ -4280,3 +4280,10 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
} }
.dl-act:hover { border-color: var(--accent); } .dl-act:hover { border-color: var(--accent); }
.dl-act.danger:hover { border-color: #ff5a4f; color: #ff5a4f; } .dl-act.danger:hover { border-color: #ff5a4f; color: #ff5a4f; }
/* Home screen over a playing video (Search tab): media stays alive, unseen */
.player-pane.home-over video,
.player-pane.home-over #stageLyrics,
.player-pane.home-over #artFallback { visibility: hidden; }
.player-pane.home-over .related-panel { display: none; }
.player-pane.home-over > :not(.player-stage) { display: none !important; }