diff --git a/README.md b/README.md
index 0a9db69..d15c034 100644
--- a/README.md
+++ b/README.md
@@ -344,6 +344,13 @@ Then put it behind your existing reverse proxy like everything else
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md
+- [x] Picture-in-picture for the video player (standard
+ `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
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md
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/client/src/views/player/Player.css b/client/src/views/player/Player.css
index 04fce66..54412fe 100644
--- a/client/src/views/player/Player.css
+++ b/client/src/views/player/Player.css
@@ -366,6 +366,15 @@
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
+/* #pipBtn while active - same "clearly-on" treatment as .iconBtn's own
+ [aria-pressed="true"] rule (#castBtn), just scoped to .fsBtn instead -
+ the two aren't combined onto one shared class since .iconBtn and
+ .fsBtn otherwise disagree on padding/border-radius/background. */
+.fsBtn[aria-pressed="true"] {
+ color: #ff9800;
+ background: rgba(255, 152, 0, .18);
+}
+
/* Replaces the video content while casting - the local