Files
jacuzzi/frontend/js/version.js
Simon b4bc90372d Load a video before showing the player, not after
Opening a video put an empty black player up at once and spun at the
viewer until the first frame arrived -- which, between resolving the
formats and the first bytes of a stream, is routinely a couple of seconds
of nothing to look at.

The session now starts off-screen: everything is built and loading, but the
card that was clicked keeps its own spinner, the page stays where it was,
and the player appears when the video has something to show. Failures
reveal it too, since the error and its retry live inside the player, and so
does a five-second timeout -- a stream that is merely slow is better
watched from inside a player that can be closed than from a card that looks
stuck.

The video is muted while it loads out of sight and restored on the way in,
because nothing should be heard from a player that isn't there. It is
hidden with opacity rather than display:none: iOS won't load a display:none
video, and the HUD's children opt back into pointer events, so they are
told not to as well -- an invisible close button must not eat a card's
click.

isActive() replaces the checks that asked "is the player open?" and meant
"is there a session": a pending one owns the same history entry, the same
card spinner and the same requests, so Escape cancels it, the back gesture
cancels it, another video takes over from it, and an update can't reload
the page out from under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:03 +00:00

159 lines
6.2 KiB
JavaScript

window.App = window.App || {};
App.version = App.version || {};
(function() {
const VERSION_URL = '/api/version';
const POLL_INTERVAL_MS = 60000;
// Baseline manifest captured on startup: { version, files: { rel: hash } }.
let baseline = null;
let timer = null;
// Set once a JS/HTML change is detected; the page reloads at a safe moment.
let reloadPending = false;
let checking = false;
async function fetchVersion() {
const resp = await fetch(VERSION_URL, { cache: 'no-store' });
if (!resp.ok) throw new Error('version fetch failed: ' + resp.status);
return resp.json();
}
// Swap a stylesheet <link> in place using a cache-busted href so updated CSS
// applies instantly. The old link is removed only after the new one loads to
// avoid a flash of unstyled content.
function hotReloadCss(relPath, hash) {
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
const match = links.find((l) => {
const href = (l.getAttribute('href') || '').split('?')[0];
return href.endsWith(relPath) || href.endsWith('/' + relPath);
});
if (!match) return false;
const base = (match.getAttribute('href') || '').split('?')[0];
const fresh = match.cloneNode(false);
fresh.setAttribute('href', base + '?v=' + hash);
fresh.addEventListener('load', () => { if (match.parentNode) match.remove(); });
fresh.addEventListener('error', () => { if (fresh.parentNode) fresh.remove(); });
match.parentNode.insertBefore(fresh, match.nextSibling);
return true;
}
function diffFiles(oldFiles, newFiles) {
const changed = [];
const keys = new Set([
...Object.keys(oldFiles || {}),
...Object.keys(newFiles || {})
]);
keys.forEach((k) => {
if ((oldFiles || {})[k] !== (newFiles || {})[k]) changed.push(k);
});
return changed;
}
// A reload is "safe" when the user isn't mid-playback: no open custom
// player, no active reels feed, and no playing <video>. App state survives
// a reload because it is restored from localStorage on boot.
function isSafeToReload() {
if (App.state && App.state.feedOpen) return false;
// A player still loading out of sight counts as in use: reloading
// would throw away the video the viewer just asked for.
if (App.player && typeof App.player.isActive === 'function' && App.player.isActive()) {
const player = document.getElementById('custom-player');
const video = player && player.querySelector('.cp-video');
if (!player.classList.contains('open')) return false;
if (video && !video.paused && !video.ended) return false;
}
return true;
}
function showUpdateBanner() {
const banner = document.getElementById('update-banner');
if (!banner) return;
banner.classList.add('show');
const btn = document.getElementById('update-banner-btn');
if (btn) btn.onclick = () => window.location.reload();
}
function tryReloadWhenSafe() {
if (!reloadPending) return;
if (isSafeToReload()) {
window.location.reload();
} else {
showUpdateBanner();
}
}
function apply(latest) {
const changed = diffFiles(baseline.files, latest.files);
if (!changed.length) return;
let needsReload = false;
changed.forEach((file) => {
if (file.endsWith('.css') && hotReloadCss(file, latest.files[file])) {
return; // hot-swapped without reload
}
// JS and HTML can't be safely live-patched; they require a reload.
needsReload = true;
});
// Adopt the new manifest so we don't re-trigger on the same change.
baseline = latest;
if (needsReload) {
reloadPending = true;
tryReloadWhenSafe();
}
}
async function check() {
if (checking || !baseline) return;
checking = true;
try {
const latest = await fetchVersion();
apply(latest);
} catch (e) {
// Network blips are non-fatal; we retry on the next tick.
} finally {
checking = false;
}
}
// Same check the poller runs, on demand: the top-bar refresh button asks for
// it so a tab left open across a deploy picks the new build up right then,
// rather than up to POLL_INTERVAL_MS later. Changed CSS hot-swaps; changed
// JS/HTML reloads as soon as that won't interrupt playback.
App.version.checkNow = function() {
if (!baseline) {
// start() never got a manifest (endpoint down, or it hasn't run
// yet). Adopt whatever the server reports now so there's something
// to diff against next time -- there's no baseline to compare this
// one against, so nothing can be concluded from it today.
return fetchVersion().then((latest) => { baseline = latest; }).catch(() => {});
}
return check();
};
App.version.start = async function() {
try {
baseline = await fetchVersion();
} catch (e) {
return; // endpoint unavailable; skip version checking entirely
}
timer = setInterval(check, POLL_INTERVAL_MS);
// Check promptly when the user returns to the tab so updates land while
// they were away, and retry a pending reload once playback stops.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
tryReloadWhenSafe();
check();
}
});
// Re-attempt a deferred reload whenever a video finishes/pauses. The
// custom player's <video> is torn down and rebuilt on every open(), so
// bind on the capture phase at the document level instead of to a
// specific element (media events don't bubble, but capture still sees
// them on ancestors).
document.addEventListener('pause', tryReloadWhenSafe, true);
document.addEventListener('ended', tryReloadWhenSafe, true);
};
})();