Make the app installable as a PWA #9

Merged
kyuno merged 2 commits from feature/pwa into master 2026-09-16 15:52:13 +02:00
10 changed files with 124 additions and 1 deletions
Showing only changes of commit 75a9fef18a - Show all commits

View file

@ -344,6 +344,9 @@ 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] 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)

View file

@ -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')],

View file

@ -4,6 +4,24 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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" />
<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>

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

@ -3419,6 +3419,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 `<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).
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's