watchparty/client/js/party.js
Nicolas 07613c1cec
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Redesign home page: library tabs, poster tiles, ongoing parties list
- New backend endpoints: GET /api/catalog/libraries (Emby's
  /Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level,
  Recursive=false - movies as movies, TV as Series tiles), GET
  /api/parties (list every active party server-wide, for the home
  page's "watch parties en cours" section)
- Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video
  are directly streamable - a Series tile only gets "Voir sur Emby")
  and `embyUrl`, a deep link into Emby's own web client built from
  EMBY_SERVER_URL + serverId (now captured at login)
- Party creation now fetches and stores the item's name/poster so the
  home page's party list doesn't need a separate lookup per party
- client/js/views/browse.js rewritten: ongoing-parties section, a
  join-by-code fallback, one tab per library, and a tile grid with a
  hover-revealed two-button overlay (start party / view on Emby) - the
  first only shown for playable types
- Removed itemDetail.js/the /item/:id route (tiles act directly now,
  no intermediate detail page); player.js simplified to use
  party.itemName instead of a redundant item lookup
- app.css: full-width home layout (no max-width cap, as asked), CSS
  grid handling all the responsive reflow, :hover/:focus-within reveal
  for the tile overlay with an always-visible (hover: none) fallback
  for touch devices

Verified by actually driving a browser (mocked fetch standing in for
/api/parties and /api/catalog/* - real login still needs real
credentials): tab switching swaps content correctly, the overlay
reveals per-tile only, a Series tile correctly omits the watch-party
button, and the touch fallback shows both buttons inline without
hover. See docs/architecture.md for exactly what that does and doesn't
confirm (real Emby /Views and item-list response shapes are still
unverified).

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

76 lines
2.4 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, 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 === '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 }));
}
},
leave() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'leave' }));
}
ws.close();
}
};
}