Single fullscreen player state everywhere (desktop/Android/iOS/feed) instead of the old modal-vs-native-fullscreen split, with custom controls: draggable timeline with buffered range, dynamically-escalating skip buttons (double-tap zones too), per-video format switching, favorites, PiP with auto-PiP on backgrounding, volume swipe, TikTok-style HUD auto-hide, and swipe-down/ back-button/close-button dismissal. Reels feed reuses the same skip/format/ PiP logic via the new customPlayer.js shared module. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
141 lines
5.2 KiB
JavaScript
141 lines
5.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;
|
|
const player = document.getElementById('custom-player');
|
|
if (player && player.classList.contains('open')) {
|
|
const video = player.querySelector('.cp-video');
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
};
|
|
})();
|