diff --git a/README.md b/README.md index e59deb5..e862c07 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,9 @@ Then put it behind your existing reverse proxy like everything else - [x] Fixed an album's track rows collapsing to 3 lines on mobile — there was always room for one, the breakpoint just assumed otherwise — see docs/architecture.md +- [x] A mobile banner nudging an install of the PWA — the real + Chrome/Android install prompt, manual instructions on iOS Safari + (no such prompt exists there) — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/pwaInstall.css b/client/src/pwaInstall.css new file mode 100644 index 0000000..ba88a55 --- /dev/null +++ b/client/src/pwaInstall.css @@ -0,0 +1,46 @@ +/* ---- Mobile-only "install this as an app" banner - see pwaInstall.ts. + A normal-flow element at the very top of , before #app, not a + fixed/sticky overlay - pushes the app down by its own height instead + of needing to coordinate an offset with shell/Shell.css's own + .mobileTopBar/.libraryHeader (already stacked on top of each other at + the same breakpoint) or alphaIndex.css's rail. Scrolls away with the + page like any other content above the sticky chrome, same as a + browser's own native install infobar would. ---- */ +.pwaInstallBanner { + display: none; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--accent); + color: #fff; + font-size: 13px; + line-height: 1.35; +} + +.pwaInstallText { flex: 1; min-width: 0; } + +.pwaInstallBtn { + flex-shrink: 0; + background: #fff; + color: var(--accent); + font-size: 12px; + font-weight: 700; + padding: 6px 12px; +} + +.pwaInstallBtn:hover { background: rgba(255, 255, 255, .85); } + +.pwaInstallClose { + flex-shrink: 0; + background: none; + color: #fff; + font-size: 20px; + line-height: 1; + padding: 0 2px; +} + +.pwaInstallClose:hover { background: none; opacity: .8; } + +@media (max-width: 720px) { + .pwaInstallBanner { display: flex; } +} diff --git a/client/src/pwaInstall.ts b/client/src/pwaInstall.ts new file mode 100644 index 0000000..ece46ac --- /dev/null +++ b/client/src/pwaInstall.ts @@ -0,0 +1,108 @@ +// Mobile-only "install this as an app" nudge - see docs/architecture.md. +// Self-mounting like toast.ts (no per-route/per-party state to attach), +// but the beforeinstallprompt listener below is attached the moment +// this module is first imported - app.ts does that at the very top, +// well before boot()'s async login check - rather than lazily on first +// use like toast.ts: the browser only ever fires this event once per +// page load, often before any UI exists to react to it, and there's no +// way to ask for a replay if a listener attaches too late to catch it. +import './pwaInstall.css'; + +/** Chrome/Edge/Android-WebView's own install-prompt event - not in + * lib.dom.d.ts (Safari/Firefox never implement it at all). */ +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +const DISMISSED_KEY = 'watchparty:pwaInstallDismissed'; +const MOBILE_QUERY = '(max-width: 720px)'; + +let deferredPrompt: BeforeInstallPromptEvent | null = null; +let bannerEl: HTMLElement | null = null; + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); // suppress the browser's own mini-infobar - this banner replaces it + deferredPrompt = e as BeforeInstallPromptEvent; + renderIfEligible(); +}); + +window.addEventListener('appinstalled', () => { + deferredPrompt = null; + removeBanner(); +}); + +function isStandalone(): boolean { + return window.matchMedia('(display-mode: standalone)').matches + || (navigator as unknown as { standalone?: boolean }).standalone === true; // iOS Safari's own flag - no display-mode query there +} + +/** No feature-detection exists for "is this iOS Safari" - beforeinstallprompt + * simply never fires there, which is the actual fact this module cares + * about, but by the time that's observable it's too late to show + * install instructions instead. Checked only as a fallback once + * deferredPrompt has had a real chance to arrive - see renderIfEligible. */ +function isIOS(): boolean { + return /iphone|ipad|ipod/i.test(navigator.userAgent) + || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ reports as a Mac +} + +function removeBanner(): void { + bannerEl?.remove(); + bannerEl = null; +} + +function dismiss(): void { + localStorage.setItem(DISMISSED_KEY, '1'); + removeBanner(); +} + +async function install(): Promise { + if (!deferredPrompt) return; + const prompt = deferredPrompt; + deferredPrompt = null; // a captured prompt can only ever be used once + await prompt.prompt(); + const { outcome } = await prompt.userChoice; + // 'dismissed' here means the user closed the browser's own native + // picker without installing - not the same signal as our own close + // button (dismiss() above), so this banner stays up rather than + // remembering "never ask again" over a single not-right-now. + if (outcome === 'accepted') removeBanner(); +} + +function renderIfEligible(): void { + if (bannerEl) return; + if (isStandalone()) return; + if (localStorage.getItem(DISMISSED_KEY)) return; + if (!window.matchMedia(MOBILE_QUERY).matches) return; + + const ios = isIOS(); + if (!deferredPrompt && !ios) return; // no install path this module actually knows how to help with + + bannerEl = document.createElement('div'); + bannerEl.className = 'pwaInstallBanner'; + bannerEl.innerHTML = ios + ? `Installe Watch Party : appuie sur Partager puis « Sur l'écran d'accueil ». + ` + : `Installe Watch Party sur ton écran d'accueil pour y accéder plus vite. + + `; + + bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss); + bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); }); + + document.body.insertBefore(bannerEl, document.body.firstChild); +} + +/** + * Called once by Shell.ts after a successful login - covers both the + * case where beforeinstallprompt already fired before Shell existed to + * react to it (deferredPrompt sitting ready, banner never actually + * shown yet) and iOS, which has no event at all to trigger this from. + * Also re-checked on resize: crossing the 720px breakpoint live (an + * unfolding or rotated device) shouldn't need a reload to notice. + */ +export function initPwaInstallPrompt(): void { + renderIfEligible(); + window.addEventListener('resize', renderIfEligible); +} diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index 8d83edb..60f76cb 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -5,6 +5,7 @@ import './Shell.css'; import html from './Shell.html'; import { auth, getLibraries, logout } from '../api/embyApi'; import { initMusicBar } from '../musicBar/MusicBar'; +import { initPwaInstallPrompt } from '../pwaInstall'; import type { LibraryDto } from '../types'; // One icon per Emby CollectionType - only used by the mobile bottom @@ -56,6 +57,7 @@ export async function renderShell(appEl: HTMLElement): Promise { // Mounted once, outside #mainContent, so a listen party survives // navigating the router - see musicBar/MusicBar.ts's header comment. initMusicBar(appEl.querySelector('#musicBar') as HTMLElement); + initPwaInstallPrompt(); // Both the sidebar's and the mobile sheet's logout button - same // label, same handler, one line each instead of a shared DOM node diff --git a/docs/architecture.md b/docs/architecture.md index 3edee18..2f2969c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4266,6 +4266,70 @@ ranges genuinely overlapping, not just similar by coincidence), left to right in that order, with the long names confirmed actually truncated (`scrollWidth > clientWidth`) rather than merely happening to fit. +## A mobile install nudge for the PWA + +Asked for directly: the PWA (see "Progressive Web App" above) existed, +but nothing ever told a mobile visitor it could be installed at all - +Chrome's own default mini-infobar is easy to miss/dismiss, and Safari +has no browser-driven prompt whatsoever. + +New `pwaInstall.ts`, self-mounting like `toast.ts` (no per-route/ +per-party state to attach) but with one real difference from that +module's own lazy-init pattern: `beforeinstallprompt` (Chrome/Edge/ +Android WebView's event for this - not in `lib.dom.d.ts`, Safari/ +Firefox never implement it) can fire before any UI exists to react to +it, and the browser never replays it for a listener that attached too +late. The listener itself is a top-level side effect of importing the +module - `shell/Shell.ts` does that (and calls the module's own +`initPwaInstallPrompt()`, after `initMusicBar`), and since ES module +imports all evaluate before an entry file's own top-level code runs, +that listener is live well before `app.ts`'s `boot()` even starts its +async login check, regardless of how soon the event actually fires. + +Two real install paths exist, and the banner speaks to whichever one +applies: +- **Chrome/Edge/Android WebView**: `event.preventDefault()` stashes the + captured event (this banner replaces the browser's own default one); + clicking "Installer" calls the real `prompt()` and reads back + `userChoice` - only an outright 'accepted' removes the banner, since + the picker's own 'dismissed' just means "not right now", not "stop + asking" (that's what the banner's own close button is for). +- **iOS Safari**: no such event exists at all, ever - detected instead + by user-agent sniffing (there's no clean feature-detection for "is + this iOS Safari"), showing manual instructions ("appuie sur Partager + puis Sur l'écran d'accueil") with no button, since there's nothing to + programmatically trigger. + +Mobile-only (`matchMedia('(max-width: 720px)')`, this app's own +existing breakpoint, re-checked on resize for a live rotate/unfold) and +suppressed entirely once already running installed +(`display-mode: standalone`, or `navigator.standalone` for iOS Safari's +own equivalent flag, which has no `display-mode` query at all). A +close click remembers "don't ask again" in `localStorage` - not +re-shown even against a fresh `beforeinstallprompt` on a later visit. + +Rendered as a plain normal-flow banner at the very top of ``, +before `#app` - not a fixed/sticky overlay - specifically to avoid +needing to coordinate yet another offset with `shell/Shell.css`'s own +already-stacked `.mobileTopBar`/`.libraryHeader` pair (see the sticky- +header sections above) or `alphaIndex.css`'s rail. It simply pushes the +app down by its own height and scrolls away with the page, the same as +a browser's own native install infobar would. + +Verified: full `typecheck`/`build` (client only). No real device +install flow is triggerable in this sandbox (nothing can fabricate a +genuine browser installability signal), so checked everything around +that boundary directly instead: a synthetic `beforeinstallprompt`- +shaped event (real `Event`, stubbed `prompt`/`userChoice`) dispatched +into real compiled CSS/JS confirmed the banner appears with the +install button, that button calls the stub `prompt()` and removes the +banner only on an 'accepted' outcome, and that the close button both +removes it and blocks a *second* dispatched event from reopening it. +Overriding `navigator.userAgent` to an iPhone string confirmed the +iOS-flavored copy with no install button. A same-event dispatch into a +real ≥900px-wide `