watchparty/client/js/party.js
Nicolas f563f5b0a2
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).

Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
  from GET /api/catalog/libraries which now also returns
  collectionType) + a #mainContent the router swaps on navigation.
  router.js made idempotent (start() safe to call more than once) so
  a login -> shell boot can happen twice in one page lifetime (logout
  then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
  shell depending on session state; login.js calls it again on
  success instead of navigate() (no router running yet at that point).

Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
  server-wide, a join-by-code fallback, then one "recently added"
  carousel per non-music library (GET /api/catalog/libraries/:id/recent,
  Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
  movies/tvshows get the tile grid (Series tiles link into /series/:id
  instead of starting a party directly), music gets an honest
  placeholder.
- views/series.js (route /series/:id): season tabs + episode list
  (new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
  /Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
  they can't drift apart.

Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
  (fetches the rest of the series, from that episode onward) -
  partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
  broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
  reloads the player against the new item. player.js shows "Épisode
  suivant" when hasNext, and auto-sends next() on the video's `ended`
  event.

Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
  MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
  MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
  (host-only) that reload the stream at the same position on change.
  Subtitles end up burned into the transcode (via SubtitleStreamIndex),
  not a toggleable track - simpler than sidecar WebVTT for this pass.
  "Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.

Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.

Removed client/js/views/browse.js, superseded by home.js + library.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:44:49 +02:00

83 lines
2.7 KiB
JavaScript

// Party REST + realtime WebSocket - same origin as everything else, auth is
// the session cookie (see embyApi.js), so no headers/tokens to pass around
// here at all.
async function apiFetch(path, options = {}) {
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();
}
export async function listActiveParties() {
const data = await apiFetch('/api/parties');
return data.parties || [];
}
export function createParty(itemId) {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
}
export function joinParty(joinCode) {
return apiFetch(`/api/parties/${encodeURIComponent(joinCode)}/join`, { method: 'POST' });
}
export function leaveParty(partyId) {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`);
}
/**
* Opens the realtime connection for a party. Auth rides along on the
* WebSocket's HTTP upgrade handshake as the session cookie (browsers attach
* cookies to that handshake automatically for a same-origin URL) - so all
* this needs to send is which party to join.
*/
export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onError, onClose } = {}) {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/ws');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'join', partyId }));
});
ws.addEventListener('message', (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch (err) { return; }
if (msg.type === 'members' && onMembers) onMembers(msg.members);
if (msg.type === 'playstate' && onPlaystate) onPlaystate(msg);
if (msg.type === 'itemChanged' && onItemChanged) onItemChanged(msg);
if (msg.type === 'error' && onError) onError(msg.message);
});
ws.addEventListener('close', () => { if (onClose) onClose(); });
return {
sendPlaystate(isPaused, positionTicks) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'playstate', isPaused, positionTicks }));
}
},
/** Host-only: advance the party's queue (e.g. to the next episode). */
next() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'next' }));
}
},
leave() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'leave' }));
}
ws.close();
}
};
}