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 `