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) * ========================================================================== */