Update prompt: non-blocking update card with inline download, install and retry states
This commit is contained in:
100
frontend/app.js
100
frontend/app.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user