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;

View File

@@ -1857,6 +1857,73 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
:root[data-density="compact"] .list-header { padding: 16px 16px 10px; }
:root[data-density="compact"] .topbar { padding: 11px 18px; }
/* ============================================================================
* UPDATE CARD — non-blocking: offer, downloading, installing, retry
* ========================================================================== */
.update-card {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 455; /* above Service mode and the report sheet, below toasts */
width: min(380px, calc(100vw - 32px));
display: grid;
grid-template-columns: 40px 1fr;
gap: 14px;
padding: 18px 18px 16px;
background: var(--bg-2);
color: var(--text);
border-radius: var(--radius);
box-shadow: 0 18px 48px -12px rgba(0, 0, 0, 0.55), 0 2px 6px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(16px);
transition: opacity 220ms cubic-bezier(0.16, 1, 0.3, 1), transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.update-card.is-open { opacity: 1; transform: none; }
.update-card .uc-icon {
width: 40px; height: 40px;
display: grid; place-items: center;
border-radius: 12px;
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
color: var(--accent-bright, var(--accent));
}
.update-card .uc-icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.update-card[data-phase="downloading"] .uc-icon svg,
.update-card[data-phase="ready"] .uc-icon svg { animation: uc-turn 1.6s linear infinite; }
.update-card[data-phase="error"] .uc-icon { background: color-mix(in srgb, var(--text-dim) 14%, var(--bg-2)); color: var(--text-2); }
.update-card .uc-main { min-width: 0; }
.update-card .uc-title { margin: 1px 0 4px; font-size: 16px; font-weight: 650; line-height: 1.3; letter-spacing: -0.01em; text-wrap: balance; }
.update-card .uc-body { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-2); text-wrap: pretty; }
.update-card .uc-progress { display: none; height: 4px; margin-top: 14px; border-radius: 4px; overflow: hidden; background: var(--bg-3); }
.update-card .uc-progress span { display: block; width: 38%; height: 100%; border-radius: inherit; background: var(--accent); animation: uc-slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
.update-card[data-phase="downloading"] .uc-progress,
.update-card[data-phase="ready"] .uc-progress { display: block; }
.update-card[data-phase="ready"] .uc-progress span { width: 100%; animation: none; }
.update-card .uc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.update-card .uc-actions button {
min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px;
font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
transition: background-color 140ms ease, transform 120ms ease;
}
.update-card .uc-actions button[hidden] { display: none; }
.update-card .uc-actions button:active { transform: scale(0.97); }
.update-card .uc-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.update-card .uc-later { background: transparent; color: var(--text-2); }
.update-card .uc-later:hover { background: var(--bg-3); color: var(--text); }
.update-card .uc-go { background: var(--accent-deep, var(--accent)); color: var(--on-primary, #fff); }
.update-card .uc-go:hover { background: color-mix(in srgb, var(--accent-deep, var(--accent)) 86%, #000); }
@keyframes uc-slide { from { transform: translateX(-100%); } to { transform: translateX(265%); } }
@keyframes uc-turn { to { transform: rotate(360deg); } }
@media (max-width: 720px) {
.update-card { right: 16px; left: 16px; width: auto; bottom: calc(16px + env(safe-area-inset-bottom)); }
}
@media (prefers-reduced-motion: reduce) {
.update-card { transition: opacity 120ms linear; transform: none; }
.update-card .uc-icon svg { animation: none !important; }
.update-card .uc-progress span { width: 100%; animation: none; opacity: 0.6; }
}
:root[data-motion="reduced"] .update-card .uc-icon svg,
:root[data-motion="reduced"] .update-card .uc-progress span { animation: none !important; }
/* ============================================================================
* PERFORMANCE MODE — drop expensive paints (grain, blur, big glows)
* ========================================================================== */