Fix mobile/UX bug cluster: video minimize, audio interrupt, scroll reset, redundant box
Four fixes in public/index.html, all reported against recaps.cc on mobile: - Video minimize no longer shows a black frame on expand. toggleVideoMinimize() used to call render(), rebuilding the YouTube iframe inside the display:none minimized container, which wedged the IFrame API. Minimize now toggles the .results-left.minimized class in place; a !videoMinimized guard on render()'s needsMount plus a new ensureYtMounted() (called from the expand paths) keep the player from ever being created in a hidden container. - Background processing no longer interrupts podcast audio or resets the transcript scroll. The ~60s relay-credit poll calls render(), which rebuilt the <audio> element and chunks-scroll. render() now preserves the live <audio> node across the innerHTML swap (replaceWith when the src matches) and restores chunks-scroll scrollTop; initPodcastPlayer() is idempotent so the preserved node doesn't get duplicate listeners. - Removed the redundant centered "Processing..." box; the staged pizza-tracker breadcrumb already covers that window. - Added -webkit-overflow-scrolling/overscroll-behavior to .chunks-scroll for the mobile can't-scroll-to-top report (best-effort, needs on-device verification). Ships as 0.2.157. Reviewer pass clean; inline JS syntax checked with node --check.
This commit is contained in:
+90
-9
@@ -66,6 +66,10 @@
|
||||
.results-right .stats-bar { flex-shrink: 0; }
|
||||
.chunks-scroll {
|
||||
flex: 1; overflow-y: auto; padding-right: 4px;
|
||||
/* iOS Safari: keep momentum scrolling and stop scroll-chaining to
|
||||
the locked page body, which could leave the transcript unable to
|
||||
pull back to the top on mobile. */
|
||||
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||
}
|
||||
.chunks-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.chunks-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -1715,10 +1719,25 @@
|
||||
// Auto-expand video if minimized when user clicks a transcript segment
|
||||
if (state.videoMinimized) {
|
||||
state.videoMinimized = false;
|
||||
render();
|
||||
// Wait for player to reinit, then seek
|
||||
setTimeout(() => seekTo(seconds), 200);
|
||||
return;
|
||||
const left = document.querySelector(".results-left");
|
||||
if (state.currentType !== "podcast" && left) {
|
||||
// YouTube: un-minimize via CSS. The player is usually still
|
||||
// mounted, so fall through and seek immediately. If a background
|
||||
// render() dropped it while hidden, mount it now and seek once
|
||||
// it's ready.
|
||||
left.classList.remove("minimized");
|
||||
const ytDiv = document.getElementById("yt-player");
|
||||
if (ytDiv && !ytDiv.querySelector("iframe")) {
|
||||
ensureYtMounted();
|
||||
setTimeout(() => seekTo(seconds), 300);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Podcast: render() rebuilds the <audio> element; seek after.
|
||||
render();
|
||||
setTimeout(() => seekTo(seconds), 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sameSegment = (lastSeekTarget === seconds);
|
||||
@@ -1761,9 +1780,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the YT player into #yt-player when the div is present, visible,
|
||||
// and missing its iframe. Centralizes the "never create the player in a
|
||||
// hidden container" rule shared by render() and the expand paths.
|
||||
function ensureYtMounted() {
|
||||
if (!state.videoId || !ytReady || state.videoMinimized) return;
|
||||
const ytDiv = document.getElementById("yt-player");
|
||||
if (ytDiv && !ytDiv.querySelector("iframe")) initPlayer(state.videoId);
|
||||
}
|
||||
|
||||
function toggleVideoMinimize() {
|
||||
state.videoMinimized = !state.videoMinimized;
|
||||
render();
|
||||
// YouTube split view: minimize is purely visual — toggle the CSS
|
||||
// class in place so the iframe (and any in-progress playback)
|
||||
// survives. A full render() rebuilt the embed and re-created the
|
||||
// YT player inside a display:none container, which wedged the
|
||||
// IFrame API → a black frame that only a page reload could clear.
|
||||
// The podcast view has no such class (it adds/removes the <audio>
|
||||
// element and resizes the list), so it still goes through render().
|
||||
const left = document.querySelector(".results-left");
|
||||
if (state.currentType !== "podcast" && left) {
|
||||
left.classList.toggle("minimized", state.videoMinimized);
|
||||
// If a background render() dropped the iframe while we were hidden,
|
||||
// mount it now that we're visible so expand never shows black.
|
||||
if (!state.videoMinimized) ensureYtMounted();
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
@@ -1886,7 +1929,11 @@
|
||||
|
||||
function initPodcastPlayer() {
|
||||
const audio = document.getElementById("podcast-audio");
|
||||
if (!audio) return;
|
||||
// Idempotent: a node preserved live across a render() (see render's
|
||||
// audio re-attach) keeps its listeners, so re-running here would
|
||||
// double them up — startPodcastSync would fire twice per play.
|
||||
if (!audio || audio.dataset.inited) return;
|
||||
audio.dataset.inited = "1";
|
||||
audio.addEventListener("play", startPodcastSync);
|
||||
audio.addEventListener("pause", stopPodcastSync);
|
||||
audio.addEventListener("ended", stopPodcastSync);
|
||||
@@ -6388,6 +6435,17 @@
|
||||
// Preserve library sidebar scroll position across full re-renders
|
||||
const __prevHistoryListEl = document.querySelector(".history-list");
|
||||
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
|
||||
// Preserve the podcast <audio> as a LIVE node across the re-render
|
||||
// so a background re-render (e.g. the ~60s relay-credit poll firing
|
||||
// while a job debits credits) doesn't tear the player out of the
|
||||
// DOM and stop playback. We hold the actual element here and
|
||||
// re-attach it into the rebuilt tree below if the new markup wants
|
||||
// the same src.
|
||||
const __liveAudio = document.getElementById("podcast-audio");
|
||||
// Preserve the transcript scroll position too — the rebuilt
|
||||
// .chunks-scroll otherwise snaps back to the top on every render.
|
||||
const __prevChunksEl = document.querySelector(".chunks-scroll");
|
||||
const __prevChunksScroll = __prevChunksEl ? __prevChunksEl.scrollTop : 0;
|
||||
const free = !isLicensed();
|
||||
// Submit is disabled when there's no URL, or when the selected
|
||||
// providers don't have any usable configuration. Relay counts as
|
||||
@@ -6568,7 +6626,10 @@
|
||||
${state.error ? `<div class="error-box">${escHtml(state.error)}</div>` : ""}
|
||||
|
||||
${state.loading && (state.videoId || state.currentType === "podcast") && !state.streaming ? renderLoadingSplit() : ""}
|
||||
${state.loading && !state.videoId && state.currentType !== "podcast" ? renderLoading() : ""}
|
||||
<!-- The old centered "Processing…" spinner box (renderLoading) was
|
||||
removed here — the top pizza-tracker breadcrumb already shows
|
||||
the Downloading→Transcribing→Analyzing stage during the
|
||||
URL-resolution window, so the box was redundant. -->
|
||||
|
||||
${(state.chunks.length > 0 && !state.loading) || state.streaming ? renderResults() : ""}
|
||||
|
||||
@@ -6618,12 +6679,26 @@
|
||||
// Cancel mid-stream, where the render path swapped from
|
||||
// renderLoadingSplit (mounted player) to renderResults (fresh
|
||||
// empty div) and the destroy/recreate raced.
|
||||
// Never mount into a minimized (display:none) container — creating
|
||||
// the YT player there wedges the IFrame API and shows a black frame
|
||||
// on expand. The expand handlers call ensureYtMounted() once visible.
|
||||
const ytDiv = document.getElementById("yt-player");
|
||||
const needsMount = state.videoId && ytReady && ytDiv && !ytDiv.querySelector("iframe");
|
||||
const needsMount = state.videoId && ytReady && ytDiv && !ytDiv.querySelector("iframe") && !state.videoMinimized;
|
||||
if (needsMount) {
|
||||
setTimeout(() => initPlayer(state.videoId), 50);
|
||||
}
|
||||
// Init podcast audio player if present
|
||||
// Re-attach the preserved live <audio> node (kept playing across
|
||||
// the DOM swap) when the rebuilt markup has a matching slot, so
|
||||
// playback is seamless. Detaching + synchronously re-attaching in
|
||||
// the same tick sidesteps the spec's "pause on disconnect" task.
|
||||
// Only when the src matches — a real navigation to a different
|
||||
// episode should get the fresh element instead.
|
||||
const __audioSlot = document.getElementById("podcast-audio");
|
||||
if (__liveAudio && __audioSlot && __liveAudio.src === __audioSlot.src) {
|
||||
__audioSlot.replaceWith(__liveAudio);
|
||||
}
|
||||
// Init podcast audio player if present (idempotent — skips the
|
||||
// preserved node, which already carries its listeners).
|
||||
if (document.getElementById("podcast-audio")) {
|
||||
setTimeout(initPodcastPlayer, 50);
|
||||
}
|
||||
@@ -6645,6 +6720,12 @@
|
||||
const newList = document.querySelector(".history-list");
|
||||
if (newList) newList.scrollTop = __prevHistoryScroll;
|
||||
}
|
||||
// Restore transcript scroll so a background re-render doesn't bounce
|
||||
// the reader back to the top of the sections list.
|
||||
if (__prevChunksScroll > 0) {
|
||||
const __newChunks = document.querySelector(".chunks-scroll");
|
||||
if (__newChunks) __newChunks.scrollTop = __prevChunksScroll;
|
||||
}
|
||||
}
|
||||
|
||||
function renderServerStatus() {
|
||||
|
||||
Reference in New Issue
Block a user