Merge remote-tracking branch 'origin/master' into feature/mobile-nav
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped

# Conflicts:
#	README.md
#	docs/architecture.md
This commit is contained in:
Nicolas 2026-09-16 15:55:14 +02:00
commit 2f10a46664
13 changed files with 248 additions and 1 deletions

View file

@ -344,6 +344,13 @@ Then put it behind your existing reverse proxy like everything else
action — resolved server-side (one atomic operation, not one action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md 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 / - [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md scrolling sidebar — see docs/architecture.md

View file

@ -11,7 +11,7 @@
// npm run watch -> rebuilds on save, for local dev (see README's // npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section) // Running locally section)
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync } from 'fs'; import { copyFileSync, cpSync, mkdirSync } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true }); mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html')); 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 = { const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')], entryPoints: [path.join(here, 'src', 'app.ts')],

View file

@ -4,6 +4,24 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title> <title>Watch Party</title>
<!-- PWA - see docs/architecture.md's "Progressive Web App" section.
theme-color/background_color both come straight from base.css's
--bg-header/--bg (the manifest can't read CSS custom properties,
so these are duplicated by hand - if that palette ever changes,
update manifest.webmanifest to match). -->
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#221d24" />
<link rel="icon" type="image/png" href="icons/favicon.png" />
<!-- iOS ignores the manifest above for installed-app chrome - Safari
needs its own separate meta tags/link for the equivalent
standalone/home-screen-icon behavior. -->
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Watch Party" />
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -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" }
]
}

View file

@ -366,6 +366,15 @@
.fsBtn:hover { background: rgba(255, 255, 255, .18); } .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 <video> is /* Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */ its last frame frozen with no explanation would just look stuck. */

View file

@ -127,6 +127,17 @@
<span id="ambientNotice" class="ambientNotice hide"></span> <span id="ambientNotice" class="ambientNotice hide"></span>
</div> </div>
<div class="bottomBarZone right"> <div class="bottomBarZone right">
<!-- Hidden by default, shown only once Player.ts confirms
document.pictureInPictureEnabled - not every browser supports
this (see its own comment), and there's no live
"availability changed" event to react to the way the cast
button has, since this never changes mid-session. -->
<button id="pipBtn" class="fsBtn hide" title="Picture-in-picture" aria-label="Picture-in-picture" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="3" y="4" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"></rect>
</svg>
</button>
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button> <button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
</div> </div>
</div> </div>

View file

