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).
const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
showSpinner(true);
setHomeOverPlayer(false);
els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory
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 cap = Math.max(30, Math.min(list.clientHeight * maxShare, maxPx));
const clamp = (px) => Math.max(minPx, Math.min(cap, px));
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 widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; };
// 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.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);
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 = [];
for (const el of rows) {
probe.textContent = el.textContent;
widthAt.text = el.textContent;
const w100 = widthAt(100);
let px = cap;
if (w100) {
@@ -2996,14 +3015,11 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const wEst = widthAt(est);
px = Math.floor(clamp(wEst ? est * (width / wEst) : est));
}
el.style.setProperty('--fit', px + 'px');
fits.push(px);
}
probe.remove();
if (uniform) {
const px = Math.min(...fits);
rows.forEach((el) => el.style.setProperty('--fit', px + 'px'));
}
if (probe) probe.remove();
const same = uniform ? Math.min(...fits) : 0;
rows.forEach((el, i) => el.style.setProperty('--fit', (uniform ? same : fits[i]) + 'px'));
}
// ============================================================================
@@ -7032,6 +7048,26 @@ function updateProgress() {
// ============================================================================
// 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() {
if (!current || !current.meta) return;
$('miniTitle').textContent = current.meta.title;
@@ -10146,9 +10182,14 @@ function wireUI() {
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) => {
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));
@@ -10476,6 +10517,7 @@ document.querySelectorAll('.chip').forEach((c) => {
// Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
$('miniBar').addEventListener('click', () => {
setHomeOverPlayer(false);
hideMiniBar();
// Scroll the player into view if needed. In portrait PWA, scroll only the
// .body pane (the designated scroll container) — scrollIntoView() also
@@ -10490,7 +10532,8 @@ document.querySelectorAll('.chip').forEach((c) => {
e.stopPropagation();
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
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.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; }