Compare commits

..

No commits in common. "master" and "fix/music-bar-progress-full-width" have entirely different histories.

51 changed files with 86 additions and 3460 deletions

View file

@ -55,16 +55,13 @@ 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.head_ref || github.ref_name }}" git fetch -q --depth 1 origin "${{ 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
View file

@ -21,9 +21,6 @@ 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

View file

@ -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, DATA_DIR) src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
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,14 +245,9 @@ 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 -v "$(pwd)/data:/data" -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty docker run -d --name watchparty -p 3000:3000 -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`.
@ -391,27 +386,6 @@ 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)

View file

@ -39,14 +39,8 @@ 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', body: JSON.stringify({ deviceId: getDeviceId() }) }); return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
} }
export function getParty(partyId: string): Promise<PartyPublicState> { export function getParty(partyId: string): Promise<PartyPublicState> {
@ -150,9 +144,6 @@ 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;
@ -233,9 +224,6 @@ 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');
}, },

View file

@ -1,74 +0,0 @@
// 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 }) });
}

View file

@ -10,8 +10,6 @@ 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);
@ -22,8 +20,6 @@ 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 +

View file

@ -1,123 +0,0 @@
// 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.
}
}

View file

@ -388,41 +388,6 @@
} }
} }
/* 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

View file

@ -10,7 +10,6 @@ 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
@ -163,18 +162,14 @@ 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));
seekTo(fraction * currentDurationSeconds); const seconds = fraction * currentDurationSeconds;
activeDriver!.currentTime = seconds;
updatePositionDisplay();
conn?.seekMusic(seconds);
} }
trackProgress.addEventListener('pointerdown', (e) => { trackProgress.addEventListener('pointerdown', (e) => {
@ -189,15 +184,6 @@ 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
@ -233,7 +219,6 @@ 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);
} }
/** /**
@ -266,24 +251,7 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void {
else leaveMusicParty(); else leaveMusicParty();
}, },
onError: (message) => showToast(message, { variant: 'error' }), onError: (message) => showToast(message, { variant: 'error' }),
// Fires for every disconnect, intentional or not - leaveMusicParty onClose: () => {}
// 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();
}
}); });
} }
@ -315,7 +283,6 @@ export function leaveMusicParty(): void {
currentItemId = null; currentItemId = null;
lastMusicState = null; lastMusicState = null;
latestMembers = []; latestMembers = [];
clearMediaSession();
conn?.leave(); conn?.leave();
conn = null; conn = null;
@ -369,7 +336,6 @@ 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
@ -463,10 +429,6 @@ 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) => {
@ -576,11 +538,6 @@ 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
@ -604,7 +561,6 @@ 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

View file

@ -1,46 +0,0 @@
/* ---- 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; }
}

View file

@ -1,108 +0,0 @@
// 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">&times;</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">&times;</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);
}

View file

@ -3,7 +3,6 @@
<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>
@ -42,7 +41,6 @@
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>

View file

@ -5,7 +5,6 @@ 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
@ -18,8 +17,6 @@ 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
@ -55,12 +52,10 @@ 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
@ -101,11 +96,8 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
const highlight = () => { const highlight = () => {
appEl.querySelectorAll('.navItem').forEach((a) => { appEl.querySelectorAll('.navItem').forEach((a) => {
const href = a.getAttribute('href'); const isHome = a.getAttribute('href') === '#/home';
const isHome = href === '#/home'; a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
// 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();

View file

@ -42,3 +42,8 @@
.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; }
}

View file

@ -61,9 +61,7 @@ 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;
// Video -> "watch party", audio -> "listen party" - see the party kinds const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
// 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>` : ''}
@ -118,7 +116,7 @@ export function tileHtml(item: ItemDto): string {
} }
/** /**
* Wires up every "Lancer une watch/listen party" button inside `container`, now * Wires up every "Lancer une watch 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.
@ -134,7 +132,6 @@ 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 {
@ -142,7 +139,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 = idleLabel; btn.textContent = 'Lancer une watch party';
} }
}); });
} }

View file

@ -171,42 +171,6 @@ 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 {

View file

@ -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 = 'Lancer une listen party'; const startLabel = 'Écouter ensemble';
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";

View file

@ -60,18 +60,3 @@
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); }

View file

@ -3,7 +3,7 @@
</div> </div>
<section class="section" id="partiesSection" hidden> <section class="section" id="partiesSection" hidden>
<h2>Parties en cours</h2> <h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div> <div id="partiesCarousel" class="carousel"></div>
</section> </section>

View file

@ -18,9 +18,7 @@ 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.
// A static watch party's sessions live in their own tab const lobby = connectLobby((parties) => renderParties(root, parties));
// (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);
@ -59,7 +57,6 @@ 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' : ''}

View file

@ -13,7 +13,6 @@ 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;
@ -229,14 +228,6 @@ 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;
@ -423,7 +414,6 @@ 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
@ -447,7 +437,6 @@ 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);
} }
/** /**
@ -528,23 +517,13 @@ 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));
seekTo(fraction * duration); activeDriver.currentTime = 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
@ -683,7 +662,6 @@ 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();
@ -691,10 +669,7 @@ 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);
// The last item ending still counts as watched (Emby Played, a static video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
// 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
@ -1063,10 +1038,8 @@ 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
@ -1214,42 +1187,22 @@ 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.
function requestPause() { requestPauseBtn.addEventListener('click', () => {
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();
} });
function requestResume() { requestResumeBtn.addEventListener('click', () => {
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);
@ -1263,20 +1216,7 @@ 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);
// Unlike onItemChanged's own loadVideo(..., 0, ...) - genuinely correct loadVideo(party.itemId, 0, false);
// 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,
@ -1301,27 +1241,11 @@ 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' }),
// Fires for every disconnect, intentional or not - leftIntentionally onClose: () => {}
// 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', goToNextItem); nextBtn.addEventListener('click', () => { conn?.next(); });
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home')); (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init(); init();
@ -1331,13 +1255,11 @@ 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);

View file

@ -25,9 +25,8 @@
.playlistActions { .playlistActions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 12px 16px; gap: 16px;
margin-top: 4px; margin-top: 4px;
} }

View file

@ -6,7 +6,6 @@
<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>

View file

@ -2,7 +2,6 @@ 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';
@ -57,15 +56,6 @@ 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) : '';

View file

@ -17,20 +17,6 @@
.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; }

View file

@ -4,16 +4,8 @@
<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"> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
<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>
</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>

View file

@ -2,7 +2,6 @@ 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';
@ -36,25 +35,11 @@ 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>`)
@ -65,35 +50,13 @@ 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');
selectedSeasonId = tab.dataset.seasonId as string; loadEpisodes(root, seriesId, 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;

View file

@ -1,81 +0,0 @@
/* ---- "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; }
}

View file

@ -1,9 +0,0 @@
<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>

View file

@ -1,77 +0,0 @@
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('');
}

View file

@ -1,138 +0,0 @@
/* ---- 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; }
}

View file

@ -1,39 +0,0 @@
<button class="linklike" id="back">&larr; 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>

View file

@ -1,227 +0,0 @@
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;
});
}

View file

@ -1,31 +0,0 @@
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;
});
});
}

View file

@ -8,9 +8,3 @@ 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

View file

@ -851,23 +851,6 @@ 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
@ -4254,368 +4237,6 @@ 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
@ -4632,10 +4253,7 @@ short version:
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 for ordinary parties: server restart or long idle time - No persistence: server restart or long idle time ends every party.
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

View file

@ -46,10 +46,6 @@ 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"]

View file

@ -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/staticPartyService.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/transcodeService.test.ts"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View file

@ -83,11 +83,4 @@ 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}`);
}
} }

View file

@ -17,11 +17,3 @@ 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';

View file

@ -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 { DATA_DIR, EMBY_SERVER_URL, PORT } from './config'; import { 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, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService'; import { CatalogService, CatalogFetchError, imageUrlFor, 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,13 +16,8 @@ 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, StaticParty } from './types'; import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } 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
@ -258,7 +253,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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
await PartyStreamService.serveMasterPlaylist(req, res, party, session); await PartyStreamService.serveMasterPlaylist(req, res, party, session);
@ -272,7 +267,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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
await PartyStreamService.serveRawAsset(req, res, party, session); await PartyStreamService.serveRawAsset(req, res, party, session);
@ -302,7 +297,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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
if (!party.burnInSubtitleIndex) { if (!party.burnInSubtitleIndex) {
@ -318,7 +313,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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
await TranscodeService.serveSegment(req, res, party, param(req, 'segment')); await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
@ -356,20 +351,6 @@ 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);
@ -386,8 +367,13 @@ 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 remaining = seriesQueueFrom(all, itemId); const startIndex = all.findIndex((e) => e.Id === itemId);
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem); const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
} }
const party = PartyService.create( const party = PartyService.create(
@ -424,17 +410,6 @@ 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
@ -481,7 +456,11 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
return; return;
} }
const queue: QueueItem[] = items.map(toVideoQueueItem); const queue: QueueItem[] = items.map((e) => ({
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,
@ -492,45 +471,6 @@ 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 -
@ -608,12 +548,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: 'Listen party introuvable.' }); res.status(404).json({ error: 'Watch party musique 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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
const track = req.body || {}; const track = req.body || {};
@ -639,12 +579,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: 'Listen party introuvable.' }); res.status(404).json({ error: 'Watch party musique 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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
const itemId = req.body?.itemId; const itemId = req.body?.itemId;
@ -668,12 +608,6 @@ 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));
}); });
@ -681,16 +615,9 @@ 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); // Shared with the socket "leave"/"disconnect" handlers so the host-
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null; // handoff notification and the lobby refresh only live in one place.
// See PartyService.isStaleDevice's own comment for why this check SocketGateway.applyLeave(io, party, req.embySession!.userId);
// 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-
// handoff notification and the lobby refresh only live in one place.
SocketGateway.applyLeave(io, party, req.embySession!.userId);
}
} }
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -714,7 +641,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 party." }); res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return; return;
} }
res.json({ token: CastTokenService.issue(party.id, session) }); res.json({ token: CastTokenService.issue(party.id, session) });
@ -728,229 +655,6 @@ 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
@ -1019,9 +723,6 @@ 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);
@ -1058,16 +759,6 @@ 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'})`);
}); });

View file

@ -3,7 +3,6 @@
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';
/** /**
@ -36,9 +35,6 @@ 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,
@ -211,17 +207,6 @@ 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
@ -425,11 +410,8 @@ class SocketGatewayClass {
} }
private lobbySnapshot(): LobbyPartyDto[] { private lobbySnapshot(): LobbyPartyDto[] {
// A solo catch-up session of a static watch party isn't something return PartyService.listActive().map((party) => {
// 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
@ -447,9 +429,7 @@ 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
}; };
}); });
} }

View file

@ -8,11 +8,10 @@ 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' isn't itself streamable, but // 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
// launching one queues the whole series (see seriesQueueFrom and POST // directly playable - only show "start a party" for things Emby can
// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their // actually stream. "Voir sur Emby" always works though.
// own pages have their own launch actions. "Voir sur Emby" always works. const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
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
@ -69,36 +68,6 @@ 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';
@ -156,23 +125,9 @@ 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}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true }); return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
} }
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> { async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {

View file

@ -8,7 +8,6 @@ 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';
/** /**
@ -145,30 +144,6 @@ 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
@ -210,7 +185,6 @@ 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)!;
@ -232,9 +206,7 @@ class PartyServiceClass {
if (party.queueIndex >= party.queue.length - 1) { if (party.queueIndex >= party.queue.length - 1) {
return false; return false;
} }
// Whatever was just playing is "done" - before the party's own state const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
// 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;
@ -245,6 +217,18 @@ 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,
@ -269,40 +253,6 @@ 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());
@ -316,7 +266,6 @@ 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

View file

@ -1,319 +0,0 @@
'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();

View file

@ -1,272 +0,0 @@
'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();

View file

@ -29,11 +29,6 @@ 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
@ -173,8 +168,6 @@ 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;
@ -187,11 +180,6 @@ 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,
@ -371,25 +359,6 @@ 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 {
@ -446,92 +415,6 @@ 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 {

View file

@ -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, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService'; import { CatalogService, imageUrlFor, 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,18 +97,6 @@ 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: [{
@ -314,34 +302,3 @@ 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']);
});
});

View file

@ -155,41 +155,6 @@ 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');

View file

@ -1,299 +0,0 @@
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);
});
});

View file

@ -1,166 +0,0 @@
# 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.