Update prompt: non-blocking update card with inline download, install and retry states

This commit is contained in:
Jonathan Sykes
2026-10-15 11:10:56 +08:00
parent 5c94a7d46a
commit 007e5b5352
2 changed files with 149 additions and 18 deletions

View File

@@ -8906,23 +8906,83 @@ async function updateFromIcon() {
await applyUpdate(target);
}
// The update card: one non-blocking surface for the whole update, from the
// offer through download and install to a retry. It never covers playback or
// takes focus, so a song keeps playing while the new build downloads.
const UpdateCard = (() => {
let el = null, target = null;
const ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v4.5h-4.5"/><path d="M12 8v5M9.5 11l2.5 2.5 2.5-2.5"/></svg>';
const COPY = {
offer: ['A new version is ready', 'It takes a few seconds. Your playlists, history and saved songs stay on this device.'],
downloading: ['Downloading the update', 'Keep listening. The app reloads by itself when the download is done.'],
ready: ['Installing the update', 'Reloading in a moment.'],
error: ['The update didn’t download', ''],
};
function build() {
el = document.createElement('section');
el.className = 'update-card';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.setAttribute('aria-labelledby', 'updateCardTitle');
el.innerHTML = `<div class="uc-icon">${ICON}</div>
<div class="uc-main">
<h2 class="uc-title" id="updateCardTitle"></h2>
<p class="uc-body"></p>
<div class="uc-progress" aria-hidden="true"><span></span></div>
<div class="uc-actions">
<button type="button" class="uc-later">Later</button>
<button type="button" class="uc-go">Update now</button>
</div>
</div>`;
el.querySelector('.uc-later').addEventListener('click', () => { hide(); setUpdateIcon(true); });
el.querySelector('.uc-go').addEventListener('click', () => { if (target) applyUpdate(target); });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape' && el.dataset.phase !== 'downloading' && el.dataset.phase !== 'ready') { hide(); setUpdateIcon(true); } });
document.body.appendChild(el);
}
function show(phase, { to, detail } = {}) {
if (to) target = to;
if (!el) build();
const [title, body] = COPY[phase] || COPY.offer;
el.dataset.phase = phase;
el.querySelector('.uc-title').textContent = title;
el.querySelector('.uc-body').textContent = detail || body;
el.querySelector('.uc-go').textContent = phase === 'error' ? 'Try again' : 'Update now';
const busy = phase === 'downloading' || phase === 'ready';
el.querySelectorAll('button').forEach((b) => { b.hidden = busy; });
el.setAttribute('aria-busy', busy ? 'true' : 'false');
place();
requestAnimationFrame(() => el && el.classList.add('is-open'));
}
// Sit above whatever is docked at the bottom (tab bar, mini player), which
// differs by layout and by browser vs installed app.
function place() {
if (!el) return;
let off = 16;
for (const sel of ['.bottom-nav', '.mini-bar']) {
const n = document.querySelector(sel);
if (!n || getComputedStyle(n).display === 'none' || getComputedStyle(n).visibility === 'hidden') continue;
const r = n.getBoundingClientRect();
if (r.height > 0 && r.top < innerHeight && r.bottom > innerHeight - 4) off = Math.max(off, innerHeight - r.top + 12);
}
el.style.bottom = off + 'px';
}
addEventListener('resize', place);
function hide() {
if (!el) return;
const node = el; el = null;
node.classList.remove('is-open');
setTimeout(() => node.remove(), 260);
}
return { show, hide, isOpen: () => !!el };
})();
function showUpdateBanner(target) {
// Only show the dialog once per page load
// Only offer once per page load
if (_updateBannerShown) return;
_updateBannerShown = true;
// However the dialog is dismissed (Later, backdrop), the icon remains.
// However the card is dismissed, the icon remains.
setUpdateIcon(true);
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
const body = document.createElement('p');
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
showModal('⬆ Update available', body, [
{ label: 'Later', onClick: () => { closeModal(); setUpdateIcon(true); } },
{ label: 'Refresh UI', primary: true, onClick: async () => {
closeModal();
await applyUpdate(target);
}},
]);
UpdateCard.show('offer', { to: target });
}
// Tracks the current SW registration so applyUpdate() can reach the
@@ -8968,10 +9028,12 @@ async function applyUpdate(target, { quiet = false } = {}) {
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
onPhase: (phase, status) => {
if (quiet) return;
if (_updateToast) _updateToast.remove();
_updateToast = phase === 'ready'
? toast(status?.offlineReady === false ? `Applying the update… Offline cache is still preparing (${status.cached}/${status.total}).` : 'Applying the update…', { duration: 20000 })
: toast('Downloading the update…', { duration: 305000 });
setUpdateIcon(false);
UpdateCard.show(phase === 'ready' ? 'ready' : 'downloading', {
to: target,
detail: phase === 'ready' && status?.offlineReady === false
? `Reloading in a moment. Offline songs are still being prepared (${status.cached} of ${status.total}).` : '',
});
},
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
cachesApi: caches,
@@ -8991,7 +9053,9 @@ async function applyUpdate(target, { quiet = false } = {}) {
_updating = false;
writeUpdateState(null);
if (_updateToast) { _updateToast.remove(); _updateToast = null; }
toast('⚠ Update didn’t download (' + (err && err.message ? err.message : 'connection problem') + '). Tap the update icon to try again.', { duration: 6000 });
const why = err && err.message ? err.message : 'The connection dropped';
if (quiet) toast('Update didn’t download (' + why + '). Tap the update icon to try again.', { duration: 6000 });
else UpdateCard.show('error', { to: target, detail: `${why.replace(/\.?$/, '.')} Check your connection and try again.` });
setUpdateIcon(true);
// Let the next poll re-offer it.
_updateBannerShown = false;