diff --git a/README.md b/README.md
index 1f6d9a1..e2493f2 100644
--- a/README.md
+++ b/README.md
@@ -344,6 +344,16 @@ 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
- [x] Music UI reworked to look like a music app instead of reusing
movie-poster tiles — circular artist tiles, square album tiles,
a real hero header, and a mobile "now playing" full-screen player
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/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css
index fccbd56..c802df8 100644
--- a/client/src/musicBar/MusicBar.css
+++ b/client/src/musicBar/MusicBar.css
@@ -6,13 +6,13 @@
able to browse/add tracks while it plays). Sits above .mainContent, at
a lower z-index than .playerWrap (20) so a video party's takeover still
correctly covers it - moot in practice since joining a video party
- calls leaveMusicParty() first (see Player.ts), but cheap to get right. */
+ calls leaveMusicParty() first (see Player.ts), but cheap to get right.
+ The fixed-to-viewport positioning itself lives one level up, on
+ Shell.css's .bottomStack wrapper - not here - so that on a narrow
+ screen the mobile nav trigger right below it in that wrapper always
+ sits flush above this bar's own actual rendered height (its content
+ can wrap taller on a narrow screen), never a hardcoded guess at it. */
.musicBar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 15;
background: var(--bg-header);
border-top: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
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/shell/Shell.css b/client/src/shell/Shell.css
index ac083cb..e2ae3d1 100644
--- a/client/src/shell/Shell.css
+++ b/client/src/shell/Shell.css
@@ -62,20 +62,162 @@
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
-/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
+/* ==== Mobile navigation (<=720px) ====
+ Replaces the sidebar outright - see docs/architecture.md's "Mobile
+ navigation" section for why a bottom sheet was chosen over reshaping
+ the sidebar into a horizontal bar. Every rule below the base
+ `display: none` only ever applies once the 720px media query turns
+ it back on, so none of this affects desktop. */
+.mobileTopBar,
+.mobileNavTrigger,
+.mobileNavScrim,
+.mobileNavSheet {
+ display: none;
+}
+
+.mobileTopBar {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ padding: 14px 16px;
+ background: var(--bg-header);
+}
+
+.mobileBrand { font-size: 16px; font-weight: 700; }
+
+/* position:fixed, not sticky - #musicBar below already relies on a
+ fixed wrapper to stay docked regardless of how far #mainContent's
+ own (whole-page, not internally-scrolled) content runs. */
+.bottomStack {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 15;
+ display: flex;
+ flex-direction: column;
+}
+
+.mobileNavTrigger {
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: var(--bg-header);
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, .08);
+ color: var(--text);
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 13px;
+ padding-bottom: calc(13px + env(safe-area-inset-bottom, 0px));
+}
+
+.mobileNavScrim {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ background: rgba(0, 0, 0, .5);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity .2s;
+}
+
+.mobileNavScrim.open { opacity: 1; pointer-events: auto; }
+
+.mobileNavSheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 21;
+ max-height: 75vh;
+ overflow-y: auto;
+ background: var(--bg-header);
+ border-radius: 16px 16px 0 0;
+ padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px));
+ transform: translateY(100%);
+ transition: transform .22s ease;
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, .3);
+}
+
+.mobileNavSheet.open { transform: translateY(0); }
+
+.sheetGrabber {
+ width: 36px;
+ height: 4px;
+ border-radius: 99px;
+ background: var(--muted);
+ margin: 10px auto 6px;
+}
+
+/* Accueil and the (conditional) Listes de lecture row both use this -
+ same visual weight, bookending the "Bibliothèques" group between
+ them, so the one part whose size actually varies (however many
+ libraries Emby returns) reads as visually distinct from the two
+ fixed rows around it. */
+.sheetPrimary {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ background: var(--panel);
+ border: none;
+ border-radius: 10px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 600;
+ padding: 12px;
+ margin: 4px 0;
+ text-decoration: none;
+}
+
+.sheetPrimary.active { background: var(--accent); color: #fff; }
+
+.sheetGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin: 4px 0 10px;
+}
+
+.sheetItem {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ background: var(--panel);
+ border-radius: 10px;
+ color: var(--text);
+ font-size: 13px;
+ padding: 12px 10px;
+ text-decoration: none;
+}
+
+.sheetItem.active { background: var(--accent); color: #fff; font-weight: 600; }
+
+.navIcon { width: 20px; height: 20px; flex-shrink: 0; display: block; }
+.navIcon svg { width: 100%; height: 100%; }
+
+.sheetLogout {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ margin-top: 8px;
+ padding: 12px;
+ border: 1px solid rgba(255, 255, 255, .15);
+ border-radius: 10px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
@media (max-width: 720px) {
.appShell { flex-direction: column; }
- .sidebar {
- width: 100%;
- height: auto;
- flex-direction: row;
- align-items: center;
- padding: 10px 12px;
- overflow-x: auto;
- overflow-y: hidden;
- }
- .sidebarBrand { padding: 0 12px 0 0; }
- .sidebarNav { flex-direction: row; flex: none; }
- .navSectionLabel { display: none; }
- .sidebarLogout { margin: 0 0 0 12px; }
+ .sidebar { display: none; }
+ .mobileTopBar { display: flex; align-items: center; }
+ .mobileNavTrigger { display: flex; }
+ .mobileNavScrim { display: block; }
+ .mobileNavSheet { display: block; }
}
diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html
index 9fa156a..4d2d356 100644
--- a/client/src/shell/Shell.html
+++ b/client/src/shell/Shell.html
@@ -6,8 +6,37 @@