Compare commits
27 commits
fix/music-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ceaa2e050 | |||
| 1a10e5bf57 | |||
| 363cf44ebe | |||
| 56f969ba78 | |||
| b0d2100210 | |||
| db3cf68843 | |||
| 58d51f5f74 | |||
| 1e437e4a91 | |||
| 2de375ac62 | |||
| 5182def5c9 | |||
| 04a9116f68 | |||
| 7fbf22419d | |||
| 076cd7a624 | |||
| 83592f1ee9 | |||
| 310dcd422e | |||
| 6bec09ae20 | |||
| b31377c1e9 | |||
| 5b2b832be7 | |||
| 3ae1fce806 | |||
| 071184e3c6 | |||
| 6582ad855b | |||
| 9cb25f4dc0 | |||
| d79b49f34d | |||
| e9e288c93c | |||
| 7f0dbc28e3 | |||
| c12e34e1e7 | |||
| 021f272a7d |
51 changed files with 3460 additions and 86 deletions
|
|
@ -55,13 +55,16 @@ jobs:
|
||||||
# node:20-alpine n'a pas git préinstallé - même approche de clone
|
# node:20-alpine n'a pas git préinstallé - même approche de clone
|
||||||
# manuel que le job publish (pas d'actions/checkout, action JS qui a
|
# manuel que le job publish (pas d'actions/checkout, action JS qui a
|
||||||
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
|
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
|
||||||
|
# Sur une PR, `ref_name` vaut le numéro de la PR (ex. `29`), qui n'est
|
||||||
|
# pas une ref récupérable ("couldn't find remote ref 29") : on récupère
|
||||||
|
# alors la branche source de la PR (`head_ref`, vide hors PR).
|
||||||
- name: Récupérer les sources
|
- name: Récupérer les sources
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
apk add --no-cache git
|
apk add --no-cache git
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
git fetch -q --depth 1 origin "${{ github.ref_name }}"
|
git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
|
||||||
git checkout -q FETCH_HEAD
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- name: Serveur - installer les dépendances
|
- name: Serveur - installer les dépendances
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,6 +21,9 @@ server/dist/
|
||||||
client/dist/
|
client/dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts)
|
||||||
|
data/
|
||||||
|
|
||||||
## OS cruft
|
## OS cruft
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -159,7 +159,7 @@ server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
|
||||||
tsc to server/dist.
|
tsc to server/dist.
|
||||||
src/index.ts Express app + route wiring only - thin,
|
src/index.ts Express app + route wiring only - thin,
|
||||||
delegates to the services below
|
delegates to the services below
|
||||||
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
|
src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR)
|
||||||
src/types.ts shared interfaces (Party, ItemDto, the
|
src/types.ts shared interfaces (Party, ItemDto, the
|
||||||
Socket.IO event maps, ...)
|
Socket.IO event maps, ...)
|
||||||
src/Logger.ts party/stream lifecycle logging
|
src/Logger.ts party/stream lifecycle logging
|
||||||
|
|
@ -245,9 +245,14 @@ build stage, so nothing needs building beforehand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t watchparty .
|
docker build -f server/Dockerfile -t watchparty .
|
||||||
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
|
docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `/data` volume holds the static (persistent) watch parties
|
||||||
|
(`DATA_DIR`, see [`spec/static-watch-party.md`](spec/static-watch-party.md)).
|
||||||
|
Without it they're lost whenever the container is recreated.
|
||||||
|
`docker-compose.yml` already mounts it.
|
||||||
|
|
||||||
Then put it behind your existing reverse proxy like everything else
|
Then put it behind your existing reverse proxy like everything else
|
||||||
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
|
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
|
||||||
`watchparty.kyuno.fr` → `http://<container>:3000`.
|
`watchparty.kyuno.fr` → `http://<container>:3000`.
|
||||||
|
|
@ -386,6 +391,27 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
was missing a grid-column span, so it (and not just the queue
|
was missing a grid-column span, so it (and not just the queue
|
||||||
popup above) was still squeezed and wrapping controls onto a
|
popup above) was still squeezed and wrapping controls onto a
|
||||||
second row — see docs/architecture.md
|
second row — see docs/architecture.md
|
||||||
|
- [x] Fixed an album's track rows collapsing to 3 lines on mobile —
|
||||||
|
there was always room for one, the breakpoint just assumed
|
||||||
|
otherwise — see docs/architecture.md
|
||||||
|
- [x] A mobile banner nudging an install of the PWA — the real
|
||||||
|
Chrome/Android install prompt, manual instructions on iOS Safari
|
||||||
|
(no such prompt exists there) — see docs/architecture.md
|
||||||
|
- [x] Linked whatever's playing (video or music) to the OS's own media
|
||||||
|
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
|
||||||
|
play/pause/seek/next — see docs/architecture.md
|
||||||
|
- [x] Fixed switching a party between devices leaving both "on the
|
||||||
|
session" with no working controls — the old device never reacted
|
||||||
|
to being kicked, and the leave route had no way to tell a stale
|
||||||
|
device apart from the current one — see docs/architecture.md
|
||||||
|
- [x] Fixed a track started directly (not via its album/artist) never
|
||||||
|
falling back to its album's artwork — see docs/architecture.md
|
||||||
|
(probably not the whole "images are hard to find" story — flagged
|
||||||
|
there)
|
||||||
|
- [x] Fixed the music bar's mobile now-playing screen not fitting a
|
||||||
|
phone rotated to landscape — the art now sits beside the title
|
||||||
|
instead of above it, sized off the viewport height instead of a
|
||||||
|
fixed width-based cap — 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)
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,14 @@ export function joinParty(partyId: string): Promise<PartyPublicState> {
|
||||||
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends this device's own deviceId so the server can tell a stale
|
||||||
|
* device (already replaced by a takeover on another one - see
|
||||||
|
* getDeviceId's own comment) apart from the currently-active one, the
|
||||||
|
* same way the socket "join"/"leave"/"disconnect" handlers already do
|
||||||
|
* via the live connection itself - a REST call has no connection to
|
||||||
|
* compare, so this is what lets the server apply that same guard here. */
|
||||||
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
||||||
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST', body: JSON.stringify({ deviceId: getDeviceId() }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParty(partyId: string): Promise<PartyPublicState> {
|
export function getParty(partyId: string): Promise<PartyPublicState> {
|
||||||
|
|
@ -144,6 +150,9 @@ export interface PartyConnection {
|
||||||
sendPlaystate(isPaused: boolean, positionTicks: number): void;
|
sendPlaystate(isPaused: boolean, positionTicks: number): void;
|
||||||
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
||||||
next(): void;
|
next(): void;
|
||||||
|
/** Host-only: the last queued item played to its end - lets the server
|
||||||
|
* count it as watched (there's no "next" to send for it). */
|
||||||
|
itemEnded(): void;
|
||||||
/** Asks the host (and tells everyone else) "please pause". Video only -
|
/** Asks the host (and tells everyone else) "please pause". Video only -
|
||||||
* a music party has no host to ask, see voteSkip/setMusicPaused. */
|
* a music party has no host to ask, see voteSkip/setMusicPaused. */
|
||||||
requestPause(): void;
|
requestPause(): void;
|
||||||
|
|
@ -224,6 +233,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
||||||
next() {
|
next() {
|
||||||
socket.emit('next');
|
socket.emit('next');
|
||||||
},
|
},
|
||||||
|
itemEnded() {
|
||||||
|
socket.emit('itemEnded');
|
||||||
|
},
|
||||||
requestPause() {
|
requestPause() {
|
||||||
socket.emit('requestPause');
|
socket.emit('requestPause');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
74
client/src/api/staticParty.ts
Normal file
74
client/src/api/staticParty.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Static (persistent) watch parties - REST only, same origin, session
|
||||||
|
// cookie auth (see embyApi.ts). A static party's live sessions are
|
||||||
|
// ordinary parties: once one of these calls returns a partyId, everything
|
||||||
|
// else goes through api/party.ts and the normal player, unchanged. See
|
||||||
|
// server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
|
||||||
|
import type { StaticPartyDetailDto, StaticPartySummaryDto } from '../types';
|
||||||
|
|
||||||
|
/** Same contract as api/party.ts's own helper: JSON in/out, the server's
|
||||||
|
* `error` message thrown as-is (they're written to be shown to users). */
|
||||||
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[staticParty] network error', path, err);
|
||||||
|
throw new Error('Serveur injoignable.');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
console.warn('[staticParty] request failed', path, res.status, body.error);
|
||||||
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every static watch party, with the viewer's own missed count. */
|
||||||
|
export async function listStaticParties(): Promise<StaticPartySummaryDto[]> {
|
||||||
|
const data = await apiFetch<{ staticParties: StaticPartySummaryDto[] }>('/api/static-parties');
|
||||||
|
return data.staticParties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates one from a series or a video playlist (`sourceId`). */
|
||||||
|
export function createStaticParty(sourceId: string, name?: string): Promise<StaticPartyDetailDto> {
|
||||||
|
return apiFetch('/api/static-parties', { method: 'POST', body: JSON.stringify({ sourceId, name }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything about one static watch party, from the viewer's side. */
|
||||||
|
export function getStaticParty(id: string): Promise<StaticPartyDetailDto> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Become a member. */
|
||||||
|
export function joinStaticParty(id: string): Promise<{ ok: true }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/join`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop being a member (drops the viewer's own progress). */
|
||||||
|
export function leaveStaticParty(id: string): Promise<{ ok: true }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/leave`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete it for everyone - creator only. */
|
||||||
|
export function deleteStaticParty(id: string): Promise<{ ok: true }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Marquer comme vu". */
|
||||||
|
export function markStaticItemWatched(id: string, itemId: string): Promise<{ ok: true }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/items/${encodeURIComponent(itemId)}/watched`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launches the group's next session, or joins the one running. */
|
||||||
|
export function startStaticSession(id: string): Promise<{ partyId: string }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/session`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catches up on `itemIds` - alone, or as a watch party others can join. */
|
||||||
|
export function startCatchUp(id: string, itemIds: string[], mode: 'solo' | 'party'): Promise<{ partyId: string }> {
|
||||||
|
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/catch-up`, { method: 'POST', body: JSON.stringify({ itemIds, mode }) });
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import { renderArtist } from './views/artist/Artist';
|
||||||
import { renderAlbum } from './views/album/Album';
|
import { renderAlbum } from './views/album/Album';
|
||||||
import { renderPlaylist } from './views/playlist/Playlist';
|
import { renderPlaylist } from './views/playlist/Playlist';
|
||||||
import { renderPartyPlayer } from './views/player/Player';
|
import { renderPartyPlayer } from './views/player/Player';
|
||||||
|
import { renderStaticParty } from './views/staticParty/StaticParty';
|
||||||
|
import { renderStaticParties } from './views/staticParties/StaticParties';
|
||||||
|
|
||||||
route('/', () => navigate('/home'));
|
route('/', () => navigate('/home'));
|
||||||
route('/login', renderLogin);
|
route('/login', renderLogin);
|
||||||
|
|
@ -20,6 +22,8 @@ route('/artist/:id', renderArtist);
|
||||||
route('/album/:id', renderAlbum);
|
route('/album/:id', renderAlbum);
|
||||||
route('/playlist/:id', renderPlaylist);
|
route('/playlist/:id', renderPlaylist);
|
||||||
route('/party/:id', renderPartyPlayer);
|
route('/party/:id', renderPartyPlayer);
|
||||||
|
route('/static', renderStaticParties);
|
||||||
|
route('/static/:id', renderStaticParty);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders either the bare login screen or the full shell (sidebar +
|
* Renders either the bare login screen or the full shell (sidebar +
|
||||||
|
|
|
||||||
123
client/src/mediaSession.ts
Normal file
123
client/src/mediaSession.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// System Media Transport Controls integration - Windows' own media-key
|
||||||
|
// popup/taskbar overlay (also macOS' Now Playing widget, Android's
|
||||||
|
// notification) - see docs/architecture.md. A thin wrapper around the
|
||||||
|
// standard Media Session API, not a new abstraction of its own: every
|
||||||
|
// function here is a one-line guard (`'mediaSession' in navigator`)
|
||||||
|
// around the exact browser call, since Firefox has never implemented
|
||||||
|
// this at all and this safely no-ops everywhere else it's missing too.
|
||||||
|
// Shared by views/player/Player.ts (video) and musicBar/MusicBar.ts
|
||||||
|
// (a listen party) - the two can never be active at once (starting/
|
||||||
|
// joining either already leaves the other first), so there's no risk
|
||||||
|
// of them fighting over this single global.
|
||||||
|
|
||||||
|
export interface MediaSessionActions {
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
/** Host-only for video (Player.ts checks isHost itself before acting -
|
||||||
|
* this just delivers the OS's own seek gesture), unrestricted for
|
||||||
|
* music (no host concept there). */
|
||||||
|
onSeek?: (seconds: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title/artist/album/art shown in the OS's own now-playing UI - artist
|
||||||
|
* and album are meaningless for a video (pass '' for both), matching
|
||||||
|
* how a movie has no equivalent fields in this app's own UI either. */
|
||||||
|
export function setMediaSessionMetadata(title: string, artist: string, album: string, artworkUrl: string): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
artwork: artworkUrl ? [{ src: artworkUrl, sizes: '512x512', type: 'image/jpeg' }] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaSessionPlaybackState(playing: boolean): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OS's own scrubber - harmless to skip (not every browser/OS
|
||||||
|
* combination renders one) but adds real functionality where it does.
|
||||||
|
* Chrome throws if `duration` isn't a positive finite number, or if
|
||||||
|
* `position` doesn't satisfy `0 <= position <= duration` - both
|
||||||
|
* momentarily true the instant an item/track changes but the ticker
|
||||||
|
* driving this hasn't caught up yet, so this is caught rather than
|
||||||
|
* guarded field-by-field (the next tick corrects it either way). */
|
||||||
|
export function setMediaSessionPositionState(duration: number, position: number): void {
|
||||||
|
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
|
||||||
|
if (!(duration > 0) || !isFinite(duration)) return;
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setPositionState({ duration, position: Math.max(0, Math.min(position, duration)), playbackRate: 1 });
|
||||||
|
} catch {
|
||||||
|
// See this function's own comment - a transient mismatch, not a real error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wired once per party lifetime (Player.ts's init(), MusicBar.ts's
|
||||||
|
* initMusicBar()) - the handlers themselves close over whatever local
|
||||||
|
* state they need (isHost, conn, ...) and read it fresh on every
|
||||||
|
* OS-triggered call, so this never needs re-wiring just because that
|
||||||
|
* state changed later (see Player.ts's own isHost, which does exactly
|
||||||
|
* that mid-session on a host handoff).
|
||||||
|
*/
|
||||||
|
export function setMediaSessionActionHandlers(actions: MediaSessionActions): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.setActionHandler('play', actions.onPlay);
|
||||||
|
navigator.mediaSession.setActionHandler('pause', actions.onPause);
|
||||||
|
if (actions.onSeek) {
|
||||||
|
const onSeek = actions.onSeek;
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||||
|
if (details.seekTime != null) onSeek(details.seekTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows/hides the OS's own "next" control - called whenever this app's
|
||||||
|
* own equivalent (Player.ts's #nextBtn, a queue advance in general)
|
||||||
|
* would also change visibility. A *registered* handler is what makes
|
||||||
|
* Windows show the button at all, regardless of whether clicking it
|
||||||
|
* would currently do anything - so this has to track "is there
|
||||||
|
* actually a next" the same way the in-page button does, not just be
|
||||||
|
* set once and left there. */
|
||||||
|
export function setMediaSessionNextAction(onNext: (() => void) | null): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', onNext);
|
||||||
|
} catch {
|
||||||
|
// An unrecognized action name on an older browser - safe to skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the OS's own now-playing card without touching the play/pause/
|
||||||
|
* seek handlers underneath it - for "still in the party, just nothing
|
||||||
|
* queued right now" (MusicBar.ts's own `!music.current`), where those
|
||||||
|
* handlers stay meaningful (playback can resume the moment a track
|
||||||
|
* does) and so shouldn't need re-wiring the instant one does. `null`,
|
||||||
|
* not an empty MediaMetadata - that's the documented way to tell the
|
||||||
|
* OS there's genuinely nothing loaded, not a track with a blank title. */
|
||||||
|
export function setMediaSessionIdle(): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.metadata = null;
|
||||||
|
navigator.mediaSession.playbackState = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when leaving a party/party page entirely - an OS-level "now
|
||||||
|
* playing" card that outlives the actual party would be actively
|
||||||
|
* wrong, not just stale, so this clears every piece the functions
|
||||||
|
* above ever set (not just the now-playing card - see
|
||||||
|
* setMediaSessionIdle above for that alone) rather than leaving it to
|
||||||
|
* be silently overwritten by whatever starts next. */
|
||||||
|
export function clearMediaSession(): void {
|
||||||
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
setMediaSessionIdle();
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler('play', null);
|
||||||
|
navigator.mediaSession.setActionHandler('pause', null);
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', null);
|
||||||
|
} catch {
|
||||||
|
// Same tolerance as setMediaSessionNextAction above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -388,6 +388,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rotated: the block above stacks the art on top of everything else and
|
||||||
|
sizes it off the viewport's WIDTH (60vw) - reported directly as
|
||||||
|
overflowing/needing to scroll once width and height swap roles in
|
||||||
|
landscape, where width is the one dimension this screen never lacks.
|
||||||
|
Same fixed-overlay itself, just laid out differently - the art moves
|
||||||
|
beside the title/artist instead of above them (freeing most of the
|
||||||
|
vertical stack this screen otherwise spends on it), and every size
|
||||||
|
below is capped by `vh`, the scarce dimension here, not `vw`. Button
|
||||||
|
sizes are deliberately left alone (56px .musicBarPlayBig, the same
|
||||||
|
above) - a touch target sizes to a finger, not the screen, in any
|
||||||
|
orientation. */
|
||||||
|
@media (max-width: 720px) and (orientation: landscape) {
|
||||||
|
.musicBarPanel {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelCloseBtn { margin-bottom: 6px; }
|
||||||
|
|
||||||
|
.musicBarPanelTrack {
|
||||||
|
flex-direction: row;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicBarPanelTrack .musicBarImage {
|
||||||
|
width: min(120px, 20vh);
|
||||||
|
height: min(120px, 20vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicBarInfo { align-items: flex-start; }
|
||||||
|
|
||||||
|
.musicBarPanelTransport { margin-bottom: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* One queue entry - a plain flex row (image, then a shrinking/truncating
|
/* One queue entry - a plain flex row (image, then a shrinking/truncating
|
||||||
info column, then the duration), NOT views/album's shared .trackRow
|
info column, then the duration), NOT views/album's shared .trackRow
|
||||||
grid (styles/trackList.css) - that one's 4 fixed columns assume
|
grid (styles/trackList.css) - that one's 4 fixed columns assume
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { showToast } from '../toast';
|
||||||
import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types';
|
import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types';
|
||||||
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast';
|
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast';
|
||||||
import type { PlaybackDriver } from '../cast';
|
import type { PlaybackDriver } from '../cast';
|
||||||
|
import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionIdle, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../mediaSession';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts
|
* Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts
|
||||||
|
|
@ -162,14 +163,18 @@ export function initMusicBar(root: HTMLElement): void {
|
||||||
// as Player.ts's progressBar.
|
// as Player.ts's progressBar.
|
||||||
let isDraggingProgress = false;
|
let isDraggingProgress = false;
|
||||||
|
|
||||||
|
function seekTo(seconds: number): void {
|
||||||
|
if (currentDurationSeconds <= 0) return;
|
||||||
|
activeDriver!.currentTime = Math.min(Math.max(0, seconds), currentDurationSeconds);
|
||||||
|
updatePositionDisplay();
|
||||||
|
conn?.seekMusic(activeDriver!.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
function seekFromPointerEvent(e: PointerEvent): void {
|
function seekFromPointerEvent(e: PointerEvent): void {
|
||||||
if (currentDurationSeconds <= 0) return;
|
if (currentDurationSeconds <= 0) return;
|
||||||
const rect = trackProgress.getBoundingClientRect();
|
const rect = trackProgress.getBoundingClientRect();
|
||||||
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
const seconds = fraction * currentDurationSeconds;
|
seekTo(fraction * currentDurationSeconds);
|
||||||
activeDriver!.currentTime = seconds;
|
|
||||||
updatePositionDisplay();
|
|
||||||
conn?.seekMusic(seconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trackProgress.addEventListener('pointerdown', (e) => {
|
trackProgress.addEventListener('pointerdown', (e) => {
|
||||||
|
|
@ -184,6 +189,15 @@ export function initMusicBar(root: HTMLElement): void {
|
||||||
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
|
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
|
||||||
trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; });
|
trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; });
|
||||||
|
|
||||||
|
// Wired once, like Player.ts's own equivalent - no host concept here
|
||||||
|
// (see activeDriver's own comment) so, unlike there, play/pause never
|
||||||
|
// need to branch into a request-instead-of-act path.
|
||||||
|
setMediaSessionActionHandlers({
|
||||||
|
onPlay: () => conn?.setMusicPaused(false),
|
||||||
|
onPause: () => conn?.setMusicPaused(true),
|
||||||
|
onSeek: seekTo
|
||||||
|
});
|
||||||
|
|
||||||
// Visible to any member the moment a Chromecast is on the network - not
|
// Visible to any member the moment a Chromecast is on the network - not
|
||||||
// gated on anything party-related, same as Player.ts's own castBtn
|
// gated on anything party-related, same as Player.ts's own castBtn
|
||||||
// (see its comment: casting is available to any member, and this bar
|
// (see its comment: casting is available to any member, and this bar
|
||||||
|
|
@ -219,6 +233,7 @@ function updatePositionDisplay(): void {
|
||||||
// than its own pointer-seek wiring: dragging is one tap (expand the
|
// than its own pointer-seek wiring: dragging is one tap (expand the
|
||||||
// panel) away, see #mobileMiniBar's own click handler.
|
// panel) away, see #mobileMiniBar's own click handler.
|
||||||
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent;
|
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent;
|
||||||
|
setMediaSessionPositionState(currentDurationSeconds, currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -251,7 +266,24 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void {
|
||||||
else leaveMusicParty();
|
else leaveMusicParty();
|
||||||
},
|
},
|
||||||
onError: (message) => showToast(message, { variant: 'error' }),
|
onError: (message) => showToast(message, { variant: 'error' }),
|
||||||
onClose: () => {}
|
// Fires for every disconnect, intentional or not - leaveMusicParty
|
||||||
|
// itself already clears activePartyId synchronously, well before its
|
||||||
|
// own conn.leave() actually closes the socket, so seeing it still set
|
||||||
|
// here means this device's connection died some other way (most
|
||||||
|
// often: replaced by a takeover on another device - see
|
||||||
|
// SocketGateway's "join" handler). Without this, the bar just sat
|
||||||
|
// there looking/sounding fully live forever on the old device, on a
|
||||||
|
// conn that was actually already dead underneath it - reported
|
||||||
|
// directly as "both devices end up on the session, controls never
|
||||||
|
// transfer." leaveMusicParty() is safe to call here even though the
|
||||||
|
// party already moved on: leaveParty's own deviceId check (see
|
||||||
|
// api/party.ts) makes its REST call a no-op for a stale device
|
||||||
|
// instead of ending the CURRENT device's membership by mistake.
|
||||||
|
onClose: () => {
|
||||||
|
if (!activePartyId) return;
|
||||||
|
showToast('Connexion à la party interrompue.', { variant: 'error' });
|
||||||
|
leaveMusicParty();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,6 +315,7 @@ export function leaveMusicParty(): void {
|
||||||
currentItemId = null;
|
currentItemId = null;
|
||||||
lastMusicState = null;
|
lastMusicState = null;
|
||||||
latestMembers = [];
|
latestMembers = [];
|
||||||
|
clearMediaSession();
|
||||||
|
|
||||||
conn?.leave();
|
conn?.leave();
|
||||||
conn = null;
|
conn = null;
|
||||||
|
|
@ -336,6 +369,7 @@ function setPlayIcon(isPlaying: boolean): void {
|
||||||
const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG;
|
const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG;
|
||||||
(barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
(barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
||||||
(barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
(barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
||||||
|
setMediaSessionPlaybackState(isPlaying);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same pairing as setPlayIcon above, for the track metadata shown in
|
/** Same pairing as setPlayIcon above, for the track metadata shown in
|
||||||
|
|
@ -429,6 +463,10 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
|
||||||
currentItemId = track.itemId;
|
currentItemId = track.itemId;
|
||||||
currentDurationSeconds = track.durationSeconds || 0;
|
currentDurationSeconds = track.durationSeconds || 0;
|
||||||
setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - '));
|
setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - '));
|
||||||
|
// Artist/album kept separate here, unlike setTrackMeta's own combined
|
||||||
|
// display line above - the OS's own now-playing UI has real fields for
|
||||||
|
// both, no need to pre-join them into one string for it.
|
||||||
|
setMediaSessionMetadata(track.itemName || '', track.artistName || '', track.albumName || '', track.itemImageUrl || '');
|
||||||
|
|
||||||
if (isCasting) {
|
if (isCasting) {
|
||||||
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
|
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
|
||||||
|
|
@ -538,6 +576,11 @@ function applyMusicState(music: MusicPublicState): void {
|
||||||
// "disable, don't just silently no-op" treatment as playPauseBtn above.
|
// "disable, don't just silently no-op" treatment as playPauseBtn above.
|
||||||
castBtn.disabled = !music.current && !isCasting;
|
castBtn.disabled = !music.current && !isCasting;
|
||||||
updateVoteSkipLabel();
|
updateVoteSkipLabel();
|
||||||
|
// A *registered* nexttrack handler is what makes the OS show the
|
||||||
|
// control at all (see setMediaSessionNextAction's own comment) - same
|
||||||
|
// "disable, don't just silently no-op" case as playPauseBtn/castBtn
|
||||||
|
// above, just with no disabled state of its own to set instead.
|
||||||
|
setMediaSessionNextAction(music.current ? () => conn?.voteSkip() : null);
|
||||||
|
|
||||||
// Its own .queueRow, NOT views/album's shared .trackRow (see
|
// Its own .queueRow, NOT views/album's shared .trackRow (see
|
||||||
// styles/trackList.css) - that one is a CSS grid whose 4 columns
|
// styles/trackList.css) - that one is a CSS grid whose 4 columns
|
||||||
|
|
@ -561,6 +604,7 @@ function applyMusicState(music: MusicPublicState): void {
|
||||||
if (!music.current) {
|
if (!music.current) {
|
||||||
currentItemId = null;
|
currentItemId = null;
|
||||||
setTrackMeta('', 'Rien ne joue pour le moment.', '');
|
setTrackMeta('', 'Rien ne joue pour le moment.', '');
|
||||||
|
setMediaSessionIdle();
|
||||||
if (hls) { hls.destroy(); hls = null; }
|
if (hls) { hls.destroy(); hls = null; }
|
||||||
activeDriver!.pause();
|
activeDriver!.pause();
|
||||||
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting
|
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting
|
||||||
|
|
|
||||||
46
client/src/pwaInstall.css
Normal file
46
client/src/pwaInstall.css
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ---- Mobile-only "install this as an app" banner - see pwaInstall.ts.
|
||||||
|
A normal-flow element at the very top of <body>, before #app, not a
|
||||||
|
fixed/sticky overlay - pushes the app down by its own height instead
|
||||||
|
of needing to coordinate an offset with shell/Shell.css's own
|
||||||
|
.mobileTopBar/.libraryHeader (already stacked on top of each other at
|
||||||
|
the same breakpoint) or alphaIndex.css's rail. Scrolls away with the
|
||||||
|
page like any other content above the sticky chrome, same as a
|
||||||
|
browser's own native install infobar would. ---- */
|
||||||
|
.pwaInstallBanner {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwaInstallText { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.pwaInstallBtn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwaInstallBtn:hover { background: rgba(255, 255, 255, .85); }
|
||||||
|
|
||||||
|
.pwaInstallClose {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwaInstallClose:hover { background: none; opacity: .8; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pwaInstallBanner { display: flex; }
|
||||||
|
}
|
||||||
108
client/src/pwaInstall.ts
Normal file
108
client/src/pwaInstall.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Mobile-only "install this as an app" nudge - see docs/architecture.md.
|
||||||
|
// Self-mounting like toast.ts (no per-route/per-party state to attach),
|
||||||
|
// but the beforeinstallprompt listener below is attached the moment
|
||||||
|
// this module is first imported - app.ts does that at the very top,
|
||||||
|
// well before boot()'s async login check - rather than lazily on first
|
||||||
|
// use like toast.ts: the browser only ever fires this event once per
|
||||||
|
// page load, often before any UI exists to react to it, and there's no
|
||||||
|
// way to ask for a replay if a listener attaches too late to catch it.
|
||||||
|
import './pwaInstall.css';
|
||||||
|
|
||||||
|
/** Chrome/Edge/Android-WebView's own install-prompt event - not in
|
||||||
|
* lib.dom.d.ts (Safari/Firefox never implement it at all). */
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'watchparty:pwaInstallDismissed';
|
||||||
|
const MOBILE_QUERY = '(max-width: 720px)';
|
||||||
|
|
||||||
|
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||||
|
let bannerEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault(); // suppress the browser's own mini-infobar - this banner replaces it
|
||||||
|
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||||
|
renderIfEligible();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
deferredPrompt = null;
|
||||||
|
removeBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isStandalone(): boolean {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|| (navigator as unknown as { standalone?: boolean }).standalone === true; // iOS Safari's own flag - no display-mode query there
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No feature-detection exists for "is this iOS Safari" - beforeinstallprompt
|
||||||
|
* simply never fires there, which is the actual fact this module cares
|
||||||
|
* about, but by the time that's observable it's too late to show
|
||||||
|
* install instructions instead. Checked only as a fallback once
|
||||||
|
* deferredPrompt has had a real chance to arrive - see renderIfEligible. */
|
||||||
|
function isIOS(): boolean {
|
||||||
|
return /iphone|ipad|ipod/i.test(navigator.userAgent)
|
||||||
|
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ reports as a Mac
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBanner(): void {
|
||||||
|
bannerEl?.remove();
|
||||||
|
bannerEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, '1');
|
||||||
|
removeBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(): Promise<void> {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
const prompt = deferredPrompt;
|
||||||
|
deferredPrompt = null; // a captured prompt can only ever be used once
|
||||||
|
await prompt.prompt();
|
||||||
|
const { outcome } = await prompt.userChoice;
|
||||||
|
// 'dismissed' here means the user closed the browser's own native
|
||||||
|
// picker without installing - not the same signal as our own close
|
||||||
|
// button (dismiss() above), so this banner stays up rather than
|
||||||
|
// remembering "never ask again" over a single not-right-now.
|
||||||
|
if (outcome === 'accepted') removeBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIfEligible(): void {
|
||||||
|
if (bannerEl) return;
|
||||||
|
if (isStandalone()) return;
|
||||||
|
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||||
|
if (!window.matchMedia(MOBILE_QUERY).matches) return;
|
||||||
|
|
||||||
|
const ios = isIOS();
|
||||||
|
if (!deferredPrompt && !ios) return; // no install path this module actually knows how to help with
|
||||||
|
|
||||||
|
bannerEl = document.createElement('div');
|
||||||
|
bannerEl.className = 'pwaInstallBanner';
|
||||||
|
bannerEl.innerHTML = ios
|
||||||
|
? `<span class="pwaInstallText">Installe Watch Party : appuie sur <strong>Partager</strong> puis « Sur l'écran d'accueil ».</span>
|
||||||
|
<button type="button" class="pwaInstallClose" aria-label="Fermer">×</button>`
|
||||||
|
: `<span class="pwaInstallText">Installe Watch Party sur ton écran d'accueil pour y accéder plus vite.</span>
|
||||||
|
<button type="button" class="pwaInstallBtn">Installer</button>
|
||||||
|
<button type="button" class="pwaInstallClose" aria-label="Fermer">×</button>`;
|
||||||
|
|
||||||
|
bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss);
|
||||||
|
bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); });
|
||||||
|
|
||||||
|
document.body.insertBefore(bannerEl, document.body.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by Shell.ts after a successful login - covers both the
|
||||||
|
* case where beforeinstallprompt already fired before Shell existed to
|
||||||
|
* react to it (deferredPrompt sitting ready, banner never actually
|
||||||
|
* shown yet) and iOS, which has no event at all to trigger this from.
|
||||||
|
* Also re-checked on resize: crossing the 720px breakpoint live (an
|
||||||
|
* unfolding or rotated device) shouldn't need a reload to notice.
|
||||||
|
*/
|
||||||
|
export function initPwaInstallPrompt(): void {
|
||||||
|
renderIfEligible();
|
||||||
|
window.addEventListener('resize', renderIfEligible);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<div class="sidebarBrand">Watch Party</div>
|
<div class="sidebarBrand">Watch Party</div>
|
||||||
<nav class="sidebarNav" id="sidebarNav">
|
<nav class="sidebarNav" id="sidebarNav">
|
||||||
<a href="#/home" class="navItem">Accueil</a>
|
<a href="#/home" class="navItem">Accueil</a>
|
||||||
|
<a href="#/static" class="navItem">Lounge parties</a>
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
plain scrim tap-only close used to leave unsolved). -->
|
plain scrim tap-only close used to leave unsolved). -->
|
||||||
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
|
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
|
||||||
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
|
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
|
||||||
|
<a href="#/static" class="navItem sheetPrimary"><span class="navIcon" data-icon="static"></span>Lounge parties</a>
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
<div class="sheetGrid" id="mobileLibGrid"></div>
|
<div class="sheetGrid" id="mobileLibGrid"></div>
|
||||||
<div id="mobilePlaylistsSlot"></div>
|
<div id="mobilePlaylistsSlot"></div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import './Shell.css';
|
||||||
import html from './Shell.html';
|
import html from './Shell.html';
|
||||||
import { auth, getLibraries, logout } from '../api/embyApi';
|
import { auth, getLibraries, logout } from '../api/embyApi';
|
||||||
import { initMusicBar } from '../musicBar/MusicBar';
|
import { initMusicBar } from '../musicBar/MusicBar';
|
||||||
|
import { initPwaInstallPrompt } from '../pwaInstall';
|
||||||
import type { LibraryDto } from '../types';
|
import type { LibraryDto } from '../types';
|
||||||
|
|
||||||
// One icon per Emby CollectionType - only used by the mobile bottom
|
// One icon per Emby CollectionType - only used by the mobile bottom
|
||||||
|
|
@ -17,6 +18,8 @@ const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18"
|
||||||
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
|
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
|
||||||
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
|
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
|
||||||
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||||
|
// The "Lounge parties" tab (static watch parties) - a circular "resume" arrow.
|
||||||
|
const STATIC_PARTIES_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 9l5 3-5 3Z" fill="currentColor"/></svg>';
|
||||||
// Emby could in principle add a CollectionType this app doesn't map yet
|
// Emby could in principle add a CollectionType this app doesn't map yet
|
||||||
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
|
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
|
||||||
// would need updating first either way) - falls back to a plain folder
|
// would need updating first either way) - falls back to a plain folder
|
||||||
|
|
@ -52,10 +55,12 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||||
appEl.innerHTML = html;
|
appEl.innerHTML = html;
|
||||||
|
|
||||||
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
|
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
|
||||||
|
appEl.querySelector('[data-icon="static"]')!.innerHTML = STATIC_PARTIES_ICON_SVG;
|
||||||
|
|
||||||
// Mounted once, outside #mainContent, so a listen party survives
|
// Mounted once, outside #mainContent, so a listen party survives
|
||||||
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
||||||
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
|
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
|
||||||
|
initPwaInstallPrompt();
|
||||||
|
|
||||||
// Both the sidebar's and the mobile sheet's logout button - same
|
// Both the sidebar's and the mobile sheet's logout button - same
|
||||||
// label, same handler, one line each instead of a shared DOM node
|
// label, same handler, one line each instead of a shared DOM node
|
||||||
|
|
@ -96,8 +101,11 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||||
|
|
||||||
const highlight = () => {
|
const highlight = () => {
|
||||||
appEl.querySelectorAll('.navItem').forEach((a) => {
|
appEl.querySelectorAll('.navItem').forEach((a) => {
|
||||||
const isHome = a.getAttribute('href') === '#/home';
|
const href = a.getAttribute('href');
|
||||||
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
|
const isHome = href === '#/home';
|
||||||
|
// The static-parties tab stays highlighted on one party's own page too.
|
||||||
|
const isStaticSection = href === '#/static' && location.hash.startsWith('#/static/');
|
||||||
|
a.classList.toggle('active', href === location.hash || (isHome && !location.hash) || isStaticSection);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
highlight();
|
highlight();
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,3 @@
|
||||||
|
|
||||||
.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; }
|
.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; }
|
||||||
.trackRow button.trackIconBtn svg { width: 12px; height: 12px; }
|
.trackRow button.trackIconBtn svg { width: 12px; height: 12px; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.trackRow { grid-template-columns: 24px 1fr; }
|
|
||||||
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
|
||||||
function posterTileHtml(item: ItemDto): string {
|
function posterTileHtml(item: ItemDto): string {
|
||||||
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
|
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
|
||||||
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
||||||
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
|
// Video -> "watch party", audio -> "listen party" - see the party kinds
|
||||||
|
// in docs/architecture.md (the third, "lounge party", is the static one).
|
||||||
|
const partyLabel = item.type === 'Audio' ? 'Lancer une listen party' : 'Lancer une watch party';
|
||||||
return `
|
return `
|
||||||
<div class="tile">
|
<div class="tile">
|
||||||
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
||||||
|
|
@ -116,7 +118,7 @@ export function tileHtml(item: ItemDto): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires up every "Lancer une watch party" button inside `container`, now
|
* Wires up every "Lancer une watch/listen party" button inside `container`, now
|
||||||
* and for any tile added to it later. `onStart(itemId)` does the actual
|
* and for any tile added to it later. `onStart(itemId)` does the actual
|
||||||
* work (create the party + navigate) - kept as a caller-supplied callback
|
* work (create the party + navigate) - kept as a caller-supplied callback
|
||||||
* so this module doesn't need to depend on api/party.ts/router.ts itself.
|
* so this module doesn't need to depend on api/party.ts/router.ts itself.
|
||||||
|
|
@ -132,6 +134,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Démarrage...';
|
btn.textContent = 'Démarrage...';
|
||||||
try {
|
try {
|
||||||
|
|
@ -139,7 +142,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast((err as Error).message, { variant: 'error' });
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Lancer une watch party';
|
btn.textContent = idleLabel;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,42 @@ export interface LobbyPartyDto {
|
||||||
positionTicks: number | null;
|
positionTicks: number | null;
|
||||||
isPaused: boolean | null;
|
isPaused: boolean | null;
|
||||||
positionUpdatedUtc: number | null;
|
positionUpdatedUtc: number | null;
|
||||||
|
/** The static watch party this live session belongs to, if any. */
|
||||||
|
staticPartyId: string | null;
|
||||||
|
staticPartyName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors server/src/types.ts's StaticPartySummaryDto - one static
|
||||||
|
* (persistent) watch party, as the home page lists it. */
|
||||||
|
export interface StaticPartySummaryDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourceType: 'series' | 'season' | 'playlist';
|
||||||
|
imageUrl: string;
|
||||||
|
itemCount: number;
|
||||||
|
groupWatchedCount: number;
|
||||||
|
nextItemName: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
youAreMember: boolean;
|
||||||
|
youAreCreator: boolean;
|
||||||
|
missedCount: number;
|
||||||
|
livePartyId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One item of a static watch party - mirrors StaticPartyItemDto. */
|
||||||
|
export interface StaticPartyItemDto {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemImageUrl: string;
|
||||||
|
groupState: 'watched' | 'next' | 'upcoming';
|
||||||
|
viewerWatched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors server/src/types.ts's StaticPartyDetailDto. */
|
||||||
|
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
|
||||||
|
items: StaticPartyItemDto[];
|
||||||
|
missedItemIds: string[];
|
||||||
|
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemChangedMsg {
|
export interface ItemChangedMsg {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
// states) - the label lives in aria-label/title for accessibility
|
// states) - the label lives in aria-label/title for accessibility
|
||||||
// rather than as visible text, now that there's one per track instead
|
// rather than as visible text, now that there's one per track instead
|
||||||
// of one per page (see docs/architecture.md's "Music UI rework").
|
// of one per page (see docs/architecture.md's "Music UI rework").
|
||||||
const startLabel = 'Écouter ensemble';
|
const startLabel = 'Lancer une listen party';
|
||||||
const addLabel = 'Ajouter à la file';
|
const addLabel = 'Ajouter à la file';
|
||||||
const startAlbumLabel = 'Écouter cet album';
|
const startAlbumLabel = 'Écouter cet album';
|
||||||
const addAlbumLabel = "Ajouter l'album à la file";
|
const addAlbumLabel = "Ajouter l'album à la file";
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,18 @@
|
||||||
color: var(--accent-bright);
|
color: var(--accent-bright);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Which kind of party a card is - "Watch party" (video) or "Listen party"
|
||||||
|
(audio). Lounge parties (static) have their own tab, never this list. */
|
||||||
|
.partyKind {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); }
|
||||||
|
.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section" id="partiesSection" hidden>
|
<section class="section" id="partiesSection" hidden>
|
||||||
<h2>Watch parties en cours</h2>
|
<h2>Parties en cours</h2>
|
||||||
<div id="partiesCarousel" class="carousel"></div>
|
<div id="partiesCarousel" class="carousel"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ export function renderHome(root: HTMLElement): () => void {
|
||||||
// label is the one piece that ticks purely client-side (from
|
// label is the one piece that ticks purely client-side (from
|
||||||
// createdUtc) - no point re-pushing from the server once a second just
|
// createdUtc) - no point re-pushing from the server once a second just
|
||||||
// for a clock.
|
// for a clock.
|
||||||
const lobby = connectLobby((parties) => renderParties(root, parties));
|
// A static watch party's sessions live in their own tab
|
||||||
|
// (views/staticParties), not here - this list is classic parties only.
|
||||||
|
const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId)));
|
||||||
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
||||||
|
|
||||||
loadRecentSections(root);
|
loadRecentSections(root);
|
||||||
|
|
@ -57,6 +59,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
<div class="partyCard">
|
<div class="partyCard">
|
||||||
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||||
<div class="partyCardInfo">
|
<div class="partyCardInfo">
|
||||||
|
<span class="partyKind partyKind--${p.mediaType}">${p.mediaType === 'music' ? 'Listen party' : 'Watch party'}</span>
|
||||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequested
|
||||||
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
||||||
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
|
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
|
||||||
import type { PlaybackDriver } from '../../cast';
|
import type { PlaybackDriver } from '../../cast';
|
||||||
|
import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../../mediaSession';
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
||||||
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
||||||
|
|
@ -228,6 +229,14 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
|
|
||||||
let isHost = false;
|
let isHost = false;
|
||||||
let hasNextQueued = false;
|
let hasNextQueued = false;
|
||||||
|
// Set at the top of cleanup() below, which every intentional way of
|
||||||
|
// leaving this route (the "Quitter" button, a sidebar link, browser
|
||||||
|
// back, ...) already runs synchronously before the socket actually
|
||||||
|
// closes - so by the time the async "disconnect" this connection's
|
||||||
|
// own onClose reacts to actually arrives, this is already true for
|
||||||
|
// every one of those. Still false there only means an unprompted
|
||||||
|
// close (most often: a device takeover elsewhere - see onClose below).
|
||||||
|
let leftIntentionally = false;
|
||||||
let conn: PartyConnection | null = null;
|
let conn: PartyConnection | null = null;
|
||||||
let hls: HlsInstance | null = null;
|
let hls: HlsInstance | null = null;
|
||||||
let currentItemId: string | null = null;
|
let currentItemId: string | null = null;
|
||||||
|
|
@ -414,6 +423,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
* onDriverStateChanged below. */
|
* onDriverStateChanged below. */
|
||||||
function updatePlayPauseBtn() {
|
function updatePlayPauseBtn() {
|
||||||
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
|
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
|
||||||
|
setMediaSessionPlaybackState(!activeDriver.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #muteBtn - deliberately reads/writes `video.muted` directly, never
|
/** #muteBtn - deliberately reads/writes `video.muted` directly, never
|
||||||
|
|
@ -437,6 +447,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
const current = activeDriver.currentTime;
|
const current = activeDriver.currentTime;
|
||||||
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
|
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
|
||||||
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
||||||
|
setMediaSessionPositionState(duration, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -517,13 +528,23 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
|
|
||||||
let isDraggingProgress = false;
|
let isDraggingProgress = false;
|
||||||
|
|
||||||
|
/** Host-only in effect (both call sites already gate on isHost before
|
||||||
|
* reaching here - the progress bar's own pointerdown handler below,
|
||||||
|
* and the Media Session seekto action) - this itself just clamps and
|
||||||
|
* applies, same shape as MusicBar.ts's own seekTo. */
|
||||||
|
function seekTo(seconds: number) {
|
||||||
|
const duration = activeDriver.duration;
|
||||||
|
if (!duration) return;
|
||||||
|
activeDriver.currentTime = Math.min(Math.max(0, seconds), duration);
|
||||||
|
onDriverStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
function seekFromPointerEvent(e: PointerEvent) {
|
function seekFromPointerEvent(e: PointerEvent) {
|
||||||
const duration = activeDriver.duration;
|
const duration = activeDriver.duration;
|
||||||
if (!duration) return;
|
if (!duration) return;
|
||||||
const rect = progressBar.getBoundingClientRect();
|
const rect = progressBar.getBoundingClientRect();
|
||||||
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
activeDriver.currentTime = fraction * duration;
|
seekTo(fraction * duration);
|
||||||
onDriverStateChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host-only (checked here, not just via the readOnly class - that one
|
// Host-only (checked here, not just via the readOnly class - that one
|
||||||
|
|
@ -662,6 +683,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
settingsWrap.classList.remove('hide');
|
settingsWrap.classList.remove('hide');
|
||||||
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
|
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
|
||||||
nextBtn.classList.toggle('hide', !hasNextQueued);
|
nextBtn.classList.toggle('hide', !hasNextQueued);
|
||||||
|
setMediaSessionNextAction(hasNextQueued ? goToNextItem : null);
|
||||||
playPauseBtn.classList.remove('hide');
|
playPauseBtn.classList.remove('hide');
|
||||||
progressBar.classList.remove('readOnly');
|
progressBar.classList.remove('readOnly');
|
||||||
updateAmbientNotice();
|
updateAmbientNotice();
|
||||||
|
|
@ -669,7 +691,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
video.addEventListener('play', onDriverStateChanged);
|
video.addEventListener('play', onDriverStateChanged);
|
||||||
video.addEventListener('pause', onDriverStateChanged);
|
video.addEventListener('pause', onDriverStateChanged);
|
||||||
video.addEventListener('seeked', onDriverStateChanged);
|
video.addEventListener('seeked', onDriverStateChanged);
|
||||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
// The last item ending still counts as watched (Emby Played, a static
|
||||||
|
// watch party's progress) - "itemEnded" tells the server so, since
|
||||||
|
// there's no "next" to send for it.
|
||||||
|
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); else conn?.itemEnded(); });
|
||||||
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
||||||
|
|
||||||
// Burn-in (image) subtitle options are host-only (see
|
// Burn-in (image) subtitle options are host-only (see
|
||||||
|
|
@ -1038,8 +1063,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
|
|
||||||
async function onItemChanged(msg: ItemChangedMsg) {
|
async function onItemChanged(msg: ItemChangedMsg) {
|
||||||
itemTitleEl.textContent = msg.itemName || '';
|
itemTitleEl.textContent = msg.itemName || '';
|
||||||
|
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
|
||||||
hasNextQueued = !!msg.hasNext;
|
hasNextQueued = !!msg.hasNext;
|
||||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||||
|
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
|
||||||
// A new item's own stream indices/subtitle pick have nothing to do
|
// A new item's own stream indices/subtitle pick have nothing to do
|
||||||
// with the old one's - PartyService.advanceQueue already resets this
|
// with the old one's - PartyService.advanceQueue already resets this
|
||||||
// server-side too (see its own comment); this just keeps this client
|
// server-side too (see its own comment); this just keeps this client
|
||||||
|
|
@ -1187,22 +1214,42 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
// cooldown (mirroring the server's own independent per-request
|
// cooldown (mirroring the server's own independent per-request
|
||||||
// throttle in SocketGateway) purely so a rapid double-click doesn't
|
// throttle in SocketGateway) purely so a rapid double-click doesn't
|
||||||
// even bother emitting the second request.
|
// even bother emitting the second request.
|
||||||
requestPauseBtn.addEventListener('click', () => {
|
function requestPause() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now < pauseRequestCooldownUntil || !conn) return;
|
if (now < pauseRequestCooldownUntil || !conn) return;
|
||||||
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
|
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
|
||||||
conn.requestPause();
|
conn.requestPause();
|
||||||
});
|
}
|
||||||
|
|
||||||
requestResumeBtn.addEventListener('click', () => {
|
function requestResume() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now < resumeRequestCooldownUntil || !conn) return;
|
if (now < resumeRequestCooldownUntil || !conn) return;
|
||||||
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
|
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
|
||||||
conn.requestResume();
|
conn.requestResume();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
requestPauseBtn.addEventListener('click', requestPause);
|
||||||
|
requestResumeBtn.addEventListener('click', requestResume);
|
||||||
|
|
||||||
|
function goToNextItem() {
|
||||||
|
conn?.next();
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
itemTitleEl.textContent = party.itemName || '';
|
itemTitleEl.textContent = party.itemName || '';
|
||||||
|
setMediaSessionMetadata(party.itemName || '', '', '', `/api/catalog/image/${party.itemId}`);
|
||||||
|
// Play/pause/seek are wired exactly once - see setMediaSessionActionHandlers'
|
||||||
|
// own comment for why they never need re-wiring on a host handoff.
|
||||||
|
// "next" isn't included here: unlike play/pause (always meaningful,
|
||||||
|
// just routed differently for a follower - see requestPause/Resume
|
||||||
|
// above), a *registered* nexttrack handler is what makes Windows show
|
||||||
|
// the control at all, so it has to track hasNextQueued/isHost the
|
||||||
|
// same way #nextBtn's own visibility does - see becomeHost/onItemChanged.
|
||||||
|
setMediaSessionActionHandlers({
|
||||||
|
onPlay: () => { if (isHost) activeDriver.play(); else requestResume(); },
|
||||||
|
onPause: () => { if (isHost) activeDriver.pause(); else requestPause(); },
|
||||||
|
onSeek: (seconds) => { if (isHost) seekTo(seconds); }
|
||||||
|
});
|
||||||
hasNextQueued = !!party.hasNext;
|
hasNextQueued = !!party.hasNext;
|
||||||
updateAmbientNotice();
|
updateAmbientNotice();
|
||||||
requestToolbar.classList.toggle('hide', party.youAreHost);
|
requestToolbar.classList.toggle('hide', party.youAreHost);
|
||||||
|
|
@ -1216,7 +1263,20 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
|
|
||||||
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
|
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
|
||||||
await loadTrackOptions(party.itemId);
|
await loadTrackOptions(party.itemId);
|
||||||
loadVideo(party.itemId, 0, false);
|
// Unlike onItemChanged's own loadVideo(..., 0, ...) - genuinely correct
|
||||||
|
// there since a fresh item has no position yet - mounting mid-party
|
||||||
|
// (a joining guest, or the host's own page reload) must NOT hardcode 0:
|
||||||
|
// the party's shared Emby transcode (PartyStreamService) is keyed by
|
||||||
|
// partyId alone, one PlaySessionId for every member, so a hls.js
|
||||||
|
// instance requesting segments from 0 against a session that already
|
||||||
|
// progressed well past that forces Emby to seek/restart the shared
|
||||||
|
// transcode - stalling it for every other member, host included, right
|
||||||
|
// as the newcomer joins. Seeding from the host's last-reported position
|
||||||
|
// (already in party.members, sent to every mount) lands the first
|
||||||
|
// segment request near where the shared session already is instead.
|
||||||
|
const hostMember = party.members.find((m) => m.isHost);
|
||||||
|
const initialResumeSeconds = hostMember ? hostMember.positionTicks / TICKS_PER_SECOND : 0;
|
||||||
|
loadVideo(party.itemId, initialResumeSeconds, false);
|
||||||
|
|
||||||
conn = connectParty(partyId, {
|
conn = connectParty(partyId, {
|
||||||
onMembers: renderMembers,
|
onMembers: renderMembers,
|
||||||
|
|
@ -1241,11 +1301,27 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
else navigate('/home');
|
else navigate('/home');
|
||||||
},
|
},
|
||||||
onError: (message) => showToast(message, { variant: 'error' }),
|
onError: (message) => showToast(message, { variant: 'error' }),
|
||||||
onClose: () => {}
|
// Fires for every disconnect, intentional or not - leftIntentionally
|
||||||
|
// is already true by the time this runs for any of those (see its
|
||||||
|
// own comment), so still false here means an unprompted close, most
|
||||||
|
// often a device takeover elsewhere (SocketGateway's "join" handler)
|
||||||
|
// - without this, the video/hls.js instance just kept running
|
||||||
|
// forever on the old device, on a conn that was actually already
|
||||||
|
// dead underneath it, reported directly as "both devices end up on
|
||||||
|
// the session, controls never transfer." navigate() runs this same
|
||||||
|
// route's cleanup() below, which is now safe to do even though the
|
||||||
|
// party already moved on - leaveParty's own deviceId check (see
|
||||||
|
// api/party.ts) makes its REST call a no-op for a stale device
|
||||||
|
// instead of ending the CURRENT device's membership by mistake.
|
||||||
|
onClose: () => {
|
||||||
|
if (leftIntentionally) return;
|
||||||
|
showToast('Connexion à la party interrompue.', { variant: 'error' });
|
||||||
|
navigate('/home');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => { conn?.next(); });
|
nextBtn.addEventListener('click', goToNextItem);
|
||||||
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
@ -1255,11 +1331,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
// other way (sidebar link, browser back, ...). This is the fix for the
|
// other way (sidebar link, browser back, ...). This is the fix for the
|
||||||
// actual leak: previously, only the "Quitter" button ran any of this.
|
// actual leak: previously, only the "Quitter" button ran any of this.
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
|
leftIntentionally = true;
|
||||||
if (heartbeat) clearInterval(heartbeat);
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
if (progressTicker) clearInterval(progressTicker);
|
if (progressTicker) clearInterval(progressTicker);
|
||||||
if (requestBannerTimer) clearTimeout(requestBannerTimer);
|
if (requestBannerTimer) clearTimeout(requestBannerTimer);
|
||||||
clearControlsHideTimer();
|
clearControlsHideTimer();
|
||||||
if (hls) hls.destroy();
|
if (hls) hls.destroy();
|
||||||
|
clearMediaSession();
|
||||||
document.removeEventListener('click', onDocumentClick);
|
document.removeEventListener('click', onDocumentClick);
|
||||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||||
playerWrap.removeEventListener('pointermove', showControls);
|
playerWrap.removeEventListener('pointermove', showControls);
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@
|
||||||
|
|
||||||
.playlistActions {
|
.playlistActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 12px 16px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<p class="hint" id="playlistMeta"></p>
|
<p class="hint" id="playlistMeta"></p>
|
||||||
<div class="playlistActions">
|
<div class="playlistActions">
|
||||||
<button id="launchBtn">Lancer cette playlist</button>
|
<button id="launchBtn">Lancer cette playlist</button>
|
||||||
|
<button class="hide" id="createStaticBtn">Créer une lounge party</button>
|
||||||
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import './Playlist.css';
|
||||||
import html from './Playlist.html';
|
import html from './Playlist.html';
|
||||||
import { getItem, getPlaylistItems } from '../../api/embyApi';
|
import { getItem, getPlaylistItems } from '../../api/embyApi';
|
||||||
import { createParty } from '../../api/party';
|
import { createParty } from '../../api/party';
|
||||||
|
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
|
||||||
import { joinMusicParty } from '../../musicBar/MusicBar';
|
import { joinMusicParty } from '../../musicBar/MusicBar';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { RouteParams } from '../../router';
|
import type { RouteParams } from '../../router';
|
||||||
|
|
@ -56,6 +57,15 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video playlists only - a static watch party has no music equivalent
|
||||||
|
// (the server refuses one too). Same "first item decides" rule as
|
||||||
|
// CatalogService.splitPlaylistByMediaType.
|
||||||
|
if (items[0]!.type !== 'Audio') {
|
||||||
|
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
|
||||||
|
createStaticBtn.classList.remove('hide');
|
||||||
|
wireCreateStaticButton(createStaticBtn, () => playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
listEl.innerHTML = items.map((item, i) => {
|
listEl.innerHTML = items.map((item, i) => {
|
||||||
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
|
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
|
||||||
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : '';
|
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : '';
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,20 @@
|
||||||
|
|
||||||
.seriesPoster.noImage { visibility: hidden; }
|
.seriesPoster.noImage { visibility: hidden; }
|
||||||
|
|
||||||
|
.seriesActions,
|
||||||
|
.seasonActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The selected season's own launch buttons, between the tabs and its
|
||||||
|
episodes. */
|
||||||
|
.seasonActions { margin: 0 0 16px; }
|
||||||
|
.seasonActions button { padding: 8px 14px; font-size: 13px; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.seriesHeader { flex-direction: column; }
|
.seriesHeader { flex-direction: column; }
|
||||||
.seriesPoster { width: 120px; }
|
.seriesPoster { width: 120px; }
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,16 @@
|
||||||
<div class="seriesInfo">
|
<div class="seriesInfo">
|
||||||
<h1 id="seriesTitle"></h1>
|
<h1 id="seriesTitle"></h1>
|
||||||
<p class="seriesOverview hide" id="seriesOverview"></p>
|
<p class="seriesOverview hide" id="seriesOverview"></p>
|
||||||
|
<div class="seriesActions">
|
||||||
|
<button id="launchSeriesBtn">Lancer toute la série</button>
|
||||||
|
<button class="secondaryBtn" id="createStaticBtn">Créer une lounge party</button>
|
||||||
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav id="seasonTabs" class="tabs"></nav>
|
<nav id="seasonTabs" class="tabs"></nav>
|
||||||
|
<div class="seasonActions hide" id="seasonActions">
|
||||||
|
<button id="launchSeasonBtn">Lancer cette saison</button>
|
||||||
|
<button class="secondaryBtn" id="createSeasonStaticBtn">Lounge party de cette saison</button>
|
||||||
|
</div>
|
||||||
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>
|
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import './Series.css';
|
||||||
import html from './Series.html';
|
import html from './Series.html';
|
||||||
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
|
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
|
||||||
import { createParty } from '../../api/party';
|
import { createParty } from '../../api/party';
|
||||||
|
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { RouteParams } from '../../router';
|
import type { RouteParams } from '../../router';
|
||||||
import { escapeHtml } from '../../tiles';
|
import { escapeHtml } from '../../tiles';
|
||||||
|
|
@ -35,11 +36,25 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
|
||||||
}
|
}
|
||||||
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
|
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
|
||||||
|
|
||||||
|
// Whole series: a watch party over every season from S1E1, or a lounge
|
||||||
|
// party over the same (see the server's seriesQueueFrom).
|
||||||
|
wireLaunchButton(root.querySelector('#launchSeriesBtn') as HTMLButtonElement, () => seriesId);
|
||||||
|
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, () => seriesId);
|
||||||
|
|
||||||
if (!seasons.length) {
|
if (!seasons.length) {
|
||||||
|
(root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true;
|
||||||
|
(root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true;
|
||||||
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just the selected season - both buttons read whichever tab is active
|
||||||
|
// at click time, so they're wired once rather than per tab switch.
|
||||||
|
let selectedSeasonId = seasons[0]!.id;
|
||||||
|
root.querySelector('#seasonActions')!.classList.remove('hide');
|
||||||
|
wireLaunchButton(root.querySelector('#launchSeasonBtn') as HTMLButtonElement, () => selectedSeasonId);
|
||||||
|
wireCreateStaticButton(root.querySelector('#createSeasonStaticBtn') as HTMLButtonElement, () => selectedSeasonId);
|
||||||
|
|
||||||
const tabsEl = root.querySelector('#seasonTabs')!;
|
const tabsEl = root.querySelector('#seasonTabs')!;
|
||||||
tabsEl.innerHTML = seasons
|
tabsEl.innerHTML = seasons
|
||||||
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
||||||
|
|
@ -50,13 +65,35 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
|
||||||
if (tab.classList.contains('active')) return;
|
if (tab.classList.contains('active')) return;
|
||||||
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
|
selectedSeasonId = tab.dataset.seasonId as string;
|
||||||
|
loadEpisodes(root, seriesId, selectedSeasonId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadEpisodes(root, seriesId, seasons[0]!.id);
|
loadEpisodes(root, seriesId, seasons[0]!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button that launches a watch party over whatever `itemId()` returns
|
||||||
|
* at click time - the series (every season) or the selected season (see
|
||||||
|
* the server's POST /api/parties Series/Season branches) - then opens it.
|
||||||
|
*/
|
||||||
|
function wireLaunchButton(btn: HTMLButtonElement, itemId: () => string): void {
|
||||||
|
const idleLabel = btn.textContent || '';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Démarrage...';
|
||||||
|
createParty(itemId())
|
||||||
|
.then((party) => navigate(`/party/${party.partyId}`))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[series] launch failed', err);
|
||||||
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = idleLabel;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
|
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
|
||||||
const listEl = root.querySelector('#episodeList')!;
|
const listEl = root.querySelector('#episodeList')!;
|
||||||
listEl.innerHTML = LOADING_HINT;
|
listEl.innerHTML = LOADING_HINT;
|
||||||
|
|
|
||||||
81
client/src/views/staticParties/StaticParties.css
Normal file
81
client/src/views/staticParties/StaticParties.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/* ---- "Lounge parties" tab: one card per static party ---- */
|
||||||
|
|
||||||
|
.staticGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole card is the link to /static/:id. */
|
||||||
|
.staticCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCard:hover { background: #2b252e; }
|
||||||
|
|
||||||
|
.staticCard img {
|
||||||
|
width: 60px;
|
||||||
|
height: 90px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCard img.noImage { visibility: hidden; }
|
||||||
|
|
||||||
|
.staticCardInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.staticCardInfo .hint { margin: 0; }
|
||||||
|
|
||||||
|
.staticCardNext {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCardBar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCardBar > div { height: 100%; background: var(--accent); }
|
||||||
|
|
||||||
|
.staticCardLive {
|
||||||
|
color: var(--accent-bright);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCardMissed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.staticGrid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
9
client/src/views/staticParties/StaticParties.html
Normal file
9
client/src/views/staticParties/StaticParties.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h1>Lounge parties</h1>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Une série ou une playlist regardée en groupe sur plusieurs séances : la progression du groupe
|
||||||
|
et celle de chacun sont gardées en mémoire. Pour en créer une, ouvre une série ou une playlist
|
||||||
|
vidéo et choisis « Créer une lounge party ».
|
||||||
|
</p>
|
||||||
|
<div class="staticGrid" id="staticGrid"></div>
|
||||||
77
client/src/views/staticParties/StaticParties.ts
Normal file
77
client/src/views/staticParties/StaticParties.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import './StaticParties.css';
|
||||||
|
import html from './StaticParties.html';
|
||||||
|
import { connectLobby } from '../../api/party';
|
||||||
|
import { listStaticParties } from '../../api/staticParty';
|
||||||
|
import { escapeHtml } from '../../tiles';
|
||||||
|
import { SOURCE_LABELS } from '../staticParty/StaticParty';
|
||||||
|
import { LOADING_HINT } from '../../loading';
|
||||||
|
import type { StaticPartySummaryDto } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route `/static` - the "Lounge parties" tab: every static
|
||||||
|
* watch party, kept apart from the home page's classic live parties
|
||||||
|
* (whose lobby list leaves static sessions out - see Home.ts).
|
||||||
|
*
|
||||||
|
* A REST list, re-fetched whenever the set of live static-party sessions
|
||||||
|
* in the lobby changes (a session starting or ending is what moves a
|
||||||
|
* card's "séance en cours"/progress state). Not on every lobby snapshot:
|
||||||
|
* those also fire on every pause/join, and each list fetch reads Emby
|
||||||
|
* once per static party (see server/src/index.ts's syncStaticSource).
|
||||||
|
*/
|
||||||
|
export function renderStaticParties(root: HTMLElement): () => void {
|
||||||
|
root.innerHTML = html;
|
||||||
|
const grid = root.querySelector('#staticGrid') as HTMLElement;
|
||||||
|
grid.innerHTML = LOADING_HINT;
|
||||||
|
|
||||||
|
let sessionsKey: string | null = null;
|
||||||
|
const lobby = connectLobby((parties) => {
|
||||||
|
const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(',');
|
||||||
|
if (key === sessionsKey) return;
|
||||||
|
sessionsKey = key;
|
||||||
|
load(grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same leak-prevention contract as Home.ts: the router calls this before
|
||||||
|
// rendering the next view.
|
||||||
|
return function cleanup() {
|
||||||
|
lobby.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches and renders every static party into `grid`. */
|
||||||
|
function load(grid: HTMLElement): void {
|
||||||
|
listStaticParties()
|
||||||
|
.then((parties) => {
|
||||||
|
if (!grid.isConnected) return; // navigated away mid-flight
|
||||||
|
render(grid, parties);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[staticParties] list failed to load', err);
|
||||||
|
if (grid.isConnected) grid.innerHTML = `<p class="error">${escapeHtml((err as Error).message)}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One card per static party: progress, next item, the viewer's missed
|
||||||
|
* count, and whether a session is running right now. */
|
||||||
|
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
|
||||||
|
if (!parties.length) {
|
||||||
|
grid.innerHTML = '<p class="hint">Aucune lounge party pour le moment.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = parties.map((p) => {
|
||||||
|
const percent = p.itemCount ? Math.round((p.groupWatchedCount / p.itemCount) * 100) : 0;
|
||||||
|
return `
|
||||||
|
<a class="staticCard" href="#/static/${escapeHtml(p.id)}">
|
||||||
|
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="staticCardInfo">
|
||||||
|
<strong>${escapeHtml(p.name)}</strong>
|
||||||
|
<span class="hint">${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
|
||||||
|
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
|
||||||
|
${p.livePartyId
|
||||||
|
? '<span class="staticCardLive">● Séance en cours</span>'
|
||||||
|
: `<span class="staticCardNext">${p.nextItemName ? `Prochain : ${escapeHtml(p.nextItemName)}` : 'Le groupe a tout vu.'}</span>`}
|
||||||
|
</div>
|
||||||
|
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
138
client/src/views/staticParty/StaticParty.css
Normal file
138
client/src/views/staticParty/StaticParty.css
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/* ---- Static (persistent) watch party page ----
|
||||||
|
.seriesInfo/.episodeList/.episodeRow/.episodeInfo are reused as-is from
|
||||||
|
views/series/Series.css (always in the bundle - same precedent as
|
||||||
|
Playlist.css/Album.css). Only what's specific to this page lives here. */
|
||||||
|
|
||||||
|
.staticHeader {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCover {
|
||||||
|
width: 160px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticCover.noImage { visibility: hidden; }
|
||||||
|
|
||||||
|
.staticBadge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticMeta { margin: 6px 0 10px; }
|
||||||
|
|
||||||
|
.staticProgress {
|
||||||
|
height: 6px;
|
||||||
|
max-width: 360px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticProgressFill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticNext { margin: 10px 0 14px; font-size: 14px; }
|
||||||
|
|
||||||
|
.staticActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A quieter variant of the global green button - for actions next to a
|
||||||
|
primary one (joining, catching up) that shouldn't compete with it. */
|
||||||
|
.secondaryBtn {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryBtn:hover { background: #333; }
|
||||||
|
|
||||||
|
.staticSectionHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticSectionHead h2 { margin: 0; }
|
||||||
|
|
||||||
|
.staticCount {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticBulkActions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.staticBulkActions button,
|
||||||
|
.staticRowActions button { padding: 8px 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
.staticMissed .episodeRow { border-left: 3px solid var(--danger); }
|
||||||
|
|
||||||
|
.staticCheck {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticRowActions { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Group-progress list: what the group has seen fades back, the next item
|
||||||
|
stands out. */
|
||||||
|
.episodeRow.isWatched { opacity: .55; }
|
||||||
|
.episodeRow.isNext { outline: 2px solid var(--accent); }
|
||||||
|
|
||||||
|
.staticState {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticState .mine { display: block; color: var(--accent-bright); font-weight: 400; }
|
||||||
|
.episodeRow.isNext .staticState { color: var(--accent-bright); }
|
||||||
|
|
||||||
|
.staticMembers { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
|
||||||
|
.staticMember {
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staticMember .hint { margin: 0 0 0 6px; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.staticHeader { flex-direction: column; }
|
||||||
|
.staticCover { width: 120px; }
|
||||||
|
.staticRowActions { flex-basis: 100%; order: 4; }
|
||||||
|
}
|
||||||
39
client/src/views/staticParty/StaticParty.html
Normal file
39
client/src/views/staticParty/StaticParty.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="staticHeader">
|
||||||
|
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="seriesInfo">
|
||||||
|
<span class="staticBadge">Lounge party</span>
|
||||||
|
<h1 id="staticTitle"></h1>
|
||||||
|
<p class="hint staticMeta" id="staticMeta"></p>
|
||||||
|
<div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div>
|
||||||
|
<p class="staticNext" id="staticNext"></p>
|
||||||
|
<div class="staticActions">
|
||||||
|
<button id="sessionBtn">Lancer la séance</button>
|
||||||
|
<button class="secondaryBtn hide" id="joinBtn">Rejoindre cette lounge party</button>
|
||||||
|
<button class="linklike hide" id="leaveBtn">Quitter</button>
|
||||||
|
<button class="linklike hide" id="deleteBtn">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section staticMissed" id="missedSection" hidden>
|
||||||
|
<div class="staticSectionHead">
|
||||||
|
<h2>Tu as manqué <span class="staticCount" id="missedCount"></span></h2>
|
||||||
|
<div class="staticBulkActions">
|
||||||
|
<button class="secondaryBtn" id="catchUpSoloBtn">Rattraper en solo</button>
|
||||||
|
<button class="secondaryBtn" id="catchUpPartyBtn">Lancer une watch party de rattrapage</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Décoche ce que tu ne veux pas rattraper tout de suite.</p>
|
||||||
|
<div class="episodeList" id="missedList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Membres</h2>
|
||||||
|
<div class="staticMembers" id="memberList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Progression du groupe</h2>
|
||||||
|
<div class="episodeList" id="itemList"></div>
|
||||||
|
</section>
|
||||||
227
client/src/views/staticParty/StaticParty.ts
Normal file
227
client/src/views/staticParty/StaticParty.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import './StaticParty.css';
|
||||||
|
import html from './StaticParty.html';
|
||||||
|
import {
|
||||||
|
deleteStaticParty,
|
||||||
|
getStaticParty,
|
||||||
|
joinStaticParty,
|
||||||
|
leaveStaticParty,
|
||||||
|
markStaticItemWatched,
|
||||||
|
startCatchUp,
|
||||||
|
startStaticSession
|
||||||
|
} from '../../api/staticParty';
|
||||||
|
import { navigate } from '../../router';
|
||||||
|
import type { RouteParams } from '../../router';
|
||||||
|
import { escapeHtml } from '../../tiles';
|
||||||
|
import { LOADING_HINT } from '../../loading';
|
||||||
|
import { showToast } from '../../toast';
|
||||||
|
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
|
||||||
|
|
||||||
|
/** How each lounge party source is named in the UI - shared with the
|
||||||
|
* "Lounge parties" tab (views/staticParties). */
|
||||||
|
export const SOURCE_LABELS: Record<StaticPartyDetailDto['sourceType'], string> = {
|
||||||
|
series: 'Série',
|
||||||
|
season: 'Saison',
|
||||||
|
playlist: 'Playlist'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route `/static/:id` - one static (persistent) watch party: the group's
|
||||||
|
* progress through the whole series/playlist, its members, and - the
|
||||||
|
* point of the feature - what *you* missed, with ways to catch up (alone,
|
||||||
|
* or as a watch party others can join) or mark things as already seen.
|
||||||
|
* Launching the group's session hands off to the normal player
|
||||||
|
* (`/party/:id`) - a session is an ordinary party server-side. See
|
||||||
|
* server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
|
||||||
|
*/
|
||||||
|
export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||||
|
const staticId = params.id as string;
|
||||||
|
root.innerHTML = LOADING_HINT;
|
||||||
|
|
||||||
|
let detail: StaticPartyDetailDto;
|
||||||
|
try {
|
||||||
|
detail = await getStaticParty(staticId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[staticParty] load failed', staticId, err);
|
||||||
|
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette lounge party.')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = html;
|
||||||
|
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||||
|
wireActions(root, staticId);
|
||||||
|
render(root, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-fetches and re-renders in place - after any action that changed
|
||||||
|
* progress or membership. */
|
||||||
|
async function reload(root: HTMLElement, staticId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const detail = await getStaticParty(staticId);
|
||||||
|
if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight
|
||||||
|
render(root, detail);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[staticParty] reload failed', staticId, err);
|
||||||
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills every part of the page from `detail`. */
|
||||||
|
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
||||||
|
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
|
||||||
|
root.querySelector('#staticTitle')!.textContent = detail.name;
|
||||||
|
const sourceLabel = SOURCE_LABELS[detail.sourceType];
|
||||||
|
root.querySelector('#staticMeta')!.textContent =
|
||||||
|
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
|
||||||
|
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
|
||||||
|
(root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`;
|
||||||
|
root.querySelector('#staticNext')!.innerHTML = detail.nextItemName
|
||||||
|
? `Prochain : <strong>${escapeHtml(detail.nextItemName)}</strong>`
|
||||||
|
: '<strong>Le groupe a tout vu.</strong>';
|
||||||
|
|
||||||
|
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
|
||||||
|
sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance';
|
||||||
|
sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName;
|
||||||
|
root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember);
|
||||||
|
root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator);
|
||||||
|
root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator);
|
||||||
|
|
||||||
|
renderMissed(root, detail);
|
||||||
|
renderMembers(root, detail);
|
||||||
|
renderItems(root, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */
|
||||||
|
function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
||||||
|
const section = root.querySelector('#missedSection') as HTMLElement;
|
||||||
|
const missed = new Set(detail.missedItemIds);
|
||||||
|
const items = detail.items.filter((i) => missed.has(i.itemId));
|
||||||
|
section.hidden = !items.length;
|
||||||
|
root.querySelector('#missedCount')!.textContent = String(items.length);
|
||||||
|
root.querySelector('#missedList')!.innerHTML = items.map((item) => `
|
||||||
|
<div class="episodeRow">
|
||||||
|
<input type="checkbox" class="staticCheck" data-item-id="${escapeHtml(item.itemId)}" checked aria-label="Inclure dans le rattrapage" />
|
||||||
|
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="episodeInfo"><strong>${escapeHtml(item.itemName)}</strong></div>
|
||||||
|
<div class="staticRowActions">
|
||||||
|
<button class="secondaryBtn soloOneBtn" data-item-id="${escapeHtml(item.itemId)}">Regarder en solo</button>
|
||||||
|
<button class="secondaryBtn markWatchedBtn" data-item-id="${escapeHtml(item.itemId)}">Marquer comme vu</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One chip per member, with how many items they've watched. */
|
||||||
|
function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
||||||
|
root.querySelector('#memberList')!.innerHTML = detail.members.length
|
||||||
|
? detail.members.map((m) => `
|
||||||
|
<span class="staticMember">${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}<span class="hint">${m.watchedCount}/${detail.itemCount}</span></span>`).join('')
|
||||||
|
: '<p class="hint">Aucun membre pour le moment.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole item list, with the group's state and the viewer's own. */
|
||||||
|
function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
||||||
|
root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => `
|
||||||
|
<div class="episodeRow${item.groupState === 'watched' ? ' isWatched' : ''}${item.groupState === 'next' ? ' isNext' : ''}">
|
||||||
|
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="episodeInfo"><strong>${i + 1}. ${escapeHtml(item.itemName)}</strong></div>
|
||||||
|
<span class="staticState">${stateLabel(item)}${item.viewerWatched ? '<span class="mine">✓ vu par toi</span>' : ''}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group-state label for one item. */
|
||||||
|
function stateLabel(item: StaticPartyItemDto): string {
|
||||||
|
if (item.groupState === 'watched') return 'Vu par le groupe';
|
||||||
|
if (item.groupState === 'next') return 'Prochain';
|
||||||
|
return 'À venir';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires every button once - lists are re-rendered on reload, so row
|
||||||
|
* buttons use one delegated listener on their container rather than
|
||||||
|
* per-row listeners re-added on every render.
|
||||||
|
*/
|
||||||
|
function wireActions(root: HTMLElement, staticId: string): void {
|
||||||
|
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
|
||||||
|
sessionBtn.addEventListener('click', () => {
|
||||||
|
runBusy(sessionBtn, async () => {
|
||||||
|
const { partyId } = await startStaticSession(staticId);
|
||||||
|
navigate(`/party/${partyId}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement;
|
||||||
|
joinBtn.addEventListener('click', () => {
|
||||||
|
runBusy(joinBtn, async () => {
|
||||||
|
await joinStaticParty(staticId);
|
||||||
|
showToast('Tu fais maintenant partie de cette lounge party.');
|
||||||
|
await reload(root, staticId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
|
||||||
|
leaveBtn.addEventListener('click', () => {
|
||||||
|
if (!confirm('Quitter cette lounge party ? Ta progression y sera oubliée.')) return;
|
||||||
|
runBusy(leaveBtn, async () => {
|
||||||
|
await leaveStaticParty(staticId);
|
||||||
|
await reload(root, staticId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
|
||||||
|
deleteBtn.addEventListener('click', () => {
|
||||||
|
if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
|
||||||
|
runBusy(deleteBtn, async () => {
|
||||||
|
await deleteStaticParty(staticId);
|
||||||
|
showToast('Lounge party supprimée.');
|
||||||
|
navigate('/static');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => {
|
||||||
|
const itemIds = Array.from(root.querySelectorAll<HTMLInputElement>('#missedList .staticCheck'))
|
||||||
|
.filter((c) => c.checked)
|
||||||
|
.map((c) => c.dataset.itemId as string);
|
||||||
|
if (!itemIds.length) {
|
||||||
|
showToast('Coche au moins un élément à rattraper.', { variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runBusy(btn, async () => {
|
||||||
|
const { partyId } = await startCatchUp(staticId, itemIds, mode);
|
||||||
|
navigate(`/party/${partyId}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement;
|
||||||
|
const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement;
|
||||||
|
soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo'));
|
||||||
|
partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party'));
|
||||||
|
|
||||||
|
root.querySelector('#missedList')!.addEventListener('click', (e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-item-id]');
|
||||||
|
if (!btn) return;
|
||||||
|
const itemId = btn.dataset.itemId as string;
|
||||||
|
if (btn.classList.contains('soloOneBtn')) {
|
||||||
|
runBusy(btn, async () => {
|
||||||
|
const { partyId } = await startCatchUp(staticId, [itemId], 'solo');
|
||||||
|
navigate(`/party/${partyId}`);
|
||||||
|
});
|
||||||
|
} else if (btn.classList.contains('markWatchedBtn')) {
|
||||||
|
runBusy(btn, async () => {
|
||||||
|
await markStaticItemWatched(staticId, itemId);
|
||||||
|
await reload(root, staticId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disables `btn` while `action` runs; a failure is toasted, logged, and
|
||||||
|
* gives the button back. */
|
||||||
|
function runBusy(btn: HTMLButtonElement, action: () => Promise<void>): void {
|
||||||
|
btn.disabled = true;
|
||||||
|
action()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[staticParty] action failed', err);
|
||||||
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
31
client/src/views/staticParty/createStaticButton.ts
Normal file
31
client/src/views/staticParty/createStaticButton.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { createStaticParty } from '../../api/staticParty';
|
||||||
|
import { navigate } from '../../router';
|
||||||
|
import { showToast } from '../../toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Créer une lounge party" - shared by views/series (whole series, or
|
||||||
|
* the selected season) and views/playlist: creates a lounge party over
|
||||||
|
* the source id (a series, a season or a video playlist - the server
|
||||||
|
* decides which, and refuses anything else with a message) and opens its
|
||||||
|
* page. `sourceId` is read at click time, so a caller whose source can
|
||||||
|
* change (the selected season tab) wires the button only once.
|
||||||
|
*/
|
||||||
|
export function wireCreateStaticButton(btn: HTMLButtonElement, getSourceId: () => string): void {
|
||||||
|
const idleLabel = btn.textContent || '';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sourceId = getSourceId();
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Création...';
|
||||||
|
createStaticParty(sourceId)
|
||||||
|
.then((sp) => {
|
||||||
|
console.info('[staticParty] created', sp.id, 'from', sourceId);
|
||||||
|
navigate(`/static/${sp.id}`);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[staticParty] create failed', sourceId, err);
|
||||||
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = idleLabel;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -8,3 +8,9 @@ services:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
EMBY_SERVER_URL: "https://theater.media.kyuno.fr"
|
EMBY_SERVER_URL: "https://theater.media.kyuno.fr"
|
||||||
|
# Static (persistent) watch parties are saved here - see
|
||||||
|
# server/src/services/StaticPartyStore.ts. Without the volume below
|
||||||
|
# they'd be lost every time the container is recreated.
|
||||||
|
DATA_DIR: "/data"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
|
|
||||||
|
|
@ -851,6 +851,23 @@ player against the new `itemId` on that message. A movie or a
|
||||||
standalone-started episode just gets a one-item queue, so this is a strict
|
standalone-started episode just gets a one-item queue, so this is a strict
|
||||||
superset of the old single-item behavior, not a separate code path.
|
superset of the old single-item behavior, not a separate code path.
|
||||||
|
|
||||||
|
**Later: a whole series or one season, not just "from this episode".**
|
||||||
|
`POST /api/parties` also accepts a `Series` id and a `Season` id, not
|
||||||
|
only an episode.
|
||||||
|
|
||||||
|
- **Series**: queues every season from S1E1. Specials (season 0) sort
|
||||||
|
first in Emby's list and are left out, so launching the whole series
|
||||||
|
builds the same queue as clicking S1E1. This is the pure
|
||||||
|
`CatalogService.seriesQueueFrom`, which the episode path now uses too.
|
||||||
|
- **Season**: queues only that season's episodes
|
||||||
|
(`CatalogService.seasonQueue`).
|
||||||
|
- **Where it's launched from**: the series page's "Lancer toute la
|
||||||
|
série" / "Lancer cette saison" buttons. A series tile in a library
|
||||||
|
also gets the normal "Lancer une watch party" button: `Series` joined
|
||||||
|
`WATCH_PARTY_TYPES`.
|
||||||
|
- **Lounge parties** take the same two sources (a whole series or one
|
||||||
|
season). See spec/static-watch-party.md.
|
||||||
|
|
||||||
### Player: language, subtitles, quality
|
### Player: language, subtitles, quality
|
||||||
|
|
||||||
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
|
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
|
||||||
|
|
@ -4237,6 +4254,368 @@ which `align-items: center` legitimately varies by each one's own
|
||||||
content height) - confirming one shared row below, in the original
|
content height) - confirming one shared row below, in the original
|
||||||
track/transport/side left-to-right order.
|
track/transport/side left-to-right order.
|
||||||
|
|
||||||
|
## An album's track rows collapsed to 3 lines on mobile for no reason
|
||||||
|
|
||||||
|
Reported directly: there was room to fit an album's track rows
|
||||||
|
(index/name/duration/a small play-or-queue button) on one line on
|
||||||
|
mobile, but `trackList.css`'s own `@media (max-width: 600px)` forced
|
||||||
|
`.trackRow` down to a 2-column grid and pinned both `.trackDuration`
|
||||||
|
and the button to that same second column - auto-placement then
|
||||||
|
stacked the two of them into their own rows underneath the track name,
|
||||||
|
three rows total instead of one.
|
||||||
|
|
||||||
|
Removed outright rather than tuned - the numbers don't support it
|
||||||
|
having ever been needed. `.trackRow`'s fixed overhead (28px index +
|
||||||
|
2×12px gaps + `auto`-sized duration/button + `12px` horizontal padding)
|
||||||
|
comes to roughly 155-165px, and even the narrowest common phone width
|
||||||
|
(320px) leaves comfortably more than that for `.mainContent`'s own
|
||||||
|
side padding plus a `1fr` name column - `.trackName`'s existing
|
||||||
|
`text-overflow: ellipsis` was already doing exactly the job this
|
||||||
|
breakpoint assumed it couldn't.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`build` (client only). Checked directly, not
|
||||||
|
assumed: real compiled CSS, a handful of `.trackRow`s (short and
|
||||||
|
deliberately overlong names) loaded into two `<iframe>`s at 320px and
|
||||||
|
375px - this sandbox's own `resize_window` viewport emulation is
|
||||||
|
unreliable, see the mobile-nav/music-UI sections above. At both widths
|
||||||
|
every row's index/name/duration/button sat on one line (their vertical
|
||||||
|
ranges genuinely overlapping, not just similar by coincidence), left to
|
||||||
|
right in that order, with the long names confirmed actually truncated
|
||||||
|
(`scrollWidth > clientWidth`) rather than merely happening to fit.
|
||||||
|
|
||||||
|
## A mobile install nudge for the PWA
|
||||||
|
|
||||||
|
Asked for directly: the PWA (see "Progressive Web App" above) existed,
|
||||||
|
but nothing ever told a mobile visitor it could be installed at all -
|
||||||
|
Chrome's own default mini-infobar is easy to miss/dismiss, and Safari
|
||||||
|
has no browser-driven prompt whatsoever.
|
||||||
|
|
||||||
|
New `pwaInstall.ts`, self-mounting like `toast.ts` (no per-route/
|
||||||
|
per-party state to attach) but with one real difference from that
|
||||||
|
module's own lazy-init pattern: `beforeinstallprompt` (Chrome/Edge/
|
||||||
|
Android WebView's event for this - not in `lib.dom.d.ts`, Safari/
|
||||||
|
Firefox never implement it) can fire before any UI exists to react to
|
||||||
|
it, and the browser never replays it for a listener that attached too
|
||||||
|
late. The listener itself is a top-level side effect of importing the
|
||||||
|
module - `shell/Shell.ts` does that (and calls the module's own
|
||||||
|
`initPwaInstallPrompt()`, after `initMusicBar`), and since ES module
|
||||||
|
imports all evaluate before an entry file's own top-level code runs,
|
||||||
|
that listener is live well before `app.ts`'s `boot()` even starts its
|
||||||
|
async login check, regardless of how soon the event actually fires.
|
||||||
|
|
||||||
|
Two real install paths exist, and the banner speaks to whichever one
|
||||||
|
applies:
|
||||||
|
- **Chrome/Edge/Android WebView**: `event.preventDefault()` stashes the
|
||||||
|
captured event (this banner replaces the browser's own default one);
|
||||||
|
clicking "Installer" calls the real `prompt()` and reads back
|
||||||
|
`userChoice` - only an outright 'accepted' removes the banner, since
|
||||||
|
the picker's own 'dismissed' just means "not right now", not "stop
|
||||||
|
asking" (that's what the banner's own close button is for).
|
||||||
|
- **iOS Safari**: no such event exists at all, ever - detected instead
|
||||||
|
by user-agent sniffing (there's no clean feature-detection for "is
|
||||||
|
this iOS Safari"), showing manual instructions ("appuie sur Partager
|
||||||
|
puis Sur l'écran d'accueil") with no button, since there's nothing to
|
||||||
|
programmatically trigger.
|
||||||
|
|
||||||
|
Mobile-only (`matchMedia('(max-width: 720px)')`, this app's own
|
||||||
|
existing breakpoint, re-checked on resize for a live rotate/unfold) and
|
||||||
|
suppressed entirely once already running installed
|
||||||
|
(`display-mode: standalone`, or `navigator.standalone` for iOS Safari's
|
||||||
|
own equivalent flag, which has no `display-mode` query at all). A
|
||||||
|
close click remembers "don't ask again" in `localStorage` - not
|
||||||
|
re-shown even against a fresh `beforeinstallprompt` on a later visit.
|
||||||
|
|
||||||
|
Rendered as a plain normal-flow banner at the very top of `<body>`,
|
||||||
|
before `#app` - not a fixed/sticky overlay - specifically to avoid
|
||||||
|
needing to coordinate yet another offset with `shell/Shell.css`'s own
|
||||||
|
already-stacked `.mobileTopBar`/`.libraryHeader` pair (see the sticky-
|
||||||
|
header sections above) or `alphaIndex.css`'s rail. It simply pushes the
|
||||||
|
app down by its own height and scrolls away with the page, the same as
|
||||||
|
a browser's own native install infobar would.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`build` (client only). No real device
|
||||||
|
install flow is triggerable in this sandbox (nothing can fabricate a
|
||||||
|
genuine browser installability signal), so checked everything around
|
||||||
|
that boundary directly instead: a synthetic `beforeinstallprompt`-
|
||||||
|
shaped event (real `Event`, stubbed `prompt`/`userChoice`) dispatched
|
||||||
|
into real compiled CSS/JS confirmed the banner appears with the
|
||||||
|
install button, that button calls the stub `prompt()` and removes the
|
||||||
|
banner only on an 'accepted' outcome, and that the close button both
|
||||||
|
removes it and blocks a *second* dispatched event from reopening it.
|
||||||
|
Overriding `navigator.userAgent` to an iPhone string confirmed the
|
||||||
|
iOS-flavored copy with no install button. A same-event dispatch into a
|
||||||
|
real ≥900px-wide `<iframe>` confirmed the banner never renders there
|
||||||
|
at all, regardless of the event firing.
|
||||||
|
|
||||||
|
## OS-level media controls (Windows' SMTC, macOS' Now Playing, ...)
|
||||||
|
|
||||||
|
Asked for directly: linking whatever's playing to Windows' own media
|
||||||
|
popup (the one media keys/the volume flyout show) - the standard
|
||||||
|
Media Session API (`navigator.mediaSession`), which the same code path
|
||||||
|
also surfaces to macOS' Now Playing widget and Android's notification
|
||||||
|
for free, on whichever OS/browser combination actually implements it
|
||||||
|
(Chromium family; Firefox has never implemented it at all).
|
||||||
|
|
||||||
|
New `mediaSession.ts`, a thin wrapper - every export is a one-line
|
||||||
|
`'mediaSession' in navigator` guard around the exact browser call, not
|
||||||
|
a new abstraction of its own - shared by `views/player/Player.ts`
|
||||||
|
(video) and `musicBar/MusicBar.ts` (a listen party), which can never
|
||||||
|
both be active at once (starting/joining either already leaves the
|
||||||
|
other first - see docs above), so there's no risk of the two fighting
|
||||||
|
over this one global.
|
||||||
|
|
||||||
|
**Metadata/position/playback state** hook into each player's own
|
||||||
|
existing single-source-of-truth functions rather than adding parallel
|
||||||
|
call sites everywhere: Player.ts's `updatePlayPauseBtn`/
|
||||||
|
`updateProgressBar` (already the one place `#playPauseBtn`'s icon and
|
||||||
|
the seek bar's fill/label get synced, from every driver-changed event,
|
||||||
|
the heartbeat, and the ticker alike) also call
|
||||||
|
`setMediaSessionPlaybackState`/`setMediaSessionPositionState` now;
|
||||||
|
MusicBar.ts's `setPlayIcon`/`updatePositionDisplay` do the same. Title/
|
||||||
|
artwork update at both places an item/track actually changes
|
||||||
|
(Player.ts's `init`/`onItemChanged`, MusicBar.ts's `loadTrack`) -
|
||||||
|
artist/album are left empty for video (meaningless there, same as this
|
||||||
|
app's own UI already treats them).
|
||||||
|
|
||||||
|
**Play/pause/seek** are wired once per party (not re-wired on state
|
||||||
|
changes) via `setMediaSessionActionHandlers` - the handler functions
|
||||||
|
themselves close over local state (`isHost`, `conn`, ...) and read it
|
||||||
|
fresh on every OS-triggered call, the same way any other closure in
|
||||||
|
either file already does, so a handler set at party-join time keeps
|
||||||
|
working correctly straight through a live host handoff without needing
|
||||||
|
to know that happened. Two real permission models, unchanged by this:
|
||||||
|
video's play/pause is host-only - a non-host's OS-level play/pause
|
||||||
|
routes to the exact same `requestPause`/`requestResume` (now factored
|
||||||
|
out so both the in-page buttons and this can call them) the request
|
||||||
|
toolbar already uses, never touching the video directly - and seeking
|
||||||
|
is gated on `isHost` the same way the in-page seek bar already is
|
||||||
|
(factored into a shared `seekTo`, reused by both the pointer handler
|
||||||
|
and this). Music has neither gate (no host concept at all - see
|
||||||
|
MusicQueueService's own reasoning), so its handlers act directly.
|
||||||
|
|
||||||
|
**"Next"** is handled differently from play/pause/seek because a
|
||||||
|
*registered* `nexttrack` handler is what makes the OS show that
|
||||||
|
control at all, regardless of whether clicking it would currently do
|
||||||
|
anything - so unlike the other three, its presence has to track
|
||||||
|
whatever the in-page equivalent's own visibility already tracks, via a
|
||||||
|
separate `setMediaSessionNextAction(fn | null)` called alongside it:
|
||||||
|
Player.ts's `#nextBtn` (`isHost && hasNextQueued`, in `becomeHost` and
|
||||||
|
`onItemChanged`) and MusicBar.ts's vote-skip button (`music.current`,
|
||||||
|
in `applyMusicState` - clicking the OS's own "next" casts one skip
|
||||||
|
vote, same as the in-page button, not an instant skip).
|
||||||
|
|
||||||
|
**Clearing**: `clearMediaSession()` (metadata, playback state, *and*
|
||||||
|
every action handler) runs on Player.ts's route cleanup and
|
||||||
|
MusicBar.ts's `leaveMusicParty` - an OS-level "now playing" card that
|
||||||
|
outlives the actual party would be actively wrong, not stale. A
|
||||||
|
lighter `setMediaSessionIdle()` (metadata/state only, action handlers
|
||||||
|
left alone) covers MusicBar.ts's own "queue ran empty but still in the
|
||||||
|
party" case instead - play/pause/seek stay meaningful the instant
|
||||||
|
something plays again, so there's nothing to re-wire there.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`build` (client only). `mediaSession.ts`
|
||||||
|
itself checked directly against the real API in a real Chromium
|
||||||
|
browser (this sandbox's own): metadata resolves to a genuine
|
||||||
|
`MediaMetadata` with the right fields and a resolved artwork URL (or
|
||||||
|
none for an empty one), playback state and position state both apply
|
||||||
|
without throwing, an invalid (non-positive/`NaN`) duration is silently
|
||||||
|
skipped rather than reaching the browser call that would throw on it,
|
||||||
|
action-handler registration doesn't throw with or without `onSeek`
|
||||||
|
supplied, and both `setMediaSessionIdle`/`clearMediaSession` reset
|
||||||
|
metadata to `null` and playback state to `'none'`. Player.ts's/
|
||||||
|
MusicBar.ts's own wiring is one-line calls into already-verified
|
||||||
|
functions at points each file already treats as its single source of
|
||||||
|
truth for that piece of state (see above) - not independently
|
||||||
|
exercised end to end against a live party (would need mocking the
|
||||||
|
entire socket/party layer for no more real signal than reading the
|
||||||
|
call sites already gives), and the actual OS-level popup itself - the
|
||||||
|
whole point - isn't something this sandbox can display or click
|
||||||
|
regardless. Worth a real Windows/Chrome check once deployed.
|
||||||
|
|
||||||
|
## Device takeover: both devices "on the session", neither one's controls working
|
||||||
|
|
||||||
|
Reported directly: switching a watch/listen party from one device to
|
||||||
|
another left both devices looking active, with no controls actually
|
||||||
|
transferring. The device-takeover mechanism itself - `SocketGateway`'s
|
||||||
|
"join" handler force-disconnecting the old device's socket once the new
|
||||||
|
one confirms - was already correct and already guarded against the
|
||||||
|
obvious failure mode (its own comment on the "leave"/"disconnect"
|
||||||
|
handlers: a stale connection's own eventual disconnect must not be
|
||||||
|
mistaken for a real leave). The actual bug was two gaps *around* that,
|
||||||
|
neither in the takeover mechanism itself.
|
||||||
|
|
||||||
|
**Gap 1 - the old device never reacted to being kicked.** Both
|
||||||
|
`Player.ts` and `MusicBar.ts` wired `onClose: () => {}` - a complete
|
||||||
|
no-op - to the connection's "disconnect" event. So the moment a takeover
|
||||||
|
force-disconnected the old device's socket, that device's own video/
|
||||||
|
hls.js instance (or the persistent music bar) just kept running exactly
|
||||||
|
as before: the socket was dead, but nothing told the local `<video>`/
|
||||||
|
`<audio>` to stop, and nothing navigated away or reset `isHost`. From
|
||||||
|
the old device's own screen, everything still looked (and for local-only
|
||||||
|
playback, sounded) fully live - exactly "both devices on the session."
|
||||||
|
Fixed: both now react to an *unprompted* close (one that didn't already
|
||||||
|
run this route/module's own explicit teardown - `leftIntentionally` in
|
||||||
|
Player.ts, `activePartyId` already being null in MusicBar.ts, both set
|
||||||
|
synchronously by that explicit teardown well before the async
|
||||||
|
"disconnect" this checks against ever arrives) by tearing down local
|
||||||
|
playback and leaving, the same as if the user had clicked "Quitter"
|
||||||
|
there themselves.
|
||||||
|
|
||||||
|
**Gap 2 - reacting to the kick had no safe way to do that.** The obvious
|
||||||
|
fix for gap 1 - call the same leave path "Quitter" already uses - ran
|
||||||
|
straight into a second, worse bug: `POST /api/parties/:partyId/leave`
|
||||||
|
removes a member by `userId` alone, with no equivalent of the socket
|
||||||
|
handlers' own staleness guard (there's no live connection for a REST
|
||||||
|
call to compare against). Calling it from the *old*, superseded device
|
||||||
|
would have ended the *new* device's membership too - silently undoing
|
||||||
|
the takeover it was supposed to be cleaning up after. Fixed by giving
|
||||||
|
this route the same guard via `deviceId` instead of a socket reference:
|
||||||
|
new `PartyService.isStaleDevice(member, deviceId)`, and `leaveParty()`
|
||||||
|
(client) now sends its own `getDeviceId()` for the server to compare.
|
||||||
|
Only ever blocks on positive evidence of a mismatch - a member with no
|
||||||
|
recorded deviceId yet, or a caller that sent none, still leaves exactly
|
||||||
|
as before this existed.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` on both projects (153/153
|
||||||
|
server tests - new coverage for `isStaleDevice` covering a matching
|
||||||
|
deviceId, a mismatched one, an unset member deviceId, an unset caller
|
||||||
|
deviceId, and an unknown member, each checked against the *reason* it
|
||||||
|
should or shouldn't block, not just the outcome). The client-side
|
||||||
|
onClose fix was checked against the exact reported scenario, not just
|
||||||
|
reasoned about: a fake `io()` standing in for Socket.IO (real `.on`/
|
||||||
|
`.emit`/a scriptable `.disconnect()` that fires real listeners) let
|
||||||
|
`joinMusicParty`/`leaveMusicParty` run for real. Firing `disconnect`
|
||||||
|
right after a real `leaveMusicParty()` call produced no toast and left
|
||||||
|
the bar hidden (no spurious "connection lost" message on an intentional
|
||||||
|
leave); firing it with no prior leave - the takeover-kick shape -
|
||||||
|
produced the "Connexion à la party interrompue" toast and correctly
|
||||||
|
tore the bar down. Player.ts's own `leftIntentionally` fix mirrors this
|
||||||
|
exact shape (confirmed by reading, not re-verified with a second live
|
||||||
|
harness - hls.js/a full player DOM would need mocking for no more
|
||||||
|
signal than the MusicBar case already gives, given both share the same
|
||||||
|
logic shape end to end).
|
||||||
|
|
||||||
|
## A track started directly never fell back to its album's artwork
|
||||||
|
|
||||||
|
Reported directly: the app struggles to find artist/album/track images.
|
||||||
|
Traced one concrete, confirmed cause: `imageUrlFor`'s own
|
||||||
|
`Audio -> AlbumId` fallback (added earlier specifically because a track
|
||||||
|
essentially never has its own Primary image, only its album usually
|
||||||
|
does) was silently never triggering for a track played directly - not
|
||||||
|
via its album or artist - because the raw item that decision runs
|
||||||
|
against, `CatalogService.getRawItem`, never requested `AlbumId` from
|
||||||
|
Emby in the first place. Emby's default field set for a single-item GET
|
||||||
|
omits it entirely, so `item.AlbumId` read `undefined` there regardless
|
||||||
|
of whether the track's actual album genuinely had one - `imageUrlFor`
|
||||||
|
fell through to the track's own id every time, for every party started
|
||||||
|
this way.
|
||||||
|
|
||||||
|
Every OTHER path that resolves a track's image already asks for it
|
||||||
|
correctly - `getAlbumTracks`/`getPlaylistItemsRaw` both already include
|
||||||
|
`AlbumId` in their own `Fields`, which is why launching a whole album/
|
||||||
|
artist/playlist, or a queue built from one, was never affected. Fixed
|
||||||
|
the one gap instead of guessing at a broader rewrite: `getRawItem` now
|
||||||
|
requests `Fields=AlbumId` too. Harmless for every non-Audio item it also
|
||||||
|
fetches (Movie/Series/Episode/...) - Emby just omits a field that
|
||||||
|
genuinely doesn't apply to them.
|
||||||
|
|
||||||
|
Not the whole story, most likely - a real Emby library can also
|
||||||
|
legitimately have no Primary image at all for a given artist or album
|
||||||
|
(nothing for this app to fall back to, no bug to fix), and this fix
|
||||||
|
only ever covers the one specific path (a track started directly) that
|
||||||
|
was provably missing a field it needed. Flagged directly rather than
|
||||||
|
overclaiming "images are fixed" - worth another, more specific report
|
||||||
|
(which item exactly, from where) if artist/album art still struggles
|
||||||
|
after this.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` (server only - `CatalogService.ts`
|
||||||
|
is server-side). New test confirms `getRawItem`'s actual outgoing
|
||||||
|
request includes `Fields=AlbumId` (a mocked `EmbyClient.fetch` capturing
|
||||||
|
the real path/query it was called with, not just asserting on the
|
||||||
|
already-separately-tested `imageUrlFor` logic downstream of it).
|
||||||
|
|
||||||
|
## The music bar's mobile now-playing screen didn't fit landscape
|
||||||
|
|
||||||
|
Reported directly, along with a fair general complaint: the mobile
|
||||||
|
now-playing overlay used mostly fixed sizes, and the one that mattered
|
||||||
|
most - the album art - was sized off `vw` (`min(220px, 60vw)`), the
|
||||||
|
viewport dimension a phone in landscape has *plenty* of. Stacked
|
||||||
|
vertically (art, then text, then the transport row, then the side row)
|
||||||
|
with that same art staying pinned near its 220px cap regardless of how
|
||||||
|
little vertical room was actually left, the screen ran well past a
|
||||||
|
typical landscape phone's height and needed to scroll to reach the
|
||||||
|
transport controls - not adapted to the rotation at all.
|
||||||
|
|
||||||
|
Fixed with a dedicated `@media (max-width: 720px) and
|
||||||
|
(orientation: landscape)` block, not by chasing every fixed value in
|
||||||
|
the portrait rules: the art moves beside the title/artist instead of
|
||||||
|
above them (`.musicBarPanelTrack` becomes `flex-direction: row`),
|
||||||
|
freeing most of the vertical space the portrait layout spends stacking
|
||||||
|
them - and every size that actually needs to respond to the rotation is
|
||||||
|
now capped by `vh` (`min(120px, 20vh)`), the dimension landscape
|
||||||
|
actually constrains, instead of `vw`. A few fixed margins shrink
|
||||||
|
alongside it in this same block, same reasoning: adapting to less
|
||||||
|
vertical room, not arbitrary tightening.
|
||||||
|
|
||||||
|
Deliberately NOT changed: `.musicBarPlayBig`'s 56px transport button
|
||||||
|
and the other icon buttons. A touch target's size answers to a
|
||||||
|
finger, not the screen - shrinking it in landscape to "adapt" would
|
||||||
|
trade a real usability problem (nothing to tap accurately) for a
|
||||||
|
cosmetic one, the opposite of the actual ask.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`build` (client only). Checked directly, not
|
||||||
|
just reasoned about - real compiled CSS with the real markup loaded
|
||||||
|
into `<iframe>`s sized to a wide (700x320) and a deliberately extreme,
|
||||||
|
narrow (650x280) landscape shape (this sandbox's own `resize_window`
|
||||||
|
viewport emulation doesn't reflect orientation reliably - iframes do,
|
||||||
|
same workaround as every other viewport-shaped check this session).
|
||||||
|
Both matched `(orientation: landscape)`, both switched
|
||||||
|
`.musicBarPanelTrack` to a real `row` layout with the art measurably to
|
||||||
|
the image's left, both image sizes tracked the shrinking `vh` cap
|
||||||
|
correctly (64px/56px respectively, not the old fixed 220px), and both
|
||||||
|
fit their entire content within the viewport's own height with zero
|
||||||
|
overflow - even the extreme 280px-tall case, which the pre-fix numbers
|
||||||
|
would have overflowed by well over 200px. A separate portrait check
|
||||||
|
(375x700) confirmed this landscape block changes nothing there: still
|
||||||
|
`column`, still the original 220px `vw`-capped art, unaffected.
|
||||||
|
|
||||||
|
## Static (persistent) watch parties
|
||||||
|
|
||||||
|
Asked for directly: a watch party built from a whole series or video
|
||||||
|
playlist that remembers the group's progress *and* each person's, so
|
||||||
|
someone coming back or joining late sees what they missed. Full design
|
||||||
|
in [`spec/static-watch-party.md`](../spec/static-watch-party.md). The
|
||||||
|
short version:
|
||||||
|
|
||||||
|
- **A new persistent entity, not a change to Party.**
|
||||||
|
`StaticPartyService` owns the group and member progress. Each session
|
||||||
|
is an ordinary live `Party` linked back through `party.staticLink`,
|
||||||
|
so the player, sync, shared stream, lobby and host handoff are all
|
||||||
|
reused unchanged. `PartyService` calls into `StaticPartyService`,
|
||||||
|
never the other way around.
|
||||||
|
- **This app's first on-disk state.** One JSON file per static party
|
||||||
|
(`StaticPartyStore`, `${DATA_DIR}/static-parties/<id>.json`). Writes
|
||||||
|
are atomic, debounced per party, and flushed on SIGTERM. It started
|
||||||
|
as a single file for every party; that was split, so a write only
|
||||||
|
touches the party that changed and a corrupt file costs only that
|
||||||
|
party. The old single file is migrated once, on first load. "Party
|
||||||
|
lifecycle" above still holds for ordinary parties.
|
||||||
|
- **Its own tab and its own name.** The UI calls these **Lounge
|
||||||
|
parties**, next to **Watch party** (classic video) and **Listen
|
||||||
|
party** (classic audio). The code keeps "static party". The "Lounge
|
||||||
|
parties" tab (`/static`) lists them. The home page's live "Parties en
|
||||||
|
cours" list (each card badged Watch/Listen party) leaves their
|
||||||
|
sessions out, so classic and lounge parties never share a list.
|
||||||
|
- **"Completed" gained a second trigger.** `PartyService
|
||||||
|
.completeCurrentItem` is now the one place an item counts as done. It
|
||||||
|
handles Emby Played, as before, plus static progress. It runs from
|
||||||
|
`advanceQueue`, and from a new host-only `"itemEnded"` socket event
|
||||||
|
for the *last* queued item. That item was previously never marked
|
||||||
|
Played at all, since there was no "next" to send for it.
|
||||||
|
- **Missed = group-completed − watched in-app − Emby Played**, computed
|
||||||
|
by the pure `computeMissedIds`. Each viewer's own Emby `UserData` is
|
||||||
|
read from the source re-fetch under their own session.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
@ -4253,7 +4632,10 @@ track/transport/side left-to-right order.
|
||||||
resume), so two listeners' `<audio>` elements could drift apart slightly
|
resume), so two listeners' `<audio>` elements could drift apart slightly
|
||||||
during a long stretch with no such event - accepted for v1 given music's
|
during a long stretch with no such event - accepted for v1 given music's
|
||||||
more forgiving tolerance for exact sync compared to video+subtitles.
|
more forgiving tolerance for exact sync compared to video+subtitles.
|
||||||
- No persistence: server restart or long idle time ends every party.
|
- No persistence for ordinary parties: server restart or long idle time
|
||||||
|
ends every one. Only static watch parties (see their own section) are
|
||||||
|
persisted - and even their live sessions end on a restart, resuming
|
||||||
|
from the last saved position.
|
||||||
- No free seeking within a music track (only vote-skip to the *next* one)
|
- No free seeking within a music track (only vote-skip to the *next* one)
|
||||||
- not asked for, and would need its own protocol addition
|
- not asked for, and would need its own protocol addition
|
||||||
(`MusicQueueService` has no notion of "jump to position X" today, only
|
(`MusicQueueService` has no notion of "jump to position X" today, only
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ COPY --from=build /app/client/dist ./client/dist
|
||||||
|
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
# Static watch parties' JSON file (StaticPartyStore) - mount a volume here
|
||||||
|
# (docker-compose.yml does), or they're lost with the container.
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
RUN mkdir -p /data
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.test.json",
|
"typecheck": "tsc -p tsconfig.test.json",
|
||||||
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
|
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/staticPartyService.test.ts test/transcodeService.test.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
|
|
@ -83,4 +83,11 @@ export class Logger {
|
||||||
static playstate(message: string): void {
|
static playstate(message: string): void {
|
||||||
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
|
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Static (persistent) watch parties - created/joined/left/deleted,
|
||||||
|
* group and per-member progress, sessions launched, and every load/save
|
||||||
|
* of their JSON file (see StaticPartyService/StaticPartyStore). */
|
||||||
|
static staticParty(message: string): void {
|
||||||
|
console.log(`[watchparty:static] ${new Date().toISOString()} ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,11 @@ if (!EMBY_SERVER_URL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PORT = Number(process.env.PORT) || 3000;
|
export const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where persistent state lives on disk - today only the static watch
|
||||||
|
* parties (see StaticPartyStore). Relative paths resolve against the
|
||||||
|
* process's working directory. Mount a volume here in Docker, or every
|
||||||
|
* static watch party is lost with the container.
|
||||||
|
*/
|
||||||
|
export const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import http from 'http';
|
||||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||||
import { Server as SocketIoServer } from 'socket.io';
|
import { Server as SocketIoServer } from 'socket.io';
|
||||||
|
|
||||||
import { EMBY_SERVER_URL, PORT } from './config';
|
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
|
||||||
import { Logger } from './Logger';
|
import { Logger } from './Logger';
|
||||||
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
||||||
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
|
import { CatalogService, CatalogFetchError, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
|
||||||
import { PartyService } from './services/PartyService';
|
import { PartyService } from './services/PartyService';
|
||||||
import { MusicQueueService } from './services/MusicQueueService';
|
import { MusicQueueService } from './services/MusicQueueService';
|
||||||
import { TranscodeService } from './services/TranscodeService';
|
import { TranscodeService } from './services/TranscodeService';
|
||||||
|
|
@ -16,8 +16,13 @@ import { PartyStreamService } from './services/PartyStreamService';
|
||||||
import { PlaystateService } from './services/PlaystateService';
|
import { PlaystateService } from './services/PlaystateService';
|
||||||
import { EmbySessionService } from './services/EmbySessionService';
|
import { EmbySessionService } from './services/EmbySessionService';
|
||||||
import { CastTokenService } from './services/CastTokenService';
|
import { CastTokenService } from './services/CastTokenService';
|
||||||
|
import { StaticPartyService } from './services/StaticPartyService';
|
||||||
|
import { StaticPartyStore } from './services/StaticPartyStore';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } from './types';
|
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
|
||||||
|
|
||||||
|
StaticPartyStore.configure(DATA_DIR);
|
||||||
|
StaticPartyService.init();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||||
|
|
@ -253,7 +258,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRo
|
||||||
if (!session) { res.status(401).end(); return; }
|
if (!session) { res.status(401).end(); return; }
|
||||||
const party = PartyService.getById(partyId);
|
const party = PartyService.getById(partyId);
|
||||||
if (!party || !party.members.has(session.userId)) {
|
if (!party || !party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
|
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
|
||||||
|
|
@ -267,7 +272,7 @@ app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(asyn
|
||||||
if (!session) { res.status(401).end(); return; }
|
if (!session) { res.status(401).end(); return; }
|
||||||
const party = PartyService.getById(partyId);
|
const party = PartyService.getById(partyId);
|
||||||
if (!party || !party.members.has(session.userId)) {
|
if (!party || !party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await PartyStreamService.serveRawAsset(req, res, party, session);
|
await PartyStreamService.serveRawAsset(req, res, party, session);
|
||||||
|
|
@ -297,7 +302,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(asyn
|
||||||
if (!session) { res.status(401).end(); return; }
|
if (!session) { res.status(401).end(); return; }
|
||||||
const party = PartyService.getById(partyId);
|
const party = PartyService.getById(partyId);
|
||||||
if (!party || !party.members.has(session.userId)) {
|
if (!party || !party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!party.burnInSubtitleIndex) {
|
if (!party.burnInSubtitleIndex) {
|
||||||
|
|
@ -313,7 +318,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
|
||||||
if (!session) { res.status(401).end(); return; }
|
if (!session) { res.status(401).end(); return; }
|
||||||
const party = PartyService.getById(partyId);
|
const party = PartyService.getById(partyId);
|
||||||
if (!party || !party.members.has(session.userId)) {
|
if (!party || !party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
|
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
|
||||||
|
|
@ -351,6 +356,20 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
||||||
// action on the party's own existing queue - see the "queue" route
|
// action on the party's own existing queue - see the "queue" route
|
||||||
// below - both share this resolution.
|
// below - both share this resolution.
|
||||||
|
// A whole series - every season, in order, from S1E1 (see
|
||||||
|
// seriesQueueFrom). Launched from the series page's own button or its
|
||||||
|
// library tile; an episode's own button still starts from that episode
|
||||||
|
// (the Episode branch below).
|
||||||
|
if (item.Type === 'Series') {
|
||||||
|
await createPartyFromSeries(res, session, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Just one season - the series page's per-season button (see seasonQueue).
|
||||||
|
if (item.Type === 'Season') {
|
||||||
|
await createPartyFromSeason(res, session, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
||||||
const tracks = await resolveMusicTracksForItem(session, item);
|
const tracks = await resolveMusicTracksForItem(session, item);
|
||||||
createMusicPartyFromTracks(res, session, tracks);
|
createMusicPartyFromTracks(res, session, tracks);
|
||||||
|
|
@ -367,13 +386,8 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
let queue: QueueItem[] | null = null;
|
let queue: QueueItem[] | null = null;
|
||||||
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
||||||
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
||||||
const startIndex = all.findIndex((e) => e.Id === itemId);
|
const remaining = seriesQueueFrom(all, itemId);
|
||||||
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
|
||||||
queue = remaining.map((e) => ({
|
|
||||||
itemId: e.Id,
|
|
||||||
itemName: formatEpisodeName(e),
|
|
||||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const party = PartyService.create(
|
const party = PartyService.create(
|
||||||
|
|
@ -410,6 +424,17 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
|
||||||
return episode.Name;
|
return episode.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One video item (episode or movie) as a queue entry - shared by every
|
||||||
|
* path that builds a video queue: an episode's series, a playlist, a
|
||||||
|
* static watch party. */
|
||||||
|
function toVideoQueueItem(item: EmbyItem): QueueItem {
|
||||||
|
return {
|
||||||
|
itemId: item.Id,
|
||||||
|
itemName: item.Type === 'Episode' ? formatEpisodeName(item) : item.Name,
|
||||||
|
itemImageUrl: `/api/catalog/image/${item.Id}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "launch this playlist" half of POST /api/parties above - kept
|
* The "launch this playlist" half of POST /api/parties above - kept
|
||||||
* separate rather than merged into the single-item path, which is
|
* separate rather than merged into the single-item path, which is
|
||||||
|
|
@ -456,11 +481,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue: QueueItem[] = items.map((e) => ({
|
const queue: QueueItem[] = items.map(toVideoQueueItem);
|
||||||
itemId: e.Id,
|
|
||||||
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
|
|
||||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
|
||||||
}));
|
|
||||||
const party = PartyService.create(
|
const party = PartyService.create(
|
||||||
session.userId, session.userName,
|
session.userId, session.userName,
|
||||||
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
|
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
|
||||||
|
|
@ -471,6 +492,45 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The "launch the whole series" half of POST /api/parties - a watch
|
||||||
|
* party queued with every episode of every season (see seriesQueueFrom). */
|
||||||
|
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
|
||||||
|
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(400).json({ error: "Cette série n'a aucun épisode." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "launch just this season" half of POST /api/parties - only that
|
||||||
|
* season's episodes, nothing after it (see seasonQueue). */
|
||||||
|
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
|
||||||
|
if (!season.SeriesId) {
|
||||||
|
res.status(400).json({ error: 'Saison introuvable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
|
||||||
|
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a video watch party playing `queue` in order, hosted by
|
||||||
|
* `session` - shared by the whole-series and single-season launches. */
|
||||||
|
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
|
||||||
|
const first = queue[0]!;
|
||||||
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
||||||
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
|
||||||
|
SocketGateway.broadcastLobby(io);
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
}
|
||||||
|
|
||||||
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
||||||
* or MusicArtist - the three POST /api/parties and the "queue" route
|
* or MusicArtist - the three POST /api/parties and the "queue" route
|
||||||
* below ever call this for) resolved into a flat, ordered track list -
|
* below ever call this for) resolved into a flat, ordered track list -
|
||||||
|
|
@ -548,12 +608,12 @@ function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks:
|
||||||
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (!party || party.mediaType !== 'music') {
|
if (!party || party.mediaType !== 'music') {
|
||||||
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
res.status(404).json({ error: 'Listen party introuvable.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = req.embySession!;
|
const session = req.embySession!;
|
||||||
if (!party.members.has(session.userId)) {
|
if (!party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const track = req.body || {};
|
const track = req.body || {};
|
||||||
|
|
@ -579,12 +639,12 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
||||||
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
|
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (!party || party.mediaType !== 'music') {
|
if (!party || party.mediaType !== 'music') {
|
||||||
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
res.status(404).json({ error: 'Listen party introuvable.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = req.embySession!;
|
const session = req.embySession!;
|
||||||
if (!party.members.has(session.userId)) {
|
if (!party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const itemId = req.body?.itemId;
|
const itemId = req.body?.itemId;
|
||||||
|
|
@ -608,6 +668,12 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
const session = req.embySession!;
|
const session = req.embySession!;
|
||||||
PartyService.join(party, session.userId, session.userName);
|
PartyService.join(party, session.userId, session.userName);
|
||||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
// Joining a static watch party's group session from the lobby makes you
|
||||||
|
// a member of that static party too - its progress is now yours.
|
||||||
|
if (party.staticLink?.mode === 'group') {
|
||||||
|
const sp = StaticPartyService.getById(party.staticLink.staticPartyId);
|
||||||
|
if (sp) StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
||||||
|
}
|
||||||
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
});
|
});
|
||||||
|
|
@ -615,10 +681,17 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (party) {
|
if (party) {
|
||||||
|
const member = party.members.get(req.embySession!.userId);
|
||||||
|
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null;
|
||||||
|
// See PartyService.isStaleDevice's own comment for why this check
|
||||||
|
// exists at all - a REST call has no live connection to guard this
|
||||||
|
// with, unlike the socket "leave"/"disconnect" handlers.
|
||||||
|
if (!PartyService.isStaleDevice(member, deviceId)) {
|
||||||
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
||||||
// handoff notification and the lobby refresh only live in one place.
|
// handoff notification and the lobby refresh only live in one place.
|
||||||
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -641,7 +714,7 @@ app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
const session = req.embySession!;
|
const session = req.embySession!;
|
||||||
if (!party || !party.members.has(session.userId)) {
|
if (!party || !party.members.has(session.userId)) {
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ token: CastTokenService.issue(party.id, session) });
|
res.json({ token: CastTokenService.issue(party.id, session) });
|
||||||
|
|
@ -655,6 +728,229 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Static (persistent) watch parties ------------------------------------
|
||||||
|
// A whole series/video playlist watched by a group over several sessions,
|
||||||
|
// remembering the group's progress and each member's - see
|
||||||
|
// StaticPartyService and spec/static-watch-party.md. Each session is an
|
||||||
|
// ordinary live party (PartyService) linked back through party.staticLink.
|
||||||
|
|
||||||
|
/** Fetches a static party's source under `session`'s own account - so
|
||||||
|
* each item carries *their* Emby UserData (Played): the whole series
|
||||||
|
* from S1E1 (specials left out - see seriesQueueFrom), one season's
|
||||||
|
* episodes, or a playlist's video items. */
|
||||||
|
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
|
||||||
|
if (source.type === 'series') {
|
||||||
|
return seriesQueueFrom(await CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId));
|
||||||
|
}
|
||||||
|
if (source.type === 'season') {
|
||||||
|
if (!source.seriesId) return [];
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, source.seriesId);
|
||||||
|
return seasonQueue(episodes, { Id: source.sourceId, IndexNumber: source.seasonNumber });
|
||||||
|
}
|
||||||
|
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
|
||||||
|
return mediaType === 'video' ? items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetches `sp`'s source for `session`: merges any new item into it
|
||||||
|
* (StaticPartyService.refreshItems) and returns the ids Emby already has
|
||||||
|
* as Played for this user. Best-effort - Emby being unreachable must not
|
||||||
|
* make the page unusable, so a failure is logged and treated as "no
|
||||||
|
* Played info, no new items" (the in-app progress still works on its own).
|
||||||
|
*/
|
||||||
|
async function syncStaticSource(session: EmbySession, sp: StaticParty): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
const raw = await fetchStaticSourceItems(session, sp.source);
|
||||||
|
StaticPartyService.refreshItems(sp, raw.map(toVideoQueueItem));
|
||||||
|
return new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`source sync failed id=${sp.id} user=${Logger.who(session.userId, session.userName)}: ${(err as Error).message}`);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sp`'s live group session id, if that party is still running - clears a
|
||||||
|
* stale one (a party can end without its static party having heard). */
|
||||||
|
function liveGroupPartyId(sp: StaticParty): string | null {
|
||||||
|
if (sp.livePartyId && !PartyService.getById(sp.livePartyId)) StaticPartyService.setLiveParty(sp, null);
|
||||||
|
return sp.livePartyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds `:staticId` or answers 404 - null means the response is sent. */
|
||||||
|
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
|
||||||
|
const sp = StaticPartyService.getById(param(req, 'staticId'));
|
||||||
|
if (!sp) res.status(404).json({ error: 'Lounge party introuvable.' });
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const session = req.embySession!;
|
||||||
|
const parties = StaticPartyService.list();
|
||||||
|
const played = await Promise.all(parties.map((sp) => syncStaticSource(session, sp)));
|
||||||
|
res.json({
|
||||||
|
staticParties: parties.map((sp, i) => StaticPartyService.toSummary(sp, session.userId, played[i]!, liveGroupPartyId(sp)))
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const sourceId = req.body?.sourceId;
|
||||||
|
if (!sourceId || typeof sourceId !== 'string') {
|
||||||
|
res.status(400).json({ error: 'sourceId is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = req.embySession!;
|
||||||
|
const item = await CatalogService.getRawItem(session, sourceId);
|
||||||
|
|
||||||
|
// A whole series, one season of it, or a video playlist. An episode
|
||||||
|
// stands for its whole series - the natural entry point from
|
||||||
|
// views/series is the series itself, but nothing's lost accepting one.
|
||||||
|
// `sourceItem` is what gives the default name and the artwork: the
|
||||||
|
// series itself for a season (a season's own poster is often missing).
|
||||||
|
let source: StaticParty['source'];
|
||||||
|
let sourceItem = item;
|
||||||
|
let defaultName = item.Name;
|
||||||
|
if (item.Type === 'Series') {
|
||||||
|
source = { type: 'series', sourceId: item.Id };
|
||||||
|
} else if (item.Type === 'Season' && item.SeriesId) {
|
||||||
|
source = { type: 'season', sourceId: item.Id, seriesId: item.SeriesId, seasonNumber: item.IndexNumber };
|
||||||
|
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
||||||
|
defaultName = `${sourceItem.Name} - ${item.Name}`;
|
||||||
|
} else if (item.Type === 'Episode' && item.SeriesId) {
|
||||||
|
source = { type: 'series', sourceId: item.SeriesId };
|
||||||
|
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
||||||
|
defaultName = sourceItem.Name;
|
||||||
|
} else if (item.Type === 'Playlist') {
|
||||||
|
source = { type: 'playlist', sourceId: item.Id };
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Une lounge party se crée depuis une série, une saison ou une playlist vidéo.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fetchStaticSourceItems(session, source);
|
||||||
|
if (!raw.length) {
|
||||||
|
const emptyMessages = { series: "Cette série n'a aucun épisode.", season: "Cette saison n'a aucun épisode.", playlist: 'Cette playlist ne contient aucune vidéo.' };
|
||||||
|
res.status(400).json({ error: emptyMessages[source.type] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : defaultName;
|
||||||
|
const sp = StaticPartyService.create(
|
||||||
|
{ userId: session.userId, userName: session.userName },
|
||||||
|
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
|
||||||
|
);
|
||||||
|
const played = new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
|
||||||
|
res.json(StaticPartyService.toDetail(sp, session.userId, played, null));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/static-parties/:staticId', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
const session = req.embySession!;
|
||||||
|
const played = await syncStaticSource(session, sp);
|
||||||
|
res.json(StaticPartyService.toDetail(sp, session.userId, played, liveGroupPartyId(sp)));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/api/static-parties/:staticId/join', requireSession, (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
const session = req.embySession!;
|
||||||
|
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/static-parties/:staticId/leave', requireSession, (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
StaticPartyService.leave(sp, req.embySession!.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
|
||||||
|
res.status(403).json({ error: 'Seule la personne qui a créé cette lounge party peut la supprimer.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession, (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
const session = req.embySession!;
|
||||||
|
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
|
||||||
|
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette lounge party.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Launches the group's next session - or joins it, if one's already
|
||||||
|
// running. Whoever launches it hosts it (see PartyService.create), and it
|
||||||
|
// resumes exactly where the group stopped last time: the host's starting
|
||||||
|
// position is seeded from the saved one, which is what Player.ts already
|
||||||
|
// reads to place every member's first frame (see its init()).
|
||||||
|
app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
const session = req.embySession!;
|
||||||
|
const who = { userId: session.userId, userName: session.userName };
|
||||||
|
|
||||||
|
const existingId = liveGroupPartyId(sp);
|
||||||
|
if (!existingId) await syncStaticSource(session, sp); // pick up new episodes before building the queue
|
||||||
|
// Checked again after the await: someone else may have launched it meanwhile.
|
||||||
|
const liveId = liveGroupPartyId(sp);
|
||||||
|
const live = liveId ? PartyService.getById(liveId) : null;
|
||||||
|
if (live) {
|
||||||
|
PartyService.join(live, session.userId, session.userName);
|
||||||
|
PartyService.setMemberEmbyToken(live, session.userId, session.embyToken);
|
||||||
|
StaticPartyService.join(sp, who);
|
||||||
|
SocketGateway.broadcastLobby(io);
|
||||||
|
res.json({ partyId: live.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = StaticPartyService.groupQueue(sp);
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = queue[0]!;
|
||||||
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
||||||
|
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
|
||||||
|
party.members.get(session.userId)!.positionTicks = sp.group.resumePositionTicks;
|
||||||
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
StaticPartyService.join(sp, who);
|
||||||
|
StaticPartyService.setLiveParty(sp, party.id);
|
||||||
|
SocketGateway.broadcastLobby(io);
|
||||||
|
res.json({ partyId: party.id });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Catching up on missed items - alone (a party hidden from the lobby) or as
|
||||||
|
// an ordinary watch party others can join. Only the participants' own
|
||||||
|
// progress changes; the group's doesn't (see StaticPartyService.onItemCompleted).
|
||||||
|
app.post('/api/static-parties/:staticId/catch-up', requireSession, (req, res) => {
|
||||||
|
const sp = staticPartyOr404(req, res);
|
||||||
|
if (!sp) return;
|
||||||
|
const session = req.embySession!;
|
||||||
|
const mode = req.body?.mode === 'party' ? 'party' : 'solo';
|
||||||
|
const requested = new Set(Array.isArray(req.body?.itemIds) ? req.body.itemIds.filter((id: unknown) => typeof id === 'string') : []);
|
||||||
|
const queue = sp.items.filter((i) => requested.has(i.itemId)); // the static party's own order, never the caller's
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(400).json({ error: 'Aucun élément à rattraper.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = queue[0]!;
|
||||||
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
||||||
|
party.staticLink = { staticPartyId: sp.id, mode: 'catchUp' };
|
||||||
|
party.hiddenFromLobby = mode === 'solo';
|
||||||
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
||||||
|
Logger.staticParty(`catch-up started id=${sp.id} mode=${mode} partyId=${party.id} items=${queue.length} by=${Logger.who(session.userId, session.userName)}`);
|
||||||
|
if (!party.hiddenFromLobby) SocketGateway.broadcastLobby(io);
|
||||||
|
res.json({ partyId: party.id });
|
||||||
|
});
|
||||||
|
|
||||||
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
|
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
|
||||||
// CatalogFetchError carries its own status/message; anything else is an
|
// CatalogFetchError carries its own status/message; anything else is an
|
||||||
// unexpected bug, logged in full server-side but never leaked to the
|
// unexpected bug, logged in full server-side but never leaked to the
|
||||||
|
|
@ -723,6 +1019,9 @@ setInterval(() => {
|
||||||
for (const party of PartyService.listActive()) {
|
for (const party of PartyService.listActive()) {
|
||||||
const state = PartyService.getCurrentPlaybackState(party);
|
const state = PartyService.getCurrentPlaybackState(party);
|
||||||
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
|
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
|
||||||
|
// A static watch party's group session: remember where the group is,
|
||||||
|
// so the next session resumes there (no-op for any other party).
|
||||||
|
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
|
||||||
for (const member of party.members.values()) {
|
for (const member of party.members.values()) {
|
||||||
if (!member.connection || !member.embyToken) continue;
|
if (!member.connection || !member.embyToken) continue;
|
||||||
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
||||||
|
|
@ -759,6 +1058,16 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}, 20 * 1000);
|
}, 20 * 1000);
|
||||||
|
|
||||||
|
// A pending (debounced) static-party save must reach disk before the
|
||||||
|
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
Logger.staticParty(`${signal} received - flushing before exit`);
|
||||||
|
StaticPartyStore.flushSync();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
import { MusicQueueService } from '../services/MusicQueueService';
|
import { MusicQueueService } from '../services/MusicQueueService';
|
||||||
import { PartyService } from '../services/PartyService';
|
import { PartyService } from '../services/PartyService';
|
||||||
|
import { StaticPartyService } from '../services/StaticPartyService';
|
||||||
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
|
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,6 +36,9 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
|
||||||
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
||||||
* "playstate" (server -> client, to everyone in the party but the sender)
|
* "playstate" (server -> client, to everyone in the party but the sender)
|
||||||
* "next" (host only - advance the queue)
|
* "next" (host only - advance the queue)
|
||||||
|
* "itemEnded" (host only - the last queued item
|
||||||
|
* played to its end; counts it as
|
||||||
|
* completed without advancing)
|
||||||
* "itemChanged" (server -> client)
|
* "itemChanged" (server -> client)
|
||||||
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
|
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
|
||||||
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
|
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
|
||||||
|
|
@ -207,6 +211,17 @@ class SocketGatewayClass {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The last queued item played to its end - nothing to advance to,
|
||||||
|
// but it still counts as completed (Emby Played flag, static watch
|
||||||
|
// party progress). Host-only, same as "next".
|
||||||
|
socket.on('itemEnded', this.safe(() => {
|
||||||
|
if (!party || userId !== party.hostUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Logger.party(`item ended partyId=${party.id} item="${party.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
||||||
|
PartyService.completeCurrentItem(party);
|
||||||
|
}));
|
||||||
|
|
||||||
// Host-only, video-only: picks (or clears) the party's *shared*
|
// Host-only, video-only: picks (or clears) the party's *shared*
|
||||||
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
|
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
|
||||||
// own comment. Deliberately doesn't touch TranscodeService directly
|
// own comment. Deliberately doesn't touch TranscodeService directly
|
||||||
|
|
@ -410,8 +425,11 @@ class SocketGatewayClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
private lobbySnapshot(): LobbyPartyDto[] {
|
private lobbySnapshot(): LobbyPartyDto[] {
|
||||||
return PartyService.listActive().map((party) => {
|
// A solo catch-up session of a static watch party isn't something
|
||||||
|
// anyone else should see or join - see Party.hiddenFromLobby.
|
||||||
|
return PartyService.listActive().filter((party) => !party.hiddenFromLobby).map((party) => {
|
||||||
const host = party.members.get(party.hostUserId);
|
const host = party.members.get(party.hostUserId);
|
||||||
|
const staticParty = party.staticLink ? StaticPartyService.getById(party.staticLink.staticPartyId) : null;
|
||||||
// Video only - see LobbyPartyDto's own comment for why music parties
|
// Video only - see LobbyPartyDto's own comment for why music parties
|
||||||
// get null here and fall back to createdUtc client-side instead.
|
// get null here and fall back to createdUtc client-side instead.
|
||||||
// The host is authoritative for position/pause the same way they are
|
// The host is authoritative for position/pause the same way they are
|
||||||
|
|
@ -429,7 +447,9 @@ class SocketGatewayClass {
|
||||||
createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
|
createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
|
||||||
positionTicks: isVideo && host ? host.positionTicks : null,
|
positionTicks: isVideo && host ? host.positionTicks : null,
|
||||||
isPaused: isVideo && host ? host.isPaused : null,
|
isPaused: isVideo && host ? host.isPaused : null,
|
||||||
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null
|
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null,
|
||||||
|
staticPartyId: staticParty ? staticParty.id : null,
|
||||||
|
staticPartyName: staticParty ? staticParty.name : null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar
|
||||||
|
|
||||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||||
// contents (distinct from the generic 'Video', which is home videos).
|
// contents (distinct from the generic 'Video', which is home videos).
|
||||||
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
|
// 'Audio' is a music track. 'Series' isn't itself streamable, but
|
||||||
// directly playable - only show "start a party" for things Emby can
|
// launching one queues the whole series (see seriesQueueFrom and POST
|
||||||
// actually stream. "Voir sur Emby" always works though.
|
// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
|
||||||
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
// own pages have their own launch actions. "Voir sur Emby" always works.
|
||||||
|
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio', 'Series']);
|
||||||
|
|
||||||
// Emby's /Views can return all sorts of things beyond actual media
|
// Emby's /Views can return all sorts of things beyond actual media
|
||||||
// libraries - boxset collections, live TV, photos, books, a mixed-content
|
// libraries - boxset collections, live TV, photos, books, a mixed-content
|
||||||
|
|
@ -68,6 +69,36 @@ export function imageUrlFor(item: EmbyItem): string {
|
||||||
return `/api/catalog/image/${imageItemId}`;
|
return `/api/catalog/image/${imageItemId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a watch party queues from a series' full episode list (Emby's own
|
||||||
|
* order, every season - see getAllSeriesEpisodesRaw): everything from
|
||||||
|
* `fromEpisodeId` on, or - when launching the whole series - everything
|
||||||
|
* from its first regular episode on. Specials (season 0) sort first in
|
||||||
|
* Emby's list, so "the whole series" deliberately starts at S1E1, the
|
||||||
|
* same queue clicking S1E1's own button builds. An unknown
|
||||||
|
* `fromEpisodeId` yields an empty list (the caller falls back to the one
|
||||||
|
* item). Pure.
|
||||||
|
*/
|
||||||
|
export function seriesQueueFrom(episodes: EmbyItem[], fromEpisodeId?: string): EmbyItem[] {
|
||||||
|
if (fromEpisodeId) {
|
||||||
|
const index = episodes.findIndex((e) => e.Id === fromEpisodeId);
|
||||||
|
return index >= 0 ? episodes.slice(index) : [];
|
||||||
|
}
|
||||||
|
const firstRegular = episodes.findIndex((e) => e.ParentIndexNumber !== 0);
|
||||||
|
return firstRegular >= 0 ? episodes.slice(firstRegular) : episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just one season's episodes, in Emby's order, out of a series' full
|
||||||
|
* episode list - "launch this season". Matched by SeasonId, falling back
|
||||||
|
* to the season number for an episode Emby returned without one. Pure.
|
||||||
|
*/
|
||||||
|
export function seasonQueue(episodes: EmbyItem[], season: { Id: string; IndexNumber?: number }): EmbyItem[] {
|
||||||
|
return episodes.filter((e) => e.SeasonId
|
||||||
|
? e.SeasonId === season.Id
|
||||||
|
: season.IndexNumber != null && e.ParentIndexNumber === season.IndexNumber);
|
||||||
|
}
|
||||||
|
|
||||||
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
||||||
if (!items.length) return { mediaType: 'video', items: [] };
|
if (!items.length) return { mediaType: 'video', items: [] };
|
||||||
const isMusic = items[0]!.Type === 'Audio';
|
const isMusic = items[0]!.Type === 'Audio';
|
||||||
|
|
@ -125,9 +156,23 @@ class CatalogServiceClass {
|
||||||
* mediaType from the clicked item's real Emby Type (see index.ts).
|
* mediaType from the clicked item's real Emby Type (see index.ts).
|
||||||
* Unlike every other method here, this preserves Emby's own status
|
* Unlike every other method here, this preserves Emby's own status
|
||||||
* (typically a real 404) rather than a blanket 502 - "titre introuvable"
|
* (typically a real 404) rather than a blanket 502 - "titre introuvable"
|
||||||
* is a not-found, not an upstream failure. */
|
* is a not-found, not an upstream failure.
|
||||||
|
*
|
||||||
|
* `Fields=AlbumId` - without it, Emby's default field set for a single
|
||||||
|
* item omits it, so a track started directly (not via its album/
|
||||||
|
* artist - the one path that reaches imageUrlFor through *this*
|
||||||
|
* method, in index.ts's POST /api/parties) always missed imageUrlFor's
|
||||||
|
* own Audio -> AlbumId fallback and fell through to the track's own
|
||||||
|
* id instead - which, confirmed directly against a real server (see
|
||||||
|
* imageUrlFor's own comment), essentially never has a Primary image
|
||||||
|
* of its own. Reported directly as "the app really struggles to find
|
||||||
|
* artist/album/track images" - this is the one concrete, fixable
|
||||||
|
* piece of that: a party started this way now gets the same album
|
||||||
|
* art every other path already resolves correctly. Harmless for
|
||||||
|
* every non-Audio item this also fetches (Movie/Series/Episode/...) -
|
||||||
|
* they have no AlbumId to begin with, so Emby just omits the field. */
|
||||||
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
|
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
|
||||||
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
|
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
|
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { PartyStreamService } from './PartyStreamService';
|
||||||
import { PlaystateService } from './PlaystateService';
|
import { PlaystateService } from './PlaystateService';
|
||||||
import { CastTokenService } from './CastTokenService';
|
import { CastTokenService } from './CastTokenService';
|
||||||
import { EmbySessionService } from './EmbySessionService';
|
import { EmbySessionService } from './EmbySessionService';
|
||||||
|
import { StaticPartyService } from './StaticPartyService';
|
||||||
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -144,6 +145,30 @@ class PartyServiceClass {
|
||||||
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
|
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request claiming to be `deviceId` is stale for `member` -
|
||||||
|
* already replaced by a takeover on a *different* device (see
|
||||||
|
* SocketGateway's "join" handler). Used by the REST /leave route
|
||||||
|
* (index.ts), which - unlike the socket "leave"/"disconnect" handlers,
|
||||||
|
* guarded by comparing `member.connection` against the live socket
|
||||||
|
* itself - has no connection to compare against instead. Without this,
|
||||||
|
* a device already superseded by a takeover elsewhere could still call
|
||||||
|
* /leave and end the CURRENT device's membership by mistake - confirmed
|
||||||
|
* as the real cause of a "both devices end up on the session, controls
|
||||||
|
* never transfer" report: the stale device's own client didn't react to
|
||||||
|
* being disconnected at all (see Player.ts/MusicBar.ts's onClose), so
|
||||||
|
* its still-rendered "Quitter" button stayed clickable and reached here.
|
||||||
|
*
|
||||||
|
* Only ever returns true on positive evidence of a mismatch (both sides
|
||||||
|
* known and different) - a member with no recorded deviceId yet, or a
|
||||||
|
* caller that didn't send one, is never treated as stale, mirroring the
|
||||||
|
* socket handlers' own "nothing to conflict with yet" reasoning for a
|
||||||
|
* null deviceId.
|
||||||
|
*/
|
||||||
|
isStaleDevice(member: PartyMember | undefined, deviceId: string | null): boolean {
|
||||||
|
return !!(member?.deviceId && deviceId && member.deviceId !== deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a member. If they were the host, promotes whoever's left
|
* Removes a member. If they were the host, promotes whoever's left
|
||||||
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
||||||
|
|
@ -185,6 +210,7 @@ class PartyServiceClass {
|
||||||
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
||||||
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
|
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
|
||||||
PartyStreamService.endSession(party.id); // same - no-op if this party never negotiated a shared stream
|
PartyStreamService.endSession(party.id); // same - no-op if this party never negotiated a shared stream
|
||||||
|
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
|
||||||
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
||||||
} else if (wasHost && newHostUserId != null) {
|
} else if (wasHost && newHostUserId != null) {
|
||||||
const newHost = party.members.get(newHostUserId)!;
|
const newHost = party.members.get(newHostUserId)!;
|
||||||
|
|
@ -206,7 +232,9 @@ class PartyServiceClass {
|
||||||
if (party.queueIndex >= party.queue.length - 1) {
|
if (party.queueIndex >= party.queue.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
|
// Whatever was just playing is "done" - before the party's own state
|
||||||
|
// moves on. See completeCurrentItem's own comment.
|
||||||
|
this.completeCurrentItem(party);
|
||||||
party.queueIndex += 1;
|
party.queueIndex += 1;
|
||||||
const next = party.queue[party.queueIndex]!;
|
const next = party.queue[party.queueIndex]!;
|
||||||
party.itemId = next.itemId;
|
party.itemId = next.itemId;
|
||||||
|
|
@ -217,18 +245,6 @@ class PartyServiceClass {
|
||||||
member.isPaused = false;
|
member.isPaused = false;
|
||||||
member.lastUpdatedUtc = Date.now();
|
member.lastUpdatedUtc = Date.now();
|
||||||
}
|
}
|
||||||
// The party moving on from `outgoingItemId` is this app's own signal
|
|
||||||
// that it's "done" - see PlaystateService.markPlayed's own comment
|
|
||||||
// for why that's a heuristic, not a real "watched it all" check, and
|
|
||||||
// why every currently-connected member gets their own account
|
|
||||||
// updated, not just the host. Fire-and-forget, same as the periodic
|
|
||||||
// position sync in index.ts - a member's bad token or a transient
|
|
||||||
// Emby hiccup here must never block the party from actually moving
|
|
||||||
// on to the next item.
|
|
||||||
for (const member of party.members.values()) {
|
|
||||||
if (!member.connection || !member.embyToken) continue;
|
|
||||||
PlaystateService.markPlayed(member.embyToken, member.userId, outgoingItemId);
|
|
||||||
}
|
|
||||||
// A new item's own MediaStreams indices have nothing to do with the
|
// A new item's own MediaStreams indices have nothing to do with the
|
||||||
// old one's - carrying a burn-in or text subtitle pick across would
|
// old one's - carrying a burn-in or text subtitle pick across would
|
||||||
// show whatever track happens to share that index on the new item,
|
// show whatever track happens to share that index on the new item,
|
||||||
|
|
@ -253,6 +269,40 @@ class PartyServiceClass {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the party's current item as "done": the party moving on from it
|
||||||
|
* (advanceQueue) or the last queued item playing to its end (the host's
|
||||||
|
* "itemEnded" - see SocketGateway) is this app's own signal for that -
|
||||||
|
* see PlaystateService.markPlayed's own comment for why it's a
|
||||||
|
* heuristic, not a real "watched it all" check, and why every
|
||||||
|
* currently-connected member gets their own account updated, not just
|
||||||
|
* the host. Fire-and-forget, same as the periodic position sync in
|
||||||
|
* index.ts - a member's bad token or a transient Emby hiccup here must
|
||||||
|
* never block the party from actually moving on.
|
||||||
|
*
|
||||||
|
* For a session of a static watch party, also records the item as
|
||||||
|
* completed there (StaticPartyService.onItemCompleted) for every
|
||||||
|
* connected member. Idempotent per item (`lastCompletedItemId`), so an
|
||||||
|
* "itemEnded" and a later "next" on the same item never count twice.
|
||||||
|
*/
|
||||||
|
completeCurrentItem(party: Party): void {
|
||||||
|
const itemId = party.itemId;
|
||||||
|
if (party.lastCompletedItemId === itemId) return;
|
||||||
|
party.lastCompletedItemId = itemId;
|
||||||
|
const connected = Array.from(party.members.values()).filter((m) => !!m.connection);
|
||||||
|
for (const member of connected) {
|
||||||
|
if (!member.embyToken) continue;
|
||||||
|
PlaystateService.markPlayed(member.embyToken, member.userId, itemId);
|
||||||
|
}
|
||||||
|
if (party.staticLink) {
|
||||||
|
StaticPartyService.onItemCompleted(
|
||||||
|
party.staticLink,
|
||||||
|
itemId,
|
||||||
|
connected.map((m) => ({ userId: m.userId, userName: m.userName }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** All active parties - powers the "watch parties en cours" home section. */
|
/** All active parties - powers the "watch parties en cours" home section. */
|
||||||
listActive(): Party[] {
|
listActive(): Party[] {
|
||||||
return Array.from(this.partiesById.values());
|
return Array.from(this.partiesById.values());
|
||||||
|
|
@ -266,6 +316,7 @@ class PartyServiceClass {
|
||||||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||||
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
||||||
PartyStreamService.endSession(party.id);
|
PartyStreamService.endSession(party.id);
|
||||||
|
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
|
||||||
// Same reasoning, for any member whose own "leave" (a clean
|
// Same reasoning, for any member whose own "leave" (a clean
|
||||||
// disconnect) never fired for whatever reason - a real gap would
|
// disconnect) never fired for whatever reason - a real gap would
|
||||||
// otherwise leave a stale entry sitting in Emby's own dashboard
|
// otherwise leave a stale entry sitting in Emby's own dashboard
|
||||||
|
|
|
||||||
319
server/src/services/StaticPartyService.ts
Normal file
319
server/src/services/StaticPartyService.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import { StaticPartyStore } from './StaticPartyStore';
|
||||||
|
import type {
|
||||||
|
PersistedStaticParty,
|
||||||
|
QueueItem,
|
||||||
|
StaticParty,
|
||||||
|
StaticPartyDetailDto,
|
||||||
|
StaticPartyLink,
|
||||||
|
StaticPartyMember,
|
||||||
|
StaticPartySummaryDto
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
/** A connected participant, as reported by a live session. */
|
||||||
|
export interface Participant {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of `items` the viewer has missed, in the items' own order: every
|
||||||
|
* item the group already completed, minus what the viewer watched in-app
|
||||||
|
* (`watchedItemIds`) and minus what Emby itself already has as Played for
|
||||||
|
* them (`embyPlayedIds` - watched outside this app, or before this party
|
||||||
|
* existed). Pure - the one rule this whole feature is about, tested on
|
||||||
|
* its own (see staticPartyService.test.ts).
|
||||||
|
*/
|
||||||
|
export function computeMissedIds(
|
||||||
|
items: QueueItem[],
|
||||||
|
completedItemIds: string[],
|
||||||
|
watchedItemIds: string[],
|
||||||
|
embyPlayedIds: ReadonlySet<string>
|
||||||
|
): string[] {
|
||||||
|
const completed = new Set(completedItemIds);
|
||||||
|
const watched = new Set(watchedItemIds);
|
||||||
|
return items
|
||||||
|
.map((i) => i.itemId)
|
||||||
|
.filter((id) => completed.has(id) && !watched.has(id) && !embyPlayedIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a freshly fetched item list into a static party's existing one:
|
||||||
|
* keeps the existing order and every existing item (even one since
|
||||||
|
* removed from the source - the group's history shouldn't lose it),
|
||||||
|
* refreshes names/images, and appends anything new (a new episode, a
|
||||||
|
* playlist addition) at the end in the source's own order. Returns the
|
||||||
|
* merged list and how many items were added. Pure.
|
||||||
|
*/
|
||||||
|
export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { items: QueueItem[]; added: number } {
|
||||||
|
const freshById = new Map(fresh.map((i) => [i.itemId, i]));
|
||||||
|
const known = new Set(existing.map((i) => i.itemId));
|
||||||
|
const items = existing.map((i) => freshById.get(i.itemId) ?? i);
|
||||||
|
const additions = fresh.filter((i) => !known.has(i.itemId));
|
||||||
|
return { items: items.concat(additions), added: additions.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static (persistent) watch parties - a whole series or video playlist a
|
||||||
|
* group watches together over several sessions. Remembers the group's own
|
||||||
|
* progress (which items it completed, where it stopped) and each member's
|
||||||
|
* own (which items they watched), so someone coming back or joining late
|
||||||
|
* sees exactly what they missed. See spec/static-watch-party.md.
|
||||||
|
*
|
||||||
|
* A static party never plays anything itself: each session is an
|
||||||
|
* ordinary live Party (PartyService), created by index.ts and linked back
|
||||||
|
* here through `party.staticLink` - so the player, sync, shared stream,
|
||||||
|
* lobby and host handoff are all reused as-is. This service never imports
|
||||||
|
* PartyService (PartyService calls into this one), keeping the dependency
|
||||||
|
* one-way.
|
||||||
|
*
|
||||||
|
* Persisted through StaticPartyStore after every change - one file per
|
||||||
|
* party, so only the party that changed is ever rewritten.
|
||||||
|
*/
|
||||||
|
class StaticPartyServiceClass {
|
||||||
|
/** Every static party, by id. */
|
||||||
|
private partiesById = new Map<string, StaticParty>();
|
||||||
|
|
||||||
|
/** Loads every persisted party - once, at startup. */
|
||||||
|
public init(): void {
|
||||||
|
this.partiesById.clear();
|
||||||
|
for (const persisted of StaticPartyStore.load()) {
|
||||||
|
this.partiesById.set(persisted.id, { ...persisted, livePartyId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every static party, most recently active first. */
|
||||||
|
public list(): StaticParty[] {
|
||||||
|
return Array.from(this.partiesById.values()).sort((a, b) => this.lastActivity(b) - this.lastActivity(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One static party, or null. */
|
||||||
|
public getById(id: string): StaticParty | null {
|
||||||
|
return this.partiesById.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a static party over `items` - the creator is its first member. */
|
||||||
|
public create(
|
||||||
|
creator: Participant,
|
||||||
|
source: StaticParty['source'],
|
||||||
|
name: string,
|
||||||
|
imageUrl: string,
|
||||||
|
items: QueueItem[]
|
||||||
|
): StaticParty {
|
||||||
|
const now = Date.now();
|
||||||
|
const sp: StaticParty = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
imageUrl,
|
||||||
|
createdByUserId: creator.userId,
|
||||||
|
createdUtc: now,
|
||||||
|
items,
|
||||||
|
group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null },
|
||||||
|
members: {},
|
||||||
|
livePartyId: null
|
||||||
|
};
|
||||||
|
sp.members[creator.userId] = this.newMember(creator);
|
||||||
|
this.partiesById.set(sp.id, sp);
|
||||||
|
Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`);
|
||||||
|
this.persist(sp);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds `who` as a member - idempotent (refreshes their name only). */
|
||||||
|
public join(sp: StaticParty, who: Participant): StaticPartyMember {
|
||||||
|
const existing = sp.members[who.userId];
|
||||||
|
if (existing) {
|
||||||
|
existing.userName = who.userName;
|
||||||
|
existing.lastSeenUtc = Date.now();
|
||||||
|
this.persist(sp);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const member = this.newMember(who);
|
||||||
|
sp.members[who.userId] = member;
|
||||||
|
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
|
||||||
|
this.persist(sp);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a member - and their progress. The party itself stays, even
|
||||||
|
* empty: only its creator deletes it (see delete()). */
|
||||||
|
public leave(sp: StaticParty, userId: string): void {
|
||||||
|
const member = sp.members[userId];
|
||||||
|
if (!member) return;
|
||||||
|
delete sp.members[userId];
|
||||||
|
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
|
||||||
|
this.persist(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the party for good - creator only. Returns false (and does
|
||||||
|
* nothing) for anyone else. */
|
||||||
|
public delete(sp: StaticParty, userId: string): boolean {
|
||||||
|
if (sp.createdByUserId !== userId) {
|
||||||
|
Logger.staticParty(`delete refused id=${sp.id} user=${userId} (not the creator)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.partiesById.delete(sp.id);
|
||||||
|
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
|
||||||
|
StaticPartyStore.remove(sp.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges a fresh fetch of the source into `sp.items` (see
|
||||||
|
* mergeItemLists) - new episodes/playlist entries appear at the end. */
|
||||||
|
public refreshItems(sp: StaticParty, fresh: QueueItem[]): void {
|
||||||
|
if (!fresh.length) return;
|
||||||
|
const { items, added } = mergeItemLists(sp.items, fresh);
|
||||||
|
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
|
||||||
|
sp.items = items;
|
||||||
|
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
|
||||||
|
if (changed) this.persist(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the group's next session plays: everything from the group's
|
||||||
|
* next item on. Empty once the group has seen everything. */
|
||||||
|
public groupQueue(sp: StaticParty): QueueItem[] {
|
||||||
|
return sp.items.slice(sp.group.nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records which live party is the group's current session (or none). */
|
||||||
|
public setLiveParty(sp: StaticParty, partyId: string | null): void {
|
||||||
|
sp.livePartyId = partyId;
|
||||||
|
if (partyId) {
|
||||||
|
sp.group.lastSessionUtc = Date.now();
|
||||||
|
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
|
||||||
|
this.persist(sp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A linked live session finished `itemId` (played to its end, or the
|
||||||
|
* host moved past it - the same heuristic as PlaystateService.markPlayed).
|
||||||
|
* Every connected participant gets it as watched (and becomes a member
|
||||||
|
* if they joined the live session without joining the static party
|
||||||
|
* first). In `group` mode the group itself also moves past it; in
|
||||||
|
* `catchUp` mode only those participants' own progress changes.
|
||||||
|
*/
|
||||||
|
public onItemCompleted(link: StaticPartyLink, itemId: string, connected: Participant[]): void {
|
||||||
|
const sp = this.partiesById.get(link.staticPartyId);
|
||||||
|
if (!sp) return; // deleted while a session was still running
|
||||||
|
for (const who of connected) {
|
||||||
|
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
|
||||||
|
member.lastSeenUtc = Date.now();
|
||||||
|
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
|
||||||
|
}
|
||||||
|
if (link.mode === 'group') {
|
||||||
|
if (!sp.group.completedItemIds.includes(itemId)) sp.group.completedItemIds.push(itemId);
|
||||||
|
const index = sp.items.findIndex((i) => i.itemId === itemId);
|
||||||
|
if (index >= 0 && index + 1 > sp.group.nextIndex) sp.group.nextIndex = index + 1;
|
||||||
|
sp.group.resumePositionTicks = 0;
|
||||||
|
}
|
||||||
|
Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`);
|
||||||
|
this.persist(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group session's current position - saved so the next session
|
||||||
|
* resumes there. Ignored for anything but the group's next item. */
|
||||||
|
public recordPosition(link: StaticPartyLink, itemId: string, positionTicks: number): void {
|
||||||
|
if (link.mode !== 'group') return;
|
||||||
|
const sp = this.partiesById.get(link.staticPartyId);
|
||||||
|
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
|
||||||
|
if (sp.group.resumePositionTicks === positionTicks) return;
|
||||||
|
sp.group.resumePositionTicks = positionTicks;
|
||||||
|
this.persist(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A linked live party ended - forget it as the group's live session. */
|
||||||
|
public onPartyDestroyed(link: StaticPartyLink, partyId: string): void {
|
||||||
|
const sp = this.partiesById.get(link.staticPartyId);
|
||||||
|
if (!sp || sp.livePartyId !== partyId) return;
|
||||||
|
sp.livePartyId = null;
|
||||||
|
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
|
||||||
|
this.persist(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
|
||||||
|
public markWatched(sp: StaticParty, who: Participant, itemId: string): boolean {
|
||||||
|
if (!sp.items.some((i) => i.itemId === itemId)) return false;
|
||||||
|
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
|
||||||
|
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
|
||||||
|
member.lastSeenUtc = Date.now();
|
||||||
|
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
|
||||||
|
this.persist(sp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `viewerUserId`'s missed items - see computeMissedIds. */
|
||||||
|
public missedIds(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>): string[] {
|
||||||
|
const watched = sp.members[viewerUserId]?.watchedItemIds ?? [];
|
||||||
|
return computeMissedIds(sp.items, sp.group.completedItemIds, watched, embyPlayedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list-view shape of `sp` for `viewerUserId`. `livePartyId` is
|
||||||
|
* passed in already checked against PartyService (still alive). */
|
||||||
|
public toSummary(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartySummaryDto {
|
||||||
|
return {
|
||||||
|
id: sp.id,
|
||||||
|
name: sp.name,
|
||||||
|
sourceType: sp.source.type,
|
||||||
|
imageUrl: sp.imageUrl,
|
||||||
|
itemCount: sp.items.length,
|
||||||
|
groupWatchedCount: sp.group.completedItemIds.length,
|
||||||
|
nextItemName: sp.items[sp.group.nextIndex]?.itemName ?? null,
|
||||||
|
memberCount: Object.keys(sp.members).length,
|
||||||
|
youAreMember: !!sp.members[viewerUserId],
|
||||||
|
youAreCreator: sp.createdByUserId === viewerUserId,
|
||||||
|
missedCount: this.missedIds(sp, viewerUserId, embyPlayedIds).length,
|
||||||
|
livePartyId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The detail-view shape of `sp` for `viewerUserId`. */
|
||||||
|
public toDetail(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartyDetailDto {
|
||||||
|
const completed = new Set(sp.group.completedItemIds);
|
||||||
|
const viewerWatched = new Set(sp.members[viewerUserId]?.watchedItemIds ?? []);
|
||||||
|
return {
|
||||||
|
...this.toSummary(sp, viewerUserId, embyPlayedIds, livePartyId),
|
||||||
|
items: sp.items.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
groupState: completed.has(item.itemId) ? 'watched' : index === sp.group.nextIndex ? 'next' : 'upcoming',
|
||||||
|
viewerWatched: viewerWatched.has(item.itemId) || embyPlayedIds.has(item.itemId)
|
||||||
|
})),
|
||||||
|
missedItemIds: this.missedIds(sp, viewerUserId, embyPlayedIds),
|
||||||
|
members: Object.values(sp.members).map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
userName: m.userName,
|
||||||
|
watchedCount: m.watchedItemIds.length,
|
||||||
|
isCreator: m.userId === sp.createdByUserId
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
|
||||||
|
* party that changed is ever rewritten. */
|
||||||
|
private persist(sp: StaticParty): void {
|
||||||
|
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sp` minus runtime-only state. */
|
||||||
|
private snapshotOf(sp: StaticParty): PersistedStaticParty {
|
||||||
|
const { livePartyId: _live, ...persisted } = sp;
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When `sp` last saw anything happen - for list ordering. */
|
||||||
|
private lastActivity(sp: StaticParty): number {
|
||||||
|
return Math.max(sp.createdUtc, sp.group.lastSessionUtc ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh member record for `who`. */
|
||||||
|
private newMember(who: Participant): StaticPartyMember {
|
||||||
|
const now = Date.now();
|
||||||
|
return { userId: who.userId, userName: who.userName, joinedUtc: now, watchedItemIds: [], lastSeenUtc: now };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StaticPartyService = new StaticPartyServiceClass();
|
||||||
272
server/src/services/StaticPartyStore.ts
Normal file
272
server/src/services/StaticPartyStore.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import type { PersistedStaticParty } from '../types';
|
||||||
|
|
||||||
|
/** One party's own file - versioned so a future format change can migrate
|
||||||
|
* instead of silently misreading an older file. */
|
||||||
|
interface StaticPartyFile {
|
||||||
|
version: 1;
|
||||||
|
party: PersistedStaticParty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first release's single-file format - only read once, to migrate. */
|
||||||
|
interface LegacyStaticPartiesFile {
|
||||||
|
version: 1;
|
||||||
|
parties: PersistedStaticParty[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a save waits for more changes to the same party before
|
||||||
|
* writing - progress updates arrive in bursts (a whole party's members
|
||||||
|
* at once, the 20s position sweep). */
|
||||||
|
const SAVE_DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
|
/** Party ids become file names - anything else is refused outright, so a
|
||||||
|
* crafted id can never reach outside the directory. */
|
||||||
|
const SAFE_ID = /^[A-Za-z0-9-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The static watch parties' persistence: **one JSON file per party**,
|
||||||
|
* `${DATA_DIR}/static-parties/<id>.json` - the first and only thing this
|
||||||
|
* app keeps on disk (everything else is deliberately in-memory - see
|
||||||
|
* docs/architecture.md's "Party lifecycle").
|
||||||
|
*
|
||||||
|
* One file per party (rather than a single file for all of them) limits
|
||||||
|
* what any one problem can damage: a write only ever touches the party
|
||||||
|
* that changed, and a corrupt file costs that one party, never the rest.
|
||||||
|
* Each write is also atomic (a `.tmp` sibling, then a rename over the
|
||||||
|
* real file), so a crash mid-write leaves the previous version intact.
|
||||||
|
*
|
||||||
|
* Writes are debounced per party (SAVE_DEBOUNCE_MS). A corrupt file is
|
||||||
|
* logged and moved aside (`.corrupt`) rather than overwritten or
|
||||||
|
* silently dropped - the server must never refuse to start over one, and
|
||||||
|
* nothing is lost for good. The first release's single
|
||||||
|
* `static-parties.json` is migrated once, on the first load that finds it.
|
||||||
|
*
|
||||||
|
* JSON files rather than a database: the data is tiny, the runtime image
|
||||||
|
* is Node 20 (no built-in `node:sqlite`), and a native SQLite binding would
|
||||||
|
* be the image's first compiled dependency - not worth it at this size.
|
||||||
|
*/
|
||||||
|
class StaticPartyStoreClass {
|
||||||
|
/** `${DATA_DIR}/static-parties` - see configure(). */
|
||||||
|
private dir: string | null = null;
|
||||||
|
/** The first release's single file, migrated on load if still present. */
|
||||||
|
private legacyFile: string | null = null;
|
||||||
|
/** Pending debounced saves, by party id. */
|
||||||
|
private timers = new Map<string, NodeJS.Timeout>();
|
||||||
|
/** What each pending save will write - always the latest snapshot. */
|
||||||
|
private pending = new Map<string, () => PersistedStaticParty>();
|
||||||
|
|
||||||
|
/** Points the store at `dataDir`. Must be called before anything else -
|
||||||
|
* index.ts does it at startup, tests with a temp directory. */
|
||||||
|
public configure(dataDir: string): void {
|
||||||
|
this.dir = path.resolve(dataDir, 'static-parties');
|
||||||
|
this.legacyFile = path.resolve(dataDir, 'static-parties.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads every persisted party - synchronous, startup-only. */
|
||||||
|
public load(): PersistedStaticParty[] {
|
||||||
|
const dir = this.requireDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
this.migrateLegacyFile();
|
||||||
|
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`could not list ${dir} - starting empty: ${(err as Error).message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parties: PersistedStaticParty[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
if (name.endsWith('.tmp')) {
|
||||||
|
// A write interrupted before its rename - the real file next to it
|
||||||
|
// (if any) is still the last complete version.
|
||||||
|
this.tryUnlink(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.endsWith('.json')) continue; // .corrupt files and anything else
|
||||||
|
const party = this.readPartyFile(file);
|
||||||
|
if (party) parties.push(party);
|
||||||
|
}
|
||||||
|
Logger.staticParty(`loaded ${parties.length} static party(ies) from ${dir}`);
|
||||||
|
return parties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedules a debounced write of `id`'s own file, from whatever
|
||||||
|
* `snapshot` returns at write time (so a burst of changes only ever
|
||||||
|
* writes the final state). */
|
||||||
|
public save(id: string, snapshot: () => PersistedStaticParty): void {
|
||||||
|
if (!SAFE_ID.test(id)) {
|
||||||
|
Logger.staticParty(`save refused: unsafe id "${id}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pending.set(id, snapshot);
|
||||||
|
if (this.timers.has(id)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.timers.delete(id);
|
||||||
|
this.writeOne(id).catch((err: unknown) => {
|
||||||
|
Logger.staticParty(`save failed id=${id}: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
timer.unref(); // a pending save must never keep a test process (or a shutdown) alive
|
||||||
|
this.timers.set(id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes `id`'s file - and cancels any save still pending for it, so
|
||||||
|
* a debounced write can't recreate a party that was just deleted. */
|
||||||
|
public remove(id: string): void {
|
||||||
|
const timer = this.timers.get(id);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
this.timers.delete(id);
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (!SAFE_ID.test(id)) return;
|
||||||
|
const file = this.fileFor(id);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
Logger.staticParty(`removed ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes every pending save immediately and synchronously - for a
|
||||||
|
* graceful shutdown (SIGTERM/SIGINT), where an async write could be cut
|
||||||
|
* short by the process exiting. */
|
||||||
|
public flushSync(): void {
|
||||||
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||||
|
this.timers.clear();
|
||||||
|
const pending = Array.from(this.pending.entries());
|
||||||
|
this.pending.clear();
|
||||||
|
for (const [id, snapshot] of pending) {
|
||||||
|
const file = this.fileFor(id);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
||||||
|
fs.renameSync(`${file}.tmp`, file);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`flush failed id=${id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.length) Logger.staticParty(`flushed ${pending.length} static party(ies)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes every pending save now (async) - lets tests wait for disk. */
|
||||||
|
public async writeNow(): Promise<void> {
|
||||||
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||||
|
this.timers.clear();
|
||||||
|
const ids = Array.from(this.pending.keys());
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => this.writeOne(id)));
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`writeNow failed: ${(err as Error).message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
|
||||||
|
private async writeOne(id: string): Promise<void> {
|
||||||
|
const snapshot = this.pending.get(id);
|
||||||
|
if (!snapshot) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
const file = this.fileFor(id);
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
||||||
|
await fs.promises.rename(`${file}.tmp`, file);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`write to ${file} failed: ${(err as Error).message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One party file's content, or null (moved aside) if it's unreadable. */
|
||||||
|
private readPartyFile(file: string): PersistedStaticParty | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<StaticPartyFile>;
|
||||||
|
const party = parsed?.party;
|
||||||
|
if (!party || typeof party.id !== 'string' || !Array.isArray(party.items)) {
|
||||||
|
throw new Error('missing or malformed "party"');
|
||||||
|
}
|
||||||
|
if (path.basename(file) !== `${party.id}.json`) throw new Error(`file name does not match id ${party.id}`);
|
||||||
|
return party;
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`corrupt file ${file} - moved aside to .corrupt, skipped: ${(err as Error).message}`);
|
||||||
|
try {
|
||||||
|
fs.renameSync(file, `${file}.corrupt`);
|
||||||
|
} catch (renameErr) {
|
||||||
|
Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first release kept every party in one `static-parties.json` -
|
||||||
|
* split it into one file per party, then rename it to `.migrated` (kept,
|
||||||
|
* not deleted, in case anything went wrong). A party that already has
|
||||||
|
* its own file is left alone: that file is newer.
|
||||||
|
*/
|
||||||
|
private migrateLegacyFile(): void {
|
||||||
|
const legacy = this.legacyFile!;
|
||||||
|
if (!fs.existsSync(legacy)) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(legacy, 'utf8')) as Partial<LegacyStaticPartiesFile>;
|
||||||
|
const parties = Array.isArray(parsed?.parties) ? parsed.parties : [];
|
||||||
|
let migrated = 0;
|
||||||
|
for (const party of parties) {
|
||||||
|
if (!party || typeof party.id !== 'string' || !SAFE_ID.test(party.id)) continue;
|
||||||
|
const file = this.fileFor(party.id);
|
||||||
|
if (fs.existsSync(file)) continue;
|
||||||
|
fs.writeFileSync(`${file}.tmp`, this.serialize(party), 'utf8');
|
||||||
|
fs.renameSync(`${file}.tmp`, file);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
fs.renameSync(legacy, `${legacy}.migrated`);
|
||||||
|
Logger.staticParty(`migrated ${migrated} static party(ies) from ${legacy} to one file each (original kept as .migrated)`);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`could not migrate ${legacy} - left in place: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `id`'s own file path. */
|
||||||
|
private fileFor(id: string): string {
|
||||||
|
return path.join(this.requireDir(), `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A party file's JSON text. */
|
||||||
|
private serialize(party: PersistedStaticParty): string {
|
||||||
|
const data: StaticPartyFile = { version: 1, party };
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort delete - logs anything but "already gone". */
|
||||||
|
private tryUnlink(file: string): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configured directory - throws if configure() was never called. */
|
||||||
|
private requireDir(): string {
|
||||||
|
if (!this.dir) throw new Error('StaticPartyStore.configure() was never called');
|
||||||
|
return this.dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StaticPartyStore = new StaticPartyStoreClass();
|
||||||
|
|
@ -29,6 +29,11 @@ export interface ClientToServerEvents {
|
||||||
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
|
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
|
||||||
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
||||||
next: () => void;
|
next: () => void;
|
||||||
|
/** Host-only (checked server-side exactly like "next") - the last
|
||||||
|
* queued item played to its end, so there's no "next" to send. Lets
|
||||||
|
* the server count that final item as completed (PartyService
|
||||||
|
* .completeCurrentItem) without advancing anything. */
|
||||||
|
itemEnded: () => void;
|
||||||
voteSkip: () => void;
|
voteSkip: () => void;
|
||||||
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
||||||
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
|
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
|
||||||
|
|
@ -168,6 +173,8 @@ export interface EmbyItem {
|
||||||
Type?: string;
|
Type?: string;
|
||||||
SeriesId?: string;
|
SeriesId?: string;
|
||||||
SeriesName?: string;
|
SeriesName?: string;
|
||||||
|
/** An episode's own season - see CatalogService.seasonQueue. */
|
||||||
|
SeasonId?: string;
|
||||||
ParentIndexNumber?: number;
|
ParentIndexNumber?: number;
|
||||||
IndexNumber?: number;
|
IndexNumber?: number;
|
||||||
RunTimeTicks?: number;
|
RunTimeTicks?: number;
|
||||||
|
|
@ -180,6 +187,11 @@ export interface EmbyItem {
|
||||||
* one sampled 500'd), only its album does - this is how an image URL
|
* one sampled 500'd), only its album does - this is how an image URL
|
||||||
* falls back to the album's art instead. */
|
* falls back to the album's art instead. */
|
||||||
AlbumId?: string;
|
AlbumId?: string;
|
||||||
|
/** The requesting user's own per-item state - Emby embeds it on every
|
||||||
|
* item fetched with a userId, unasked (see PlaystateService's own
|
||||||
|
* comment). Only `Played` is read, by the static watch party's
|
||||||
|
* "missed items" computation (StaticPartyService). */
|
||||||
|
UserData?: { Played?: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
|
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
|
||||||
|
|
@ -359,6 +371,25 @@ export interface Party {
|
||||||
* MusicBar.ts never had this picker in the first place. */
|
* MusicBar.ts never had this picker in the first place. */
|
||||||
streamAudioIndex: string | null;
|
streamAudioIndex: string | null;
|
||||||
streamQuality: string | null;
|
streamQuality: string | null;
|
||||||
|
/** Set only for a live session launched from a static watch party (see
|
||||||
|
* StaticPartyService) - absent for every ordinary party. */
|
||||||
|
staticLink?: StaticPartyLink;
|
||||||
|
/** Kept out of the home page's live lobby list (SocketGateway
|
||||||
|
* .lobbySnapshot) - a solo catch-up session of a static watch party. */
|
||||||
|
hiddenFromLobby?: boolean;
|
||||||
|
/** The last item this party already reported as completed (see
|
||||||
|
* PartyService.completeCurrentItem) - guards against an "itemEnded"
|
||||||
|
* followed by a "next" on the same item counting it twice. */
|
||||||
|
lastCompletedItemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a live party relates to the static watch party it was launched
|
||||||
|
* from - `group` is the group's own session (advances the group's
|
||||||
|
* progress), `catchUp` is someone catching up on items they missed
|
||||||
|
* (only marks those items watched for whoever was connected). */
|
||||||
|
export interface StaticPartyLink {
|
||||||
|
staticPartyId: string;
|
||||||
|
mode: 'group' | 'catchUp';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicMember {
|
export interface PublicMember {
|
||||||
|
|
@ -415,6 +446,92 @@ export interface LobbyPartyDto {
|
||||||
positionTicks: number | null;
|
positionTicks: number | null;
|
||||||
isPaused: boolean | null;
|
isPaused: boolean | null;
|
||||||
positionUpdatedUtc: number | null;
|
positionUpdatedUtc: number | null;
|
||||||
|
/** The static watch party this live session belongs to, if any - shown
|
||||||
|
* as a badge on the home page's lobby card. */
|
||||||
|
staticPartyId: string | null;
|
||||||
|
staticPartyName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Static (persistent) watch party ------------------------------------------
|
||||||
|
|
||||||
|
/** A lounge party's source: a whole series, one season of it, or a
|
||||||
|
* video playlist. */
|
||||||
|
export type StaticPartySourceType = 'series' | 'season' | 'playlist';
|
||||||
|
|
||||||
|
/** One member of a static watch party - persisted, independent of any
|
||||||
|
* live connection (unlike PartyMember). */
|
||||||
|
export interface StaticPartyMember {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
joinedUtc: number;
|
||||||
|
/** Items this member watched in-app: with the group, in a catch-up
|
||||||
|
* session, or marked watched by hand. Emby's own Played flag is
|
||||||
|
* combined with this at read time, never copied into it. */
|
||||||
|
watchedItemIds: string[];
|
||||||
|
lastSeenUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group's own progress through a static watch party's items. */
|
||||||
|
export interface StaticPartyGroupProgress {
|
||||||
|
/** Index into `items` of the next item the group will watch. */
|
||||||
|
nextIndex: number;
|
||||||
|
/** Where the group stopped inside items[nextIndex] - applied as the
|
||||||
|
* host's starting position on the next session. */
|
||||||
|
resumePositionTicks: number;
|
||||||
|
completedItemIds: string[];
|
||||||
|
lastSessionUtc: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persistent watch party over a whole series or video playlist - see
|
||||||
|
* StaticPartyService and spec/static-watch-party.md. */
|
||||||
|
export interface StaticParty {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** What the items come from. A `season` also records its series
|
||||||
|
* (`seriesId`) and number (`seasonNumber`) - episodes are fetched per
|
||||||
|
* series, then filtered (see CatalogService.seasonQueue). */
|
||||||
|
source: { type: StaticPartySourceType; sourceId: string; seriesId?: string; seasonNumber?: number };
|
||||||
|
imageUrl: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdUtc: number;
|
||||||
|
items: QueueItem[];
|
||||||
|
group: StaticPartyGroupProgress;
|
||||||
|
members: Record<string, StaticPartyMember>;
|
||||||
|
/** Runtime only (never persisted) - the live group session, if any. */
|
||||||
|
livePartyId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persisted shape of a StaticParty - everything but runtime state. */
|
||||||
|
export type PersistedStaticParty = Omit<StaticParty, 'livePartyId'>;
|
||||||
|
|
||||||
|
/** One item of a static watch party, as the client sees it. */
|
||||||
|
export interface StaticPartyItemDto extends QueueItem {
|
||||||
|
groupState: 'watched' | 'next' | 'upcoming';
|
||||||
|
/** Whether the viewer has watched it (in-app or Emby's Played flag). */
|
||||||
|
viewerWatched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/static-parties list entry. */
|
||||||
|
export interface StaticPartySummaryDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourceType: StaticPartySourceType;
|
||||||
|
imageUrl: string;
|
||||||
|
itemCount: number;
|
||||||
|
groupWatchedCount: number;
|
||||||
|
nextItemName: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
youAreMember: boolean;
|
||||||
|
youAreCreator: boolean;
|
||||||
|
missedCount: number;
|
||||||
|
livePartyId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/static-parties/:id - the summary plus every item and member. */
|
||||||
|
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
|
||||||
|
items: StaticPartyItemDto[];
|
||||||
|
missedItemIds: string[];
|
||||||
|
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaveResult {
|
export interface LeaveResult {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
||||||
import { EmbyClient } from '../src/emby/EmbyClient';
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||||
import type { EmbyItem, EmbySession } from '../src/types';
|
import type { EmbyItem, EmbySession } from '../src/types';
|
||||||
|
|
||||||
|
|
@ -97,6 +97,18 @@ describe('CatalogService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getRawItem requests Fields=AlbumId - without it, a track started directly (not via its album) always missed imageUrlFor\'s own fallback', async (t) => {
|
||||||
|
let requestedPath = '';
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async (pathAndQuery: string) => {
|
||||||
|
requestedPath = pathAndQuery;
|
||||||
|
return jsonResponse({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await CatalogService.getRawItem(session, 't1');
|
||||||
|
|
||||||
|
assert.ok(requestedPath.includes('Fields=AlbumId'), requestedPath);
|
||||||
|
});
|
||||||
|
|
||||||
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not text-based, real text subtitles as fine', async (t) => {
|
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not text-based, real text subtitles as fine', async (t) => {
|
||||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
MediaSources: [{
|
MediaSources: [{
|
||||||
|
|
@ -302,3 +314,34 @@ describe('imageUrlFor', () => {
|
||||||
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
|
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('seriesQueueFrom / seasonQueue', () => {
|
||||||
|
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
|
||||||
|
// Emby's own order: specials (season 0) first, then every season in turn.
|
||||||
|
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
|
||||||
|
|
||||||
|
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a series with only specials still queues them', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from an episode, queues it and everything after it, across seasons', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a season queues only its own episodes', () => {
|
||||||
|
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a season falls back to the season number for episodes without a SeasonId', () => {
|
||||||
|
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
|
||||||
|
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,41 @@ describe('PartyService', () => {
|
||||||
assert.equal(called, false);
|
assert.equal(called, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the deviceId matches the member\'s own', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a';
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): true when the deviceId differs from the member\'s own - the actual takeover case', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a'; // device B's takeover already moved this forward
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-b'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the member has no recorded deviceId yet - nothing to conflict with, same reasoning as the socket "join" handler', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!; // deviceId still null - no socket "join" has succeeded yet
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the caller sent no deviceId at all - only ever blocks on positive evidence of a mismatch', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a';
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false for an unknown member (already left some other way)', () => {
|
||||||
|
assert.equal(PartyService.isStaleDevice(undefined, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
||||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
|
|
||||||
299
server/test/staticPartyService.test.ts
Normal file
299
server/test/staticPartyService.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { describe, it, before, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { StaticPartyService, computeMissedIds, mergeItemLists } from '../src/services/StaticPartyService';
|
||||||
|
import { StaticPartyStore } from '../src/services/StaticPartyStore';
|
||||||
|
import { PartyService } from '../src/services/PartyService';
|
||||||
|
import { PlaystateService } from '../src/services/PlaystateService';
|
||||||
|
import type { AppSocket, QueueItem } from '../src/types';
|
||||||
|
|
||||||
|
const item = (id: string): QueueItem => ({ itemId: id, itemName: `Episode ${id}`, itemImageUrl: `/img/${id}` });
|
||||||
|
const ITEMS = ['e1', 'e2', 'e3', 'e4'].map(item);
|
||||||
|
const alice = { userId: 'u-alice', userName: 'Alice' };
|
||||||
|
const bob = { userId: 'u-bob', userName: 'Bob' };
|
||||||
|
const NONE: ReadonlySet<string> = new Set();
|
||||||
|
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-static-'));
|
||||||
|
StaticPartyStore.configure(dataDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
const partiesDir = () => path.join(dataDir, 'static-parties');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
|
||||||
|
fs.rmSync(partiesDir(), { recursive: true, force: true });
|
||||||
|
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
|
||||||
|
StaticPartyService.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeMissedIds', () => {
|
||||||
|
it('is every group-completed item, in the items\' own order', () => {
|
||||||
|
assert.deepEqual(computeMissedIds(ITEMS, ['e2', 'e1'], [], NONE), ['e1', 'e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops what the viewer watched in-app and what Emby already has as Played', () => {
|
||||||
|
assert.deepEqual(computeMissedIds(ITEMS, ['e1', 'e2', 'e3'], ['e1'], new Set(['e3'])), ['e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lists an item the group has not completed yet, even if the viewer watched ahead', () => {
|
||||||
|
assert.deepEqual(computeMissedIds(ITEMS, ['e1'], ['e4'], NONE), ['e1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeItemLists', () => {
|
||||||
|
it('keeps the existing order, appends new items and keeps ones gone from the source', () => {
|
||||||
|
const { items, added } = mergeItemLists([item('e1'), item('e2')], [item('e2'), item('e3')]);
|
||||||
|
assert.deepEqual(items.map((i) => i.itemId), ['e1', 'e2', 'e3']);
|
||||||
|
assert.equal(added, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a known item\'s name from the fresh fetch', () => {
|
||||||
|
const { items } = mergeItemLists([item('e1')], [{ ...item('e1'), itemName: 'Renamed' }]);
|
||||||
|
assert.equal(items[0]!.itemName, 'Renamed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StaticPartyService', () => {
|
||||||
|
it('create() makes the creator the first member, with nothing watched yet', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
assert.deepEqual(Object.keys(sp.members), ['u-alice']);
|
||||||
|
assert.equal(sp.group.nextIndex, 0);
|
||||||
|
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a group completion advances the group and credits only connected participants', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.join(sp, bob);
|
||||||
|
const link = { staticPartyId: sp.id, mode: 'group' as const };
|
||||||
|
|
||||||
|
StaticPartyService.onItemCompleted(link, 'e1', [alice, bob]);
|
||||||
|
StaticPartyService.onItemCompleted(link, 'e2', [alice]); // Bob missed this one
|
||||||
|
|
||||||
|
assert.equal(sp.group.nextIndex, 2);
|
||||||
|
assert.deepEqual(sp.group.completedItemIds, ['e1', 'e2']);
|
||||||
|
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e2']);
|
||||||
|
assert.deepEqual(StaticPartyService.missedIds(sp, alice.userId, NONE), []);
|
||||||
|
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e3', 'e4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a late joiner sees everything the group completed before them as missed', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
StaticPartyService.join(sp, bob);
|
||||||
|
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a connected participant who never joined the static party becomes a member on completion', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice, bob]);
|
||||||
|
assert.deepEqual(sp.members[bob.userId]!.watchedItemIds, ['e1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a catch-up completion credits the participant without moving the group', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
StaticPartyService.join(sp, bob);
|
||||||
|
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'catchUp' }, 'e1', [bob]);
|
||||||
|
|
||||||
|
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
|
||||||
|
assert.equal(sp.group.nextIndex, 1, 'unchanged by a catch-up');
|
||||||
|
assert.deepEqual(sp.group.completedItemIds, ['e1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markWatched removes an item from the missed list, and refuses an unknown item', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
assert.equal(StaticPartyService.markWatched(sp, bob, 'e1'), true);
|
||||||
|
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
|
||||||
|
assert.equal(StaticPartyService.markWatched(sp, bob, 'nope'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordPosition only keeps a position for the group\'s next item, and a completion resets it', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
const link = { staticPartyId: sp.id, mode: 'group' as const };
|
||||||
|
StaticPartyService.recordPosition(link, 'e3', 999);
|
||||||
|
assert.equal(sp.group.resumePositionTicks, 0, 'not the next item - ignored');
|
||||||
|
StaticPartyService.recordPosition(link, 'e1', 123);
|
||||||
|
assert.equal(sp.group.resumePositionTicks, 123);
|
||||||
|
StaticPartyService.recordPosition({ ...link, mode: 'catchUp' }, 'e1', 456);
|
||||||
|
assert.equal(sp.group.resumePositionTicks, 123, 'a catch-up session never moves the group');
|
||||||
|
StaticPartyService.onItemCompleted(link, 'e1', [alice]);
|
||||||
|
assert.equal(sp.group.resumePositionTicks, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshItems appends new episodes without losing progress', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS.slice(0, 2));
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
StaticPartyService.refreshItems(sp, ITEMS);
|
||||||
|
assert.deepEqual(sp.items.map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
|
||||||
|
assert.equal(sp.group.nextIndex, 1);
|
||||||
|
assert.deepEqual(sp.group.completedItemIds, ['e1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only the creator can delete; leaving keeps the party', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.join(sp, bob);
|
||||||
|
assert.equal(StaticPartyService.delete(sp, bob.userId), false);
|
||||||
|
StaticPartyService.leave(sp, bob.userId);
|
||||||
|
assert.ok(StaticPartyService.getById(sp.id));
|
||||||
|
assert.equal(StaticPartyService.delete(sp, alice.userId), true);
|
||||||
|
assert.equal(StaticPartyService.getById(sp.id), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onPartyDestroyed only clears the matching live session', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.setLiveParty(sp, 'p1');
|
||||||
|
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'catchUp' }, 'p2');
|
||||||
|
assert.equal(sp.livePartyId, 'p1');
|
||||||
|
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'group' }, 'p1');
|
||||||
|
assert.equal(sp.livePartyId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toDetail marks each item\'s group state and the viewer\'s own (in-app or Emby Played)', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
const detail = StaticPartyService.toDetail(sp, bob.userId, new Set(['e4']), null);
|
||||||
|
assert.deepEqual(detail.items.map((i) => i.groupState), ['watched', 'next', 'upcoming', 'upcoming']);
|
||||||
|
assert.deepEqual(detail.items.map((i) => i.viewerWatched), [false, false, false, true]);
|
||||||
|
assert.deepEqual(detail.missedItemIds, ['e1']);
|
||||||
|
assert.equal(detail.youAreMember, false);
|
||||||
|
assert.equal(detail.missedCount, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StaticPartyStore', () => {
|
||||||
|
it('writes one file per party, and round-trips it without runtime-only state', async () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
|
||||||
|
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
|
StaticPartyService.setLiveParty(sp, 'live-1');
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
|
||||||
|
assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
|
||||||
|
assert.equal(onDisk.version, 1);
|
||||||
|
assert.equal('livePartyId' in onDisk.party, false);
|
||||||
|
|
||||||
|
StaticPartyService.init();
|
||||||
|
const reloaded = StaticPartyService.getById(sp.id)!;
|
||||||
|
assert.equal(reloaded.name, 'Soirées');
|
||||||
|
assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
|
||||||
|
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
|
||||||
|
assert.ok(StaticPartyService.getById(other.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a change only rewrites the party that changed', async () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
||||||
|
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
const otherFile = path.join(partiesDir(), `${other.id}.json`);
|
||||||
|
const past = new Date(Date.now() - 60000);
|
||||||
|
fs.utimesSync(otherFile, past, past);
|
||||||
|
// Compared against what the filesystem itself reports back, not
|
||||||
|
// past.getTime(): Linux returns mtimeMs as a float that can land a
|
||||||
|
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
|
||||||
|
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
|
||||||
|
|
||||||
|
StaticPartyService.markWatched(sp, alice, 'e1');
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
|
||||||
|
assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a party removes its file, even with a save still pending', async () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
|
||||||
|
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a corrupt file is moved aside and only that party is lost', async () => {
|
||||||
|
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
|
||||||
|
|
||||||
|
StaticPartyService.init();
|
||||||
|
|
||||||
|
assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
|
||||||
|
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
||||||
|
await StaticPartyStore.writeNow();
|
||||||
|
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
|
||||||
|
|
||||||
|
StaticPartyService.init();
|
||||||
|
|
||||||
|
assert.ok(StaticPartyService.getById(sp.id));
|
||||||
|
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates the first release\'s single static-parties.json into one file per party', () => {
|
||||||
|
const legacy = { version: 1, parties: [
|
||||||
|
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
|
||||||
|
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
|
||||||
|
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
|
||||||
|
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
|
||||||
|
] };
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
|
||||||
|
|
||||||
|
StaticPartyService.init();
|
||||||
|
|
||||||
|
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
|
||||||
|
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
|
||||||
|
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
|
||||||
|
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty data directory is a normal first start', () => {
|
||||||
|
StaticPartyService.init();
|
||||||
|
assert.equal(StaticPartyService.list().length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PartyService <-> static watch party', () => {
|
||||||
|
const connected = { emit() {}, disconnect() {} } as unknown as AppSocket;
|
||||||
|
|
||||||
|
it('advanceQueue credits the outgoing item to a linked static party, once', (t) => {
|
||||||
|
t.mock.method(PlaystateService, 'markPlayed', async () => {});
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
|
||||||
|
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
|
||||||
|
party.members.get(alice.userId)!.connection = connected;
|
||||||
|
|
||||||
|
PartyService.completeCurrentItem(party); // "itemEnded"...
|
||||||
|
PartyService.advanceQueue(party); // ...then "next" on the same item: must not double-count
|
||||||
|
|
||||||
|
assert.deepEqual(sp.group.completedItemIds, ['e1']);
|
||||||
|
assert.deepEqual(sp.members[alice.userId]!.watchedItemIds, ['e1']);
|
||||||
|
assert.equal(sp.group.nextIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ordinary party never touches any static party', (t) => {
|
||||||
|
t.mock.method(PlaystateService, 'markPlayed', async () => {});
|
||||||
|
const completed = t.mock.method(StaticPartyService, 'onItemCompleted');
|
||||||
|
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
|
||||||
|
party.members.get(alice.userId)!.connection = connected;
|
||||||
|
PartyService.advanceQueue(party);
|
||||||
|
assert.equal(completed.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the last member leaving a group session clears the static party\'s live session', () => {
|
||||||
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
||||||
|
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
|
||||||
|
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
|
||||||
|
StaticPartyService.setLiveParty(sp, party.id);
|
||||||
|
PartyService.leave(party, alice.userId);
|
||||||
|
assert.equal(sp.livePartyId, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
166
spec/static-watch-party.md
Normal file
166
spec/static-watch-party.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Lounge party (watch party statique)
|
||||||
|
|
||||||
|
## Les trois types de party
|
||||||
|
|
||||||
|
| Nom dans l'interface | Quoi | Où |
|
||||||
|
|---|---|---|
|
||||||
|
| **Watch party** | party vidéo classique, éphémère | accueil, « Parties en cours » |
|
||||||
|
| **Listen party** | party audio classique, éphémère (barre musique) | accueil, « Parties en cours » |
|
||||||
|
| **Lounge party** | party statique : une série ou une playlist vidéo suivie sur plusieurs séances | onglet « Lounge parties » |
|
||||||
|
|
||||||
|
Côté code, la lounge party reste nommée « static party » (`StaticPartyService`,
|
||||||
|
route `/static`, `staticLink`...). Seuls les libellés visibles ont changé. Sur
|
||||||
|
l'accueil, chaque carte affiche un badge « Watch party » ou « Listen party ».
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Suivre une série entière, ou une playlist vidéo, en groupe sur plusieurs
|
||||||
|
soirées. L'app mémorise :
|
||||||
|
|
||||||
|
- **l'avancement du groupe** : les éléments terminés ensemble, et où la
|
||||||
|
dernière séance s'est arrêtée ;
|
||||||
|
- **l'avancement de chaque personne** : ce qu'elle a vu.
|
||||||
|
|
||||||
|
Une personne qui revient ou qui rejoint en cours de route voit la liste de
|
||||||
|
ce qu'elle a **manqué** et peut le rattraper.
|
||||||
|
|
||||||
|
Contrairement à une watch party classique, qui vit en mémoire et disparaît
|
||||||
|
avec son dernier membre, une watch party statique est **persistée sur
|
||||||
|
disque** et survit aux redémarrages.
|
||||||
|
|
||||||
|
## Modèle
|
||||||
|
|
||||||
|
| Entité | Où | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `StaticParty` | `server/src/types.ts`, persistée | la « saison » du groupe : items, progression du groupe, membres |
|
||||||
|
| `StaticPartyMember` | dans `StaticParty.members` | `watchedItemIds` = ce que la personne a vu dans l'app |
|
||||||
|
| Séance | une `Party` live ordinaire + `party.staticLink` | la lecture elle-même : lecteur, sync, flux partagé et lobby réutilisés tels quels |
|
||||||
|
|
||||||
|
`party.staticLink.mode` :
|
||||||
|
|
||||||
|
- `group` : la séance du groupe. Un item terminé fait avancer le groupe
|
||||||
|
**et** est crédité à chaque participant connecté.
|
||||||
|
- `catchUp` : un rattrapage. L'item est crédité aux participants connectés
|
||||||
|
uniquement, le groupe ne bouge pas.
|
||||||
|
|
||||||
|
`party.hiddenFromLobby` : un rattrapage en solo n'apparaît pas dans la liste
|
||||||
|
des watch parties en cours.
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- **Item terminé** :
|
||||||
|
- l'hôte passe à l'item suivant (`next`),
|
||||||
|
- ou le dernier item de la file se termine (`itemEnded`, nouvel event socket réservé à l'hôte).
|
||||||
|
|
||||||
|
Les deux passent par `PartyService.completeCurrentItem`, qui gère aussi le
|
||||||
|
flag `Played` d'Emby. Un même item n'est jamais compté deux fois de suite
|
||||||
|
(`lastCompletedItemId`). C'est la même heuristique que
|
||||||
|
`PlaystateService.markPlayed` : « le groupe est passé à la suite » vaut
|
||||||
|
« vu ».
|
||||||
|
- **Seuls les membres connectés** au moment où l'item se termine sont
|
||||||
|
crédités. Une personne partie en cours d'épisode l'a donc manqué ; elle
|
||||||
|
peut le marquer comme vu.
|
||||||
|
- **Manqués d'une personne** : les items terminés par le groupe, dans
|
||||||
|
l'ordre de la liste, moins ceux qu'elle a vus dans l'app
|
||||||
|
(`watchedItemIds`), moins ceux qu'Emby a déjà en `Played` pour son compte
|
||||||
|
(vus ailleurs). C'est la fonction pure `computeMissedIds`.
|
||||||
|
- **Reprise** : le sweep existant des 20 s enregistre la position de l'hôte
|
||||||
|
(`group.resumePositionTicks`). La séance suivante démarre avec cette
|
||||||
|
position comme position de l'hôte, que `Player.ts` utilise déjà pour
|
||||||
|
placer la première image.
|
||||||
|
- **Nouveaux épisodes** : chaque lecture de la page, et chaque lancement de
|
||||||
|
séance, recharge la source. Les nouveaux items sont ajoutés à la fin ; les
|
||||||
|
items retirés de la source sont conservés dans l'historique
|
||||||
|
(`mergeItemLists`).
|
||||||
|
- **Accès** : ouverte à tous les comptes Emby.
|
||||||
|
- Rejoindre = devenir membre. Rejoindre la séance du groupe depuis le
|
||||||
|
lobby rend aussi membre de la watch party statique.
|
||||||
|
- N'importe quel membre peut lancer la séance ; celui qui la lance en
|
||||||
|
devient l'hôte.
|
||||||
|
- Quitter efface sa propre progression.
|
||||||
|
- Seul le créateur peut supprimer la watch party.
|
||||||
|
- **Contenus** : une série entière, **une seule saison**, ou une playlist
|
||||||
|
vidéo. Une playlist musicale est refusée.
|
||||||
|
- Série entière : tous les épisodes de toutes les saisons à partir de S1E1.
|
||||||
|
Les spéciaux (saison 0) sont exclus : Emby les trie en premier, et une
|
||||||
|
séance commencerait par eux (`CatalogService.seriesQueueFrom`). Les
|
||||||
|
lounge parties créées avant ce changement gardent leurs spéciaux déjà
|
||||||
|
en liste.
|
||||||
|
- Saison : uniquement les épisodes de cette saison
|
||||||
|
(`CatalogService.seasonQueue`, par `SeasonId`, sinon par numéro de
|
||||||
|
saison). La source garde `seriesId` et `seasonNumber`, car les
|
||||||
|
épisodes sont récupérés par série puis filtrés. Nom par défaut :
|
||||||
|
« Série - Saison », avec l'affiche de la série.
|
||||||
|
|
||||||
|
## API (`server/src/index.ts`)
|
||||||
|
|
||||||
|
| Méthode | Route | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
|
||||||
|
| POST | `/api/static-parties` | `{sourceId, name?}` : série, saison, épisode (sa série) ou playlist |
|
||||||
|
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
|
||||||
|
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
|
||||||
|
| DELETE | `/:id` | supprimer (créateur uniquement) |
|
||||||
|
| POST | `/:id/session` | lancer la séance du groupe, ou rejoindre celle en cours, puis renvoyer `{partyId}` |
|
||||||
|
| POST | `/:id/items/:itemId/watched` | marquer comme vu |
|
||||||
|
| POST | `/:id/catch-up` | `{itemIds, mode: 'solo'\|'party'}`, renvoie `{partyId}` |
|
||||||
|
|
||||||
|
## Persistance
|
||||||
|
|
||||||
|
- `StaticPartyStore` écrit **un fichier JSON par watch party** :
|
||||||
|
`${DATA_DIR}/static-parties/<id>.json`, au format `{version: 1, party}`.
|
||||||
|
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
|
||||||
|
`docker-compose.yml` y monte `./data`.
|
||||||
|
- Un fichier par party limite les dégâts : une écriture ne touche que la
|
||||||
|
party modifiée, et un fichier corrompu ne coûte que cette party-là.
|
||||||
|
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s) party
|
||||||
|
par party. Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
|
||||||
|
- Au démarrage, un `.tmp` restant (écriture interrompue) est supprimé ; le
|
||||||
|
vrai fichier, s'il existe, est la dernière version complète.
|
||||||
|
- Un fichier corrompu (ou dont le nom ne correspond pas à l'id) est
|
||||||
|
renommé en `.corrupt` et ignoré ; les autres parties se chargent
|
||||||
|
normalement. Le serveur ne crashe jamais pour ça.
|
||||||
|
- Supprimer une party supprime son fichier et annule toute sauvegarde en
|
||||||
|
attente pour elle.
|
||||||
|
- Migration : l'ancien fichier unique `${DATA_DIR}/static-parties.json` de
|
||||||
|
la première version est éclaté en un fichier par party au premier
|
||||||
|
démarrage, puis renommé en `.migrated` (conservé, pas supprimé).
|
||||||
|
- Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une
|
||||||
|
dépendance native serait une première dans l'image, et les données sont
|
||||||
|
minuscules.
|
||||||
|
- `livePartyId` n'est jamais persisté : un redémarrage termine toutes les
|
||||||
|
séances, et la reprise se fait depuis la position enregistrée.
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
- `views/staticParty/` (route `/static/:id`) contient :
|
||||||
|
- l'en-tête, avec la progression du groupe et le bouton lancer / rejoindre
|
||||||
|
la séance ;
|
||||||
|
- « Tu as manqué », avec pour chaque élément cases à cocher, *Regarder en
|
||||||
|
solo* et *Marquer comme vu*, plus deux actions groupées : rattraper en
|
||||||
|
solo, ou lancer une watch party de rattrapage ;
|
||||||
|
- les membres ;
|
||||||
|
- la liste complète des items.
|
||||||
|
- Page série (`views/series`) :
|
||||||
|
- en-tête : « Lancer toute la série » (watch party) et « Créer une lounge
|
||||||
|
party » (série entière) ;
|
||||||
|
- au-dessus des épisodes de l'onglet de saison sélectionné : « Lancer
|
||||||
|
cette saison » (watch party) et « Lounge party de cette saison ».
|
||||||
|
- « Créer une lounge party » sur `views/playlist` (vidéo uniquement).
|
||||||
|
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route
|
||||||
|
`/static` (`views/staticParties/`) : une carte par watch party avec la
|
||||||
|
progression, le prochain élément, un badge « N manqués » et une pastille
|
||||||
|
« séance en cours ». L'onglet reste actif sur la page d'une party.
|
||||||
|
- Séparation avec les watch/listen parties classiques : la liste « Parties
|
||||||
|
en cours » de l'accueil exclut les séances de lounge party
|
||||||
|
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
|
||||||
|
|
||||||
|
## Limites connues
|
||||||
|
|
||||||
|
- « Vu » reste une heuristique : passer à la suite compte comme vu, comme
|
||||||
|
pour le flag `Played` d'Emby. Il n'y a pas de seuil de pourcentage.
|
||||||
|
- La liste et le détail relisent la source Emby à chaque affichage (une
|
||||||
|
requête par watch party pour la liste). C'est acceptable à l'échelle d'un
|
||||||
|
serveur perso ; à mettre en cache si la liste grossit.
|
||||||
|
- Plusieurs watch parties statiques peuvent exister sur la même série
|
||||||
|
(des groupes différents) : c'est voulu, sans déduplication.
|
||||||
Loading…
Reference in a new issue