watchparty/client/js/party.js
Nicolas 669873e41c
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Presence overlay (avatars) + "request pause" in the player
Two features requested together: a semi-transparent overlay showing who's
in the party, and a way for a non-host to ask the host to pause.

- server/src/socketHandler.js: new "requestPause" event (anyone, throttled
  5s per member server-side via member.lastPauseRequestUtc - the real
  guard, independent of whatever the client does) broadcasts
  "pauseRequested" {userId, userName} to the whole party room, not just the
  host, so everyone sees why playback is about to pause.
- client/js/party.js: connectParty() grows an onPauseRequested callback and
  the returned connection a requestPause() method, same shape as the
  existing next()/sendPlaystate().
- client/js/views/player.js: renderMembers() now also rebuilds a
  #presenceOverlay of one circle per member (initials from userName,
  colorForName() picks a deterministic background from a small palette by
  hashing the name - same person, same color, no server-side assignment
  needed). "Which circle is mine" compares userName against the auth
  singleton embyApi.js already populates, matching how the rest of the
  client avoids keying UI logic on userId directly. Your own circle, only
  when you're not host, is clickable and calls requestPause() (client-side
  cooldown too, purely to avoid a click that would visibly do nothing - the
  server-side one is what actually matters). Receiving pauseRequested shows
  a short auto-clearing banner and pulses the matching circle for everyone,
  including the requester and the host.
- client/css/app.css: .presenceOverlay/.avatar (bottom-left, opacity .75 is
  the "semi transparence" itself) and the clickable/sent/pulse states,
  .pauseRequestBanner.

Verified with mocked members/pauseRequested Socket.IO events against the
real running local server (see session history): initials/colors render
correctly, the host's own circle never gets the clickable affordance,
rapid repeat clicks collapse to exactly one requestPause emission (three
synchronous clicks -> one new emitted event), a pauseRequested event
banners and pulses the right circle. Re-checked the pre-existing host path
(becomeHost, settings menu, next-episode button) still works unchanged -
this pass only touched renderMembers() and init()'s connectParty() call.

docs/architecture.md has the full writeup. Also fixed two roadmap
checkboxes in README.md that should have been checked off by the previous
commit (live lobby/host handoff/no join codes) but were missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:05:40 +02:00

100 lines
3.5 KiB
JavaScript

// Party REST + realtime - same origin as everything else, auth is the
// session cookie (see embyApi.js), so no headers/tokens to pass around here.
// Realtime is Socket.IO (window.io, loaded via <script> in index.html) -
// gives us automatic reconnection and, on the server side, heartbeat-based
// dead-connection detection for free (see server/src/socketHandler.js).
//
// No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md.
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 function createParty(itemId) {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
}
export function joinParty(partyId) {
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
}
export function leaveParty(partyId) {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`);
}
/**
* 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.js's router
* cleanup) - same leak-prevention pattern as connectParty below.
*/
export function connectLobby(onParties) {
const socket = window.io();
socket.on('connect', () => socket.emit('subscribeLobby'));
socket.on('parties', onParties);
return {
disconnect() {
socket.disconnect();
}
};
}
/**
* 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.js/player.js) - otherwise the socket, and whatever it's
* keeping alive via closures, never gets released.
*/
export function connectParty(partyId, {
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onError, onClose
} = {}) {
const socket = window.io();
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
// also covers rejoining after a transient network drop, not just the
// first connection.
socket.on('connect', () => {
socket.emit('join', { partyId });
});
if (onMembers) socket.on('members', onMembers);
if (onPlaystate) socket.on('playstate', onPlaystate);
if (onItemChanged) socket.on('itemChanged', onItemChanged);
if (onHostChanged) socket.on('hostChanged', onHostChanged);
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
if (onClose) socket.on('disconnect', onClose);
return {
sendPlaystate(isPaused, positionTicks) {
socket.emit('playstate', { isPaused, positionTicks });
},
/** Host-only: advance the party's queue (e.g. to the next episode). */
next() {
socket.emit('next');
},
/** Asks the host (and tells everyone else) "please pause". */
requestPause() {
socket.emit('requestPause');
},
leave() {
socket.emit('leave');
socket.disconnect();
}
};
}