diff --git a/README.md b/README.md
index f90bd76..55edfb5 100644
--- a/README.md
+++ b/README.md
@@ -348,6 +348,9 @@ Then put it behind your existing reverse proxy like everything else
`requestPictureInPicture`, confirmed present on Android Chrome) —
asked for directly for mobile, where there's no second window to
put this app in while switching apps — see docs/architecture.md
+- [x] Installable as a PWA (manifest + icons, standalone display,
+ no service worker — this app has nothing worth caching offline)
+ — 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/build.mjs b/client/build.mjs
index 0290eef..55fb725 100644
--- a/client/build.mjs
+++ b/client/build.mjs
@@ -11,7 +11,7 @@
// npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section)
import * as esbuild from 'esbuild';
-import { copyFileSync, mkdirSync } from 'fs';
+import { copyFileSync, cpSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
+// PWA manifest + icons (manifest.webmanifest, icons/*) - see
+// docs/architecture.md's "Progressive Web App" section. Copied wholesale
+// rather than named file-by-file like index.html above, so adding another
+// icon later needs no build.mjs change.
+cpSync(path.join(here, 'src', 'public'), outdir, { recursive: true });
const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')],
diff --git a/client/src/index.html b/client/src/index.html
index e57676f..ecb359c 100644
--- a/client/src/index.html
+++ b/client/src/index.html
@@ -4,6 +4,24 @@
Watch Party
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/public/icons/apple-touch-icon.png b/client/src/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..c8262c7
Binary files /dev/null and b/client/src/public/icons/apple-touch-icon.png differ
diff --git a/client/src/public/icons/favicon.png b/client/src/public/icons/favicon.png
new file mode 100644
index 0000000..bcc545f
Binary files /dev/null and b/client/src/public/icons/favicon.png differ
diff --git a/client/src/public/icons/icon-192.png b/client/src/public/icons/icon-192.png
new file mode 100644
index 0000000..e533df5
Binary files /dev/null and b/client/src/public/icons/icon-192.png differ
diff --git a/client/src/public/icons/icon-512-maskable.png b/client/src/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..b20de6e
Binary files /dev/null and b/client/src/public/icons/icon-512-maskable.png differ
diff --git a/client/src/public/icons/icon-512.png b/client/src/public/icons/icon-512.png
new file mode 100644
index 0000000..983626f
Binary files /dev/null and b/client/src/public/icons/icon-512.png differ
diff --git a/client/src/public/manifest.webmanifest b/client/src/public/manifest.webmanifest
new file mode 100644
index 0000000..f6a2eaa
--- /dev/null
+++ b/client/src/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "Watch Party",
+ "short_name": "Watch Party",
+ "description": "Regardez et écoutez en même temps que les autres utilisateurs de votre serveur Emby.",
+ "lang": "fr",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#151216",
+ "theme_color": "#221d24",
+ "categories": ["entertainment", "multimedia"],
+ "icons": [
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index fe7e06f..eebd73d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -3480,6 +3480,87 @@ tokens) rather than eyeballed. **Not verified**: an actual large
discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox.
+## Progressive Web App
+
+Asked for directly, as a follow-up to Picture-in-picture above: since
+that feature's automatic-on-backgrounding variant turned out not to be
+achievable (browsers require a *fresh* user gesture for
+`requestPictureInPicture()` - confirmed empirically, not just from
+docs, by calling it from a real `visibilitychange` listener in a real
+Chromium tab and getting `NotAllowedError` even with
+`navigator.userActivation.hasBeenActive === true` from an earlier real
+click), a PWA install was asked for instead. **Worth being explicit
+about, since it'd be easy to assume otherwise**: installing this app
+does not change that outcome - the transient-activation requirement is
+part of the Picture-in-Picture spec itself, not something tied to
+being loaded in a browser tab versus an installed, `display: standalone`
+window. The one Chrome feature that skips that requirement
+(media-session-handler-based automatic PiP) stays desktop-only and
+getUserMedia-gated regardless of install state, and nothing above
+touches that gate. So the manual `#pipBtn` toggle remains the way to
+enter PiP either way - a PWA install is its own, independently useful
+feature here (an icon on the home screen, no browser chrome/URL bar
+around the player, an OS-level app-switcher entry), not a workaround
+for the previous section's limitation.
+
+Three pieces, all standard and Chromium/Android-focused since that's
+this app's stated mobile target (see Picture-in-picture above):
+
+- **`client/src/public/manifest.webmanifest`** - `name`/`short_name`,
+ `display: "standalone"`, and `background_color`/`theme_color` copied
+ by hand from `base.css`'s `--bg`/`--bg-header` (a manifest can't read
+ CSS custom properties, so these two are duplicated on purpose - if
+ that palette ever changes, update both places). `start_url: "/"`,
+ `lang: "fr"` matching `index.html`'s own ``. No
+ `service worker` - confirmed against MDN's own installability
+ requirements that Chrome does not require one for the install
+ prompt, and this app has no real use for one: every page is either
+ live party/socket state or a proxied Emby catalog, and caching either
+ offline would mean showing stale library data or a stale party that's
+ already gone, not a working offline mode - so skipping it avoids a
+ whole stale-cache-invalidation problem this app doesn't need to
+ solve.
+- **Icons** - hand-authored SVG (this project has no existing
+ logo/brand asset), rasterized to PNG at the sizes each consumer
+ needs (192/512 for the manifest, 180 for `apple-touch-icon`, 32 for
+ the browser-tab favicon) via a `