Merge remote-tracking branch 'origin/master' into feature/mobile-nav
# Conflicts: # README.md # docs/architecture.md
This commit is contained in:
commit
2f10a46664
13 changed files with 248 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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 <video> is
|
||||
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
||||
its last frame frozen with no explanation would just look stuck. */
|
||||
|
|
|
|||
|
|
@ -127,6 +127,17 @@
|
|||
<span id="ambientNotice" class="ambientNotice hide"></span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||
const burnInHint = root.querySelector('#burnInHint')!;
|
||||
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
|
||||
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
||||
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
||||
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
||||
|
|
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
});
|
||||
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) {
|
||||
itemTitleEl.textContent = msg.itemName || '';
|
||||
hasNextQueued = !!msg.hasNext;
|
||||
|
|
|
|||
|
|
@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
|
|||
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Asked for directly: the sidebar's old mobile treatment just reshaped
|
||||
|
|
|
|||
Loading…
Reference in a new issue