diff --git a/frontend/app.js b/frontend/app.js
index 32c2ce3..37729d1 100755
--- a/frontend/app.js
+++ b/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 = '';
+ 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 = `
${ICON}
+
+
+
+
+
+
+
+
+
`;
+ 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;
diff --git a/frontend/styles.css b/frontend/styles.css
index 41670f9..00c9dc7 100755
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -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)
* ========================================================================== */