Asked for directly: views/album's and views/artist's per-track/ per-album buttons had no whole-album or whole-discography equivalent - queuing an album meant clicking every track one at a time, and an artist page had no listen-party action at all beyond drilling into a specific album. Resolved server-side, not via a client-side loop - the same choice already made for playlists, and for the same reason: an artist's entire discography can genuinely be dozens of albums and hundreds of tracks, and a client loop of individual add-track calls would mean that many sequential round trips (slow, and non-atomic) instead of one. New resolveMusicTracksForItem (index.ts) takes a raw Emby item already known to be Audio/MusicAlbum/MusicArtist and resolves it to a flat, ordered track list - a single track, every track in an album (in Emby's own IndexNumber order), or every album's tracks for an artist's whole discography (fetched in parallel, in Emby's own chronological album order). POST /api/parties gained a MusicAlbum/MusicArtist branch right alongside the existing Playlist one, using this plus a new shared createMusicPartyFromTracks - which also let createPartyFromPlaylist's own music branch drop its separate per-track loop in favor of the same two calls. MusicQueueService.addTracks is the batch sibling of the existing addTrack (both now share a buildTrackEntry helper) - same auto-starts-if-the-queue-was-empty behavior, but one queueUpdated broadcast for the whole batch rather than one per track, so a hundred-track discography doesn't spam the party with a hundred intermediate broadcasts for what's really one user action. Adding to an already-live party's queue needed its own new route, POST /api/parties/:partyId/queue - given just an itemId, resolves it the same way and calls addTracks. Kept separate from the existing POST .../tracks, which takes an already-built track object a caller already has in hand (views/album's own per-track buttons) - overloading that route to guess whether it received a full track or a bare id would have been more surprising than a second, clearly-named one. The buttons themselves mirror views/playlist's own "Lancer cette playlist" - same label-reflects-current-state-and-re-checks-on-click discipline views/album's per-track buttons already had, same attach-the-bar-and-stay-put behavior when starting fresh. views/album gained one page-level button (reusing .playlistActions from Playlist.css, same "one CSS bundle regardless of which view's file a rule started in" precedent Album.css already set for .seriesInfo). views/artist gained its first listen-party action ever, sitting in .pageHeader itself (its own justify-content: space-between already puts a second child on the right of the title with no new CSS needed). Verified: full typecheck/test/build on both projects (147/147 server tests - new coverage for addTracks: batch order preserved, auto-start only fires for the batch's own first track when the queue was empty, exactly one broadcast regardless of batch size, harmless no-op for an empty batch). resolveMusicTracksForItem/createMusicPartyFromTracks themselves are index.ts route glue, not independently unit-tested - consistent with this project's existing boundary that route-level orchestration isn't, only the services it calls into are. The two new buttons' layout was checked visually against the real compiled CSS (a static render, real theme tokens), not eyeballed. Not verified: an actual large discography queuing correctly end to end against a real server - no Emby login reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
// Party REST + realtime - same origin as everything else, auth is the
|
|
// session cookie (see embyApi.ts), so no headers/tokens to pass around here.
|
|
// Realtime is Socket.IO (the `io` global, loaded via <script> in
|
|
// index.html - see global.d.ts) - gives us automatic reconnection and, on
|
|
// the server side, heartbeat-based dead-connection detection for free (see
|
|
// server/src/realtime/SocketGateway.ts).
|
|
//
|
|
// No join codes: joining happens straight from the home page's live list
|
|
// (connectLobby below) by party id - see docs/architecture.md.
|
|
import { getDeviceId } from '../deviceId';
|
|
import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../types';
|
|
|
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* `startAtItemId` only ever does anything when `itemId` is a Playlist
|
|
* (see index.ts's createPartyFromPlaylist) - "Lire à partir d'ici"
|
|
* (views/playlist), picking a starting point other than the playlist's
|
|
* own first item. Meaningless (silently ignored server-side) for every
|
|
* other item type, so every other call site can keep passing just one
|
|
* argument.
|
|
*/
|
|
export function createParty(itemId: string, startAtItemId?: string): Promise<PartyPublicState> {
|
|
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId, startAtItemId }) });
|
|
}
|
|
|
|
export function joinParty(partyId: string): Promise<PartyPublicState> {
|
|
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
|
}
|
|
|
|
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
|
}
|
|
|
|
export function getParty(partyId: string): Promise<PartyPublicState> {
|
|
return apiFetch(`/api/parties/${partyId}`);
|
|
}
|
|
|
|
/**
|
|
* Music only: adds a track to a party's shared queue. REST, not a socket
|
|
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs
|
|
* to work from the album-browsing page (views/album/Album.ts), which
|
|
* holds no live connection to the party at all - only the persistent
|
|
* musicBar/MusicBar.ts does (see its header comment for why browsing and
|
|
* the live party are two separate things now). `track` is whatever
|
|
* ItemDto shape the catalog endpoints already returned - see
|
|
* server/src/services/MusicQueueService.ts.
|
|
*/
|
|
export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise<PartyPublicState> {
|
|
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
|
}
|
|
|
|
/**
|
|
* Music only: queues `itemId` into a party's shared queue, whatever it
|
|
* turns out to be - a single track queues itself, an album or an
|
|
* artist expands into every track inside it (see index.ts's
|
|
* resolveMusicTracksForItem) - the "add to queue" half of
|
|
* views/album's/views/artist's own whole-album/whole-discography
|
|
* button (createParty(itemId) is the other half, for when there's no
|
|
* party active yet - see Album.ts/Artist.ts). Distinct from
|
|
* addTrackToQueue above, which takes an already-built track object a
|
|
* caller has in hand rather than an id to resolve server-side.
|
|
*/
|
|
export function queueItem(partyId: string, itemId: string): Promise<PartyPublicState> {
|
|
return apiFetch(`/api/parties/${partyId}/queue`, { method: 'POST', body: JSON.stringify({ itemId }) });
|
|
}
|
|
|
|
/**
|
|
* Mints a short-lived, party-scoped token a Chromecast can use to reach
|
|
* this party's own shared stream routes, which otherwise only accept the
|
|
* sender's httpOnly session cookie - something a Chromecast can never
|
|
* carry (see server/src/services/CastTokenService.ts). Called right
|
|
* before starting a cast - see cast.ts/Player.ts.
|
|
*/
|
|
export function issueCastToken(partyId: string): Promise<{ token: string }> {
|
|
return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'POST' });
|
|
}
|
|
|
|
/** Invalidates whatever cast token this member currently holds for this
|
|
* party - called when a cast session ends, from either side (the user
|
|
* stopping it here, or the receiver itself ending it - see
|
|
* Player.ts/cast.ts's onCastSessionEnded). Harmless if nothing was cast. */
|
|
export function revokeCastToken(partyId: string): Promise<{ ok: true }> {
|
|
return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Live "watch parties en cours" feed for the home page. `onParties` gets
|
|
* called with the full current list on subscribe, and again every time any
|
|
* party anywhere changes (created, someone joined/left, host handed off).
|
|
* Caller MUST call .disconnect() when navigating away (see home's router
|
|
* cleanup) - same leak-prevention pattern as connectParty below.
|
|
*/
|
|
export function connectLobby(onParties: (parties: LobbyPartyDto[]) => void): { disconnect(): void } {
|
|
const socket = io();
|
|
socket.on('connect', () => socket.emit('subscribeLobby'));
|
|
socket.on('parties', (parties) => onParties(parties as LobbyPartyDto[]));
|
|
return {
|
|
disconnect() {
|
|
socket.disconnect();
|
|
}
|
|
};
|
|
}
|
|
|
|
export interface PartyConnectionCallbacks {
|
|
onMembers?: (members: SocketMember[]) => void;
|
|
onPlaystate?: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
|
onItemChanged?: (msg: ItemChangedMsg) => void;
|
|
onHostChanged?: () => void;
|
|
onPauseRequested?: (msg: PauseRequestedMsg) => void;
|
|
onResumeRequested?: (msg: ResumeRequestedMsg) => void;
|
|
onQueueUpdated?: (state: MusicPublicState) => void;
|
|
/** This account is already actively connected to this party from a
|
|
* DIFFERENT device (see client/src/deviceId.ts) - the socket is still
|
|
* connected but inert (not joined to the party) until the caller either
|
|
* calls confirmDeviceTakeover() or gives up (e.g. navigates away). See
|
|
* SocketGateway's "join" handler. */
|
|
onDeviceConflict?: () => void;
|
|
/** The host picked (or cleared) the party's shared burn-in stream - see
|
|
* setBurnInSubtitle below and Player.ts. */
|
|
onBurnInChanged?: (msg: BurnInChangedMsg) => void;
|
|
/** The host picked (or cleared) a *text* subtitle for the whole party -
|
|
* see setTextSubtitle below and Player.ts. */
|
|
onTextSubtitleChanged?: (msg: TextSubtitleChangedMsg) => void;
|
|
/** The host changed the party's shared stream's audio track/quality -
|
|
* see setStreamOptions below and Player.ts. */
|
|
onStreamOptionsChanged?: (msg: StreamOptionsChangedMsg) => void;
|
|
onError?: (message: string) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export interface PartyConnection {
|
|
sendPlaystate(isPaused: boolean, positionTicks: number): void;
|
|
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
|
next(): void;
|
|
/** Asks the host (and tells everyone else) "please pause". Video only -
|
|
* a music party has no host to ask, see voteSkip/setMusicPaused. */
|
|
requestPause(): void;
|
|
/** Same idea, the other direction - "please resume". Independently
|
|
* throttled server-side from requestPause (see SocketGateway). */
|
|
requestResume(): void;
|
|
/** Music only: vote to skip the current track - see MusicQueueService
|
|
* on the server for the majority threshold. */
|
|
voteSkip(): void;
|
|
/** Music only: no host check server-side, unlike sendPlaystate above. */
|
|
setMusicPaused(isPaused: boolean): void;
|
|
/** Music only: jump to `positionSeconds` in the current track - same
|
|
* no-host-check treatment as setMusicPaused above (see
|
|
* MusicQueueService.seek's own comment for why). */
|
|
seekMusic(positionSeconds: number): void;
|
|
/** Re-sends "join" with force:true after the user confirms they want to
|
|
* disconnect their other device and take over here - call this from
|
|
* onDeviceConflict's handler once the user says yes. */
|
|
confirmDeviceTakeover(): void;
|
|
/** Host-only: picks the party's shared self-hosted-ffmpeg burn-in
|
|
* stream for a PGS/image subtitle (null clears it) - see
|
|
* TranscodeService and Player.ts's subtitle <select>. Not for a text
|
|
* subtitle - see setTextSubtitle below for those. */
|
|
setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void;
|
|
/** Host-only: picks (or clears) a *text* subtitle for the whole party -
|
|
* every member still renders it themselves as a native <track>, this
|
|
* only syncs *which* one. Mutually exclusive with setBurnInSubtitle -
|
|
* the server clears one when the other is set for real. */
|
|
setTextSubtitle(subtitleIndex: string | null): void;
|
|
/** Host-only, video-only: picks the audio track/quality of the party's
|
|
* shared normal stream (PartyStreamService) - there's only ever one
|
|
* stream now, so this is necessarily party-wide, not each viewer's
|
|
* own local pick the way it was before streams were shared. Either
|
|
* argument null/omitted means "Emby's own default". */
|
|
setStreamOptions(audioStreamIndex?: string | null, quality?: string | null): void;
|
|
leave(): void;
|
|
}
|
|
|
|
/**
|
|
* Opens the realtime connection for a party. Returns an object the caller
|
|
* MUST eventually call .leave() on (e.g. from a router cleanup function -
|
|
* see router.ts/player) - otherwise the socket, and whatever it's keeping
|
|
* alive via closures, never gets released.
|
|
*/
|
|
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
|
|
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onTextSubtitleChanged, onStreamOptionsChanged, onError, onClose } = callbacks;
|
|
const socket = io();
|
|
const deviceId = getDeviceId();
|
|
|
|
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
|
// also covers rejoining after a transient network drop, not just the
|
|
// first connection. force is deliberately NOT set here even on a
|
|
// reconnect - a drop-and-retry re-sends the same deviceId as before, so
|
|
// it never conflicts with itself; force only ever comes from
|
|
// confirmDeviceTakeover() below, after a real "deviceConflict".
|
|
socket.on('connect', () => {
|
|
socket.emit('join', { partyId, deviceId });
|
|
});
|
|
|
|
if (onMembers) socket.on('members', (m) => onMembers(m as SocketMember[]));
|
|
if (onPlaystate) socket.on('playstate', (m) => onPlaystate(m as { isPaused: boolean; positionTicks: number }));
|
|
if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
|
|
if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
|
|
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg));
|
|
if (onResumeRequested) socket.on('resumeRequested', (m) => onResumeRequested(m as ResumeRequestedMsg));
|
|
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
|
|
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
|
|
if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg));
|
|
if (onTextSubtitleChanged) socket.on('textSubtitleChanged', (m) => onTextSubtitleChanged(m as TextSubtitleChangedMsg));
|
|
if (onStreamOptionsChanged) socket.on('streamOptionsChanged', (m) => onStreamOptionsChanged(m as StreamOptionsChangedMsg));
|
|
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
|
|
if (onClose) socket.on('disconnect', () => onClose());
|
|
|
|
return {
|
|
sendPlaystate(isPaused, positionTicks) {
|
|
socket.emit('playstate', { isPaused, positionTicks });
|
|
},
|
|
next() {
|
|
socket.emit('next');
|
|
},
|
|
requestPause() {
|
|
socket.emit('requestPause');
|
|
},
|
|
requestResume() {
|
|
socket.emit('requestResume');
|
|
},
|
|
voteSkip() {
|
|
socket.emit('voteSkip');
|
|
},
|
|
setMusicPaused(isPaused) {
|
|
socket.emit('musicPlaystate', { isPaused });
|
|
},
|
|
seekMusic(positionSeconds) {
|
|
socket.emit('musicSeek', { positionSeconds });
|
|
},
|
|
confirmDeviceTakeover() {
|
|
socket.emit('join', { partyId, deviceId, force: true });
|
|
},
|
|
setBurnInSubtitle(subtitleIndex, audioIndex) {
|
|
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
|
|
},
|
|
setTextSubtitle(subtitleIndex) {
|
|
socket.emit('setTextSubtitle', { subtitleIndex });
|
|
},
|
|
setStreamOptions(audioStreamIndex, quality) {
|
|
socket.emit('setStreamOptions', { audioStreamIndex, quality });
|
|
},
|
|
leave() {
|
|
socket.emit('leave');
|
|
socket.disconnect();
|
|
}
|
|
};
|
|
}
|