@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!; const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement; const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement; const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement; const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement; const progressBar = root.querySelector('#progressBar') as HTMLElement;
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}); });
updateFullscreenBtn(); updateFullscreenBtn();
/**
* Picture-in-picture - asked for directly, mobile in particular: the
* one way to keep watching in a small floating window while switching
* to another app, since a phone has no second window/screen to put
* this app in the way a desktop might. Feature-detected once at mount,
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
* Chromecast appearing/disappearing on the network mid-session,
* browser support for this is fixed for the lifetime of the page, so
* there's nothing to react to after this first check. video itself
* needs no new attribute for this to work - unlike disableremoteplayback
* (added specifically to suppress Chrome's own auto cast icon), there's
* no equivalent auto-injected native PiP control this app needs to
* fight with.
*/
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
pipBtn.classList.remove('hide');
pipBtn.addEventListener('click', () => {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
} else {
video.requestPictureInPicture().catch((err) => {
// Can genuinely fail even when supported (no user gesture in
// some browsers' stricter reading of that, video not yet
// ready) - same "try it, log it, don't crash anything else"
// instinct as every other best-effort browser API this app
// calls (video.play().catch, requestFullscreen().catch, ...).
console.error('[watchparty player] picture-in-picture failed', err);
});
}
});
// Reflects state via the same [aria-pressed] treatment #castBtn
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
// browser's own PiP window being closed directly (its native close
// button, or the OS), not just this button's own click.
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
}
async function onItemChanged(msg: ItemChangedMsg) { async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;

View file

@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real `X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
dashboard the first time a party runs after this ships. dashboard the first time a party runs after this ships.
## Picture-in-picture
Asked for directly, mobile in particular: a phone has no second window
to put this app in the way a desktop might, so the standard browser
Picture-in-Picture API - popping the `<video>` out into a small,
OS-managed floating window - is the one way to keep watching while
switching to another app. Standard `HTMLVideoElement
.requestPictureInPicture()`/`document.exitPictureInPicture()`, not the
Chromecast-adjacent `disableremoteplayback` trick or anything WebKit-
prefixed - confirmed directly in a real Chromium build that
`document.pictureInPictureEnabled`/`requestPictureInPicture`/
`exitPictureInPicture` are all present, which is what actually matters
for Android Chrome, the mobile browser this was asked for.
New `#pipBtn`, right next to `#fullscreenBtn` (`.bottomBarZone.right`)
- both are "how is this displayed" toggles, so they share the same
`.fsBtn` sizing, and a new `.fsBtn[aria-pressed="true"]` rule gives PiP
the identical "clearly on" orange treatment `#castBtn` already has
(kept as its own rule rather than combined onto `.iconBtn` - that class
and `.fsBtn` disagree on padding/border-radius/background, so
combining them risked an inconsistent size instead of a clean shared
look). Feature-detected once at mount (`document.pictureInPictureEnabled
&& !video.disablePictureInPicture`), not live the way `#castBtn`'s own
`onCastAvailabilityChanged` is - browser support for this doesn't
appear/disappear mid-session the way a Chromecast on the network does,
so there's nothing to react to after the first check. No new attribute
needed on `<video>` itself either - unlike `disableremoteplayback`
(added specifically to suppress Chrome's own auto-injected cast icon),
there's no equivalent native PiP affordance this app needs to
suppress.
State (`aria-pressed`) is kept in sync via the video's own
`enterpictureinpicture`/`leavepictureinpicture` events rather than the
button's own click handler alone - covers the browser's native PiP
window being closed directly (its own close control, or the OS), not
just a second click back in this app. Requires no cleanup on route
navigation: like every other per-mount element here, `<video>` itself
is destroyed and replaced by the next `root.innerHTML = html` (router.ts
runs this unconditionally on any navigation) - browsers already close
an active PiP window automatically once its source element leaves the
document, matching this app's very deliberate opposite choice for
casting (leaving an active Chromecast session running after
navigating away - see "Casting to a Chromecast" above): PiP has no
independent existence apart from the `<video>` element it mirrors, a
Chromecast session does.
Verified: full `typecheck`/`build` (client only - this is a pure
client-side feature, no server involved). Confirmed directly in a real
browser, not just type-checked: `document.pictureInPictureEnabled` is
`true` here, and running the exact show/hide + click-handling logic
against a real (sourceless) `<video>` correctly shows the button and
catches the resulting `InvalidStateError` from `requestPictureInPicture()`
without it becoming an uncaught exception - the same rejection a real
video would throw for a different reason (already in PiP, or the
document not focused), confirming the error handling path itself
rather than just assuming it. **Not verified**: an actual floating PiP
window on a real mobile browser, playing a real Emby stream - no
device/login reachable from this sandbox, same standing gap as this
project's other browser-native integrations (casting, the intro-skip
buttons).
## Queuing a whole album, or an artist's entire discography, in one action ## Queuing a whole album, or an artist's entire discography, in one action
Asked for directly: `views/album`'s and `views/artist`'s per-track/ Asked for directly: `views/album`'s and `views/artist`'s per-track/
@ -3419,6 +3480,87 @@ tokens) rather than eyeballed. **Not verified**: an actual large
discography queuing correctly end to end against a real server - no discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox. 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 `<html lang="fr">`. 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 `<canvas>` in a real browser rather
than an image-editing tool, since that's what was available here.
Two source SVGs, not one: `icon.svg` fills the canvas (used for the
manifest's `"any"` purpose, the favicon, and `apple-touch-icon` -
none of those get OS-mask-cropped) and `icon-maskable.svg` scales
the same design down (~75%) so its farthest content point sits
inside Android's adaptive-icon safe zone (a circle of ~30.5% of the
icon's radius from center) - checked by computing the actual
farthest-point distance for both versions, not just eyeballed,
since a maskable icon that ignores that zone gets silently clipped
by circular-mask launchers.
- **`index.html`** - `<link rel="manifest">`, a `theme-color` meta
(Chrome/Android chrome), and the separate iOS-only tags Safari needs
instead of reading the manifest for the equivalent behavior
(`apple-touch-icon` link, `apple-mobile-web-app-capable`,
`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`).
`build.mjs` gained one `cpSync(src/public, dist, { recursive: true })`
call alongside its existing `index.html` copy, rather than naming the
manifest and every icon individually - adding another icon later needs
no build.mjs change. `express.static` (already serving all of
`client/dist` unconditionally) picks up `manifest.webmanifest` and
`icons/*` with no server-side change at all.
Verified: full `typecheck`/`build` (client only). Confirmed directly
against a real running instance of this server, not just assumed:
`manifest.webmanifest` and all three manifest icons resolve `200` with
the correct `application/manifest+json`/`image/png` content types
(express's static middleware infers both from the file extension
correctly on its own), the `apple-touch-icon` link resolves `200`, the
generated PNGs are genuinely the declared dimensions (32/180/192/512),
and the page's own console shows no manifest-related warnings after
loading it. **Not verified**: an actual "Add to Home Screen"/install
prompt, or the installed app's standalone window, on a real device -
no device reachable from this sandbox, same standing gap as every
other browser-native integration in this project (casting, PiP, the
intro-skip buttons).
## Mobile navigation: a bottom sheet, not a reshaped sidebar ## Mobile navigation: a bottom sheet, not a reshaped sidebar
Asked for directly: the sidebar's old mobile treatment just reshaped Asked for directly: the sidebar's old mobile treatment just reshaped