Make the app installable as a PWA #9
10 changed files with 124 additions and 1 deletions
|
|
@ -348,6 +348,9 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
||||||
asked for directly for mobile, where there's no second window to
|
asked for directly for mobile, where there's no second window to
|
||||||
put this app in while switching apps — see docs/architecture.md
|
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 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
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||||
confirmed here — no real device/session)
|
confirmed here — no real device/session)
|
||||||
|
|
|
||||||
|
|
@ -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')],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
client/src/public/icons/apple-touch-icon.png
Normal file
BIN
client/src/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
client/src/public/icons/favicon.png
Normal file
BIN
client/src/public/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1,003 B |
BIN
client/src/public/icons/icon-192.png
Normal file
BIN
client/src/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
client/src/public/icons/icon-512-maskable.png
Normal file
BIN
client/src/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/src/public/icons/icon-512.png
Normal file
BIN
client/src/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
16
client/src/public/manifest.webmanifest
Normal file
16
client/src/public/manifest.webmanifest
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -3480,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).
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue