Live lobby, host handoff reflected live, remove join codes, fix player scroll
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Three requested changes plus one found-in-passing bug fix: 1. Home page's "watch parties en cours" is now a live Socket.IO subscription (connectLobby -> "lobby" room), not a one-shot fetch - updates the instant anything changes anywhere (create/join/leave). Host name + member count come from the snapshot; elapsed time ticks purely client-side from a createdUtc timestamp (no point pushing a recomputed duration every second for every viewer). home.js returns this subscription from its router cleanup, same leak-prevention pattern as the player's socket. 2. Host handoff is now live end to end, not just correct in the data model. PartyManager.leave already promoted another member and destroyed an empty party (that part predates this change) - what was missing was any way for an already-open follower player to find out it became host. applyLeave (shared by the socket leave/ disconnect handlers and the REST leave endpoint, so this and the lobby broadcast only live in one place) now emits a personal "hostChanged" to the new host's own socket; player.js's becomeHost() unlocks controls live. becomeHost is written to tolerate being called more than once safely (addEventListener no-ops on a repeat identical listener; the heartbeat setInterval is explicitly guarded since it has no such idempotence - calling it twice would leak the first interval, the exact bug class the previous pass fixed). 3. No more join codes - PartyManager no longer generates or tracks one. The live lobby list is the only discovery mechanism now; a code copied out of band doesn't add anything on top of a list that's already always current. POST /api/parties/:partyId/join replaces :joinCode; the "Rejoindre avec un code" UI is gone. Found while touching this: the home page's "Rejoindre" button used to navigate to the player WITHOUT ever calling the join endpoint first - it loaded fine (GET /api/parties/:id never checked membership) but the realtime "join" was silently rejected server-side (not an actual member), so sync never worked for anyone joining this way. Fixed to await joinParty() before navigating, matching how the library grid's tiles already did it. Also: .playerWrap was width:100%/height:100vh inside .mainContent's own padding, so the two stacked and made the page taller than the viewport - an outer scrollbar despite the video already being full- height. Changed to position:fixed/inset:0 so it fills the real viewport regardless of ancestor padding. Verified live against a real running server (see session history): lobby subscription renders host/member-count/ticking-elapsed correctly from a faked "parties" push (createdUtc 2:05 in the past rendered "2:06" after ~1s more, proving the client-side tick is real, not static); the join-then-navigate fix calls the join endpoint before navigating; .playerWrap computes to position:fixed with all four insets at 0. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d535d18d8
commit
b6019bd211
9 changed files with 315 additions and 133 deletions
13
README.md
13
README.md
|
|
@ -13,13 +13,18 @@ including why this replaced an earlier Emby-plugin attempt (still in
|
|||
1. Sign in with your Emby account (just username/password — the server
|
||||
address is fixed server-side, see Deploying below).
|
||||
2. A sidebar lists your Emby libraries; the home page shows every ongoing
|
||||
watch party plus a "recently added" row per library, Emby-style.
|
||||
watch party live (host, how long it's been running, member count) plus a
|
||||
"recently added" row per library, Emby-style. No join codes — if a party
|
||||
is running, it's already on the list, updating in real time.
|
||||
3. Pick a movie/episode tile and hit "Lancer une watch party" (or drill into
|
||||
a Series' seasons/episodes first) — you become the host — or join one
|
||||
with a 6-character code.
|
||||
a Series' seasons/episodes first) — you become the host — or hit
|
||||
"Rejoindre" on any party already listed on the home page.
|
||||
4. The host's play/pause/seek drives everyone else's player automatically;
|
||||
starting from an episode queues the rest of the series so the host can
|
||||
just hit "Épisode suivant" instead of restarting a party each time.
|
||||
just hit "Épisode suivant" instead of restarting a party each time. If
|
||||
the host leaves, the party hands off to another member automatically
|
||||
(their player unlocks live, no reload needed) — only closing once
|
||||
everyone's gone.
|
||||
|
||||
Everything — catalog, images, video, sync — flows through this app's own
|
||||
backend, which proxies whatever it needs from Emby server-side. The browser
|
||||
|
|
|
|||
|
|
@ -412,9 +412,14 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
/* ---- Player ---- */
|
||||
|
||||
.playerWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
/* Fixed + inset so it fills the actual viewport regardless of
|
||||
.mainContent's own padding (which used to add to 100vh and push the
|
||||
whole page taller than the screen, causing an outer scrollbar). Also
|
||||
covers the sidebar while playing, like most video players' theater
|
||||
mode - "Quitter" is how you get back to it. */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
// 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, {
|
||||
|
|
@ -17,17 +20,12 @@ async function apiFetch(path, options = {}) {
|
|||
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 joinParty(partyId) {
|
||||
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function leaveParty(partyId) {
|
||||
|
|
@ -38,13 +36,31 @@ 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, onError, onClose } = {}) {
|
||||
export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onHostChanged, onError, onClose } = {}) {
|
||||
const socket = window.io();
|
||||
|
||||
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
||||
|
|
@ -57,6 +73,7 @@ export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, o
|
|||
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 (onError) socket.on('errorMessage', (msg) => onError(msg.message));
|
||||
if (onClose) socket.on('disconnect', onClose);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getLibraries, getRecentItems } from '../embyApi.js';
|
||||
import { listActiveParties, createParty, joinParty } from '../party.js';
|
||||
import { connectLobby, joinParty, createParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
|
||||
|
||||
|
|
@ -7,12 +7,7 @@ export function renderHome(root) {
|
|||
root.innerHTML = `
|
||||
<div class="pageHeader">
|
||||
<h1>Accueil</h1>
|
||||
<div class="joinInline">
|
||||
<input id="joinCode" maxlength="6" placeholder="Code de party" style="text-transform:uppercase" />
|
||||
<button id="joinBtn">Rejoindre</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="joinError" class="error"></p>
|
||||
|
||||
<section class="section" id="partiesSection" hidden>
|
||||
<h2>Watch parties en cours</h2>
|
||||
|
|
@ -21,45 +16,86 @@ export function renderHome(root) {
|
|||
|
||||
<div id="recentSections"></div>`;
|
||||
|
||||
root.querySelector('#joinBtn').addEventListener('click', async () => {
|
||||
const code = root.querySelector('#joinCode').value.trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const party = await joinParty(code);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
root.querySelector('#joinError').textContent = err.message;
|
||||
}
|
||||
});
|
||||
// Live, not a one-shot fetch: the list (and each card's host/member count)
|
||||
// updates the moment anything changes anywhere - see
|
||||
// server/src/socketHandler.js's "lobby" room. The elapsed-time label is
|
||||
// the one piece that ticks purely client-side (from createdUtc) - no
|
||||
// point re-pushing from the server once a second just for a clock.
|
||||
const lobby = connectLobby((parties) => renderParties(root, parties));
|
||||
const elapsedTimer = setInterval(() => updateElapsedLabels(root), 1000);
|
||||
|
||||
loadParties(root);
|
||||
loadRecentSections(root);
|
||||
|
||||
// Called by router.js before rendering whatever comes next - stops the
|
||||
// lobby socket and the ticking clock from running after you've navigated
|
||||
// away (see docs/architecture.md's memory-leak writeup).
|
||||
return function cleanup() {
|
||||
lobby.disconnect();
|
||||
clearInterval(elapsedTimer);
|
||||
};
|
||||
}
|
||||
|
||||
async function loadParties(root) {
|
||||
let parties;
|
||||
try {
|
||||
parties = await listActiveParties();
|
||||
} catch (err) {
|
||||
return; // non-critical - the rest of the home page still works
|
||||
}
|
||||
if (!parties.length) return;
|
||||
function renderParties(root, parties) {
|
||||
const section = root.querySelector('#partiesSection');
|
||||
const list = root.querySelector('#partiesCarousel');
|
||||
if (!section || !list) return; // navigated away mid-flight
|
||||
|
||||
root.querySelector('#partiesSection').hidden = false;
|
||||
root.querySelector('#partiesCarousel').innerHTML = parties.map((p) => `
|
||||
if (!parties.length) {
|
||||
section.hidden = true;
|
||||
list.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
section.hidden = false;
|
||||
list.innerHTML = parties.map((p) => `
|
||||
<div class="partyCard">
|
||||
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||
<div class="partyCardInfo">
|
||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
|
||||
<span class="hint">
|
||||
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
||||
· <span class="elapsed" data-created="${p.createdUtc}">--:--</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
root.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`));
|
||||
list.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// Actually register as a member before navigating - the player's
|
||||
// realtime "join" is rejected server-side otherwise (you'd load the
|
||||
// video but never get added to the members list or sync).
|
||||
await joinParty(btn.dataset.partyId);
|
||||
navigate(`/party/${btn.dataset.partyId}`);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateElapsedLabels(root);
|
||||
}
|
||||
|
||||
function updateElapsedLabels(root) {
|
||||
const now = Date.now();
|
||||
root.querySelectorAll('.elapsed').forEach((el) => {
|
||||
const created = Number(el.dataset.created);
|
||||
if (!created) return;
|
||||
el.textContent = formatElapsed(now - created);
|
||||
});
|
||||
}
|
||||
|
||||
function formatElapsed(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
async function loadRecentSections(root) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export function renderPartyPlayer(root, params) {
|
|||
|
||||
<div class="overlay">
|
||||
<span id="itemTitle" class="itemTitle"></span>
|
||||
<span>Code: <span id="code" class="code"></span></span>
|
||||
<span id="members" class="members"></span>
|
||||
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
||||
|
||||
|
|
@ -55,7 +54,6 @@ export function renderPartyPlayer(root, params) {
|
|||
const errorBox = root.querySelector('#playerError');
|
||||
const errorText = root.querySelector('#playerErrorText');
|
||||
const itemTitleEl = root.querySelector('#itemTitle');
|
||||
const codeEl = root.querySelector('#code');
|
||||
const membersEl = root.querySelector('#members');
|
||||
const nextBtn = root.querySelector('#nextBtn');
|
||||
const followerNotice = root.querySelector('#followerNotice');
|
||||
|
|
@ -86,6 +84,7 @@ export function renderPartyPlayer(root, params) {
|
|||
});
|
||||
|
||||
let isHost = false;
|
||||
let hasNextQueued = false;
|
||||
let conn = null;
|
||||
let hls = null;
|
||||
let currentItemId = null;
|
||||
|
|
@ -119,6 +118,34 @@ export function renderPartyPlayer(root, params) {
|
|||
conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes this client to host - called either at init (if we already
|
||||
* were) or on a live "hostChanged" event (the previous host left and the
|
||||
* server picked us next - see socketHandler.js's applyLeave). Safe to
|
||||
* call more than once: the early return covers the normal case, and
|
||||
* addEventListener with the same function reference is a no-op if already
|
||||
* attached, so even a re-entrant call can't double up the play/pause/
|
||||
* seeked listeners. The heartbeat is guarded separately since setInterval
|
||||
* has no such built-in idempotence - calling it twice would leak the
|
||||
* first interval's id.
|
||||
*/
|
||||
function becomeHost(notify) {
|
||||
if (isHost) return;
|
||||
isHost = true;
|
||||
video.controls = true;
|
||||
settingsWrap.classList.remove('hide');
|
||||
followerNotice.classList.add('hide');
|
||||
nextBtn.classList.toggle('hide', !hasNextQueued);
|
||||
|
||||
video.addEventListener('play', sendLocalState);
|
||||
video.addEventListener('pause', sendLocalState);
|
||||
video.addEventListener('seeked', sendLocalState);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); });
|
||||
if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000);
|
||||
|
||||
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
||||
}
|
||||
|
||||
async function loadTrackOptions(itemId) {
|
||||
try {
|
||||
const options = await getPlaybackOptions(itemId);
|
||||
|
|
@ -200,7 +227,8 @@ export function renderPartyPlayer(root, params) {
|
|||
|
||||
async function onItemChanged(msg) {
|
||||
itemTitleEl.textContent = msg.itemName || '';
|
||||
nextBtn.classList.toggle('hide', !(isHost && msg.hasNext));
|
||||
hasNextQueued = !!msg.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
await loadTrackOptions(msg.itemId);
|
||||
loadVideo(msg.itemId, 0, true);
|
||||
}
|
||||
|
|
@ -215,13 +243,9 @@ export function renderPartyPlayer(root, params) {
|
|||
return;
|
||||
}
|
||||
|
||||
isHost = party.youAreHost;
|
||||
itemTitleEl.textContent = party.itemName || '';
|
||||
codeEl.textContent = party.joinCode;
|
||||
video.controls = isHost;
|
||||
settingsWrap.classList.toggle('hide', !isHost);
|
||||
followerNotice.classList.toggle('hide', isHost);
|
||||
nextBtn.classList.toggle('hide', !(isHost && party.hasNext));
|
||||
hasNextQueued = !!party.hasNext;
|
||||
followerNotice.classList.toggle('hide', party.youAreHost);
|
||||
|
||||
await loadTrackOptions(party.itemId);
|
||||
loadVideo(party.itemId, 0, false);
|
||||
|
|
@ -230,19 +254,12 @@ export function renderPartyPlayer(root, params) {
|
|||
onMembers: renderMembers,
|
||||
onPlaystate: applyRemoteState,
|
||||
onItemChanged,
|
||||
onHostChanged: () => becomeHost(true),
|
||||
onError: (message) => alert(message),
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
if (isHost) {
|
||||
video.addEventListener('play', sendLocalState);
|
||||
video.addEventListener('pause', sendLocalState);
|
||||
video.addEventListener('seeked', sendLocalState);
|
||||
video.addEventListener('ended', () => {
|
||||
if (!nextBtn.classList.contains('hide')) conn.next();
|
||||
});
|
||||
heartbeat = setInterval(sendLocalState, 3000);
|
||||
}
|
||||
if (party.youAreHost) becomeHost(false);
|
||||
}
|
||||
|
||||
function renderMembers(members) {
|
||||
|
|
|
|||
|
|
@ -186,6 +186,74 @@ difference is the error event, sent as `"errorMessage"` rather than
|
|||
`"error"`, since Socket.IO reserves the latter for its own internal error
|
||||
handling.
|
||||
|
||||
## Live lobby, host handoff, no join codes
|
||||
|
||||
Three related changes, all in service of "the home page should reflect
|
||||
reality without a refresh":
|
||||
|
||||
- **The home page's party list is a live subscription, not a fetch.**
|
||||
`client/js/party.js`'s `connectLobby` joins a `"lobby"` Socket.IO room
|
||||
(`subscribeLobby` → `server/src/socketHandler.js`) and gets a fresh
|
||||
snapshot on subscribe, then again every time `broadcastLobby(io)` runs -
|
||||
which is now wired into every place party membership changes: creating a
|
||||
party, joining one (both in `index.js`'s REST handlers), and leaving
|
||||
(`applyLeave`, used by both the socket `leave`/`disconnect` handlers and
|
||||
the REST leave endpoint, so this - and the host-handoff notification below
|
||||
- only has to be right in one place). `home.js` returns this subscription
|
||||
as part of its router cleanup function, same as the player does for its
|
||||
own socket - an open lobby subscription left running after navigating away
|
||||
would have been exactly the same class of leak just fixed above.
|
||||
- **Host and elapsed time are shown per party**, per the request. Host name
|
||||
and member count come from the same snapshot. Elapsed time doesn't: it
|
||||
ticks client-side from a `createdUtc` timestamp the server includes once
|
||||
(`home.js`'s `setInterval` re-renders every `.elapsed` label every
|
||||
second) - pushing a recomputed duration from the server every second for
|
||||
every viewer would be pure waste when the client can derive it from one
|
||||
number. Verified live (see session history): faking a `parties` push with
|
||||
a `createdUtc` two minutes in the past and waiting rendered "2:06", not a
|
||||
static value.
|
||||
- **No join codes.** `PartyManager` no longer generates or tracks one - see
|
||||
its class comment. The home page's live list is now the only way to find
|
||||
a party; a code that has to be copied and typed out of band doesn't add
|
||||
anything on top of a list that's already always current. `POST
|
||||
/api/parties/:partyId/join` replaces the old `:joinCode` route.
|
||||
- **Host handoff is live, not just correct in the data model.**
|
||||
`PartyManager.leave` already promoted another member to host when the
|
||||
host left and destroyed the party once empty - that part predates this
|
||||
change. What was missing: a player already open as a follower had no way
|
||||
to find out it became host mid-session. `applyLeave` now emits a personal
|
||||
`"hostChanged"` (to that one socket, not the room) when this happens;
|
||||
`player.js`'s `becomeHost()` - the same function `init()` calls when you
|
||||
already are the host - unlocks the controls live, no reload needed.
|
||||
`becomeHost` is written to tolerate being called more than once safely:
|
||||
`addEventListener` with the same function reference is a no-op if already
|
||||
attached, and the heartbeat `setInterval` is explicitly guarded
|
||||
(`if (!heartbeat)`) since `setInterval` has no such built-in idempotence -
|
||||
calling it twice would leak the first interval's id, the exact class of
|
||||
bug the rest of this pass was about fixing, not reintroducing.
|
||||
|
||||
One incidental bug found and fixed while touching this code: the home
|
||||
page's "Rejoindre" button on a live party card used to `navigate()` straight
|
||||
to the player **without ever calling the join endpoint first** - it worked
|
||||
well enough to load the video and read basic party info (`GET
|
||||
/api/parties/:id` never checked membership), but the realtime `"join"`
|
||||
would be rejected server-side ("Tu n'es pas membre de cette party.") since
|
||||
`socketHandler.js`'s join handler does check membership - meaning sync
|
||||
silently never worked for anyone who joined this way. Fixed by awaiting
|
||||
`joinParty()` before navigating, same as the library grid's tiles already
|
||||
did correctly.
|
||||
|
||||
## Player: no page scroll
|
||||
|
||||
`.playerWrap` was `width:100%; height:100vh` sitting inside `.mainContent`,
|
||||
which has its own padding - the two stacked (100vh *plus* padding) made the
|
||||
whole shell taller than the viewport, so the page grew a scrollbar even
|
||||
though the video itself was already full-height. Changed to
|
||||
`position:fixed; inset:0` so it's sized against the real viewport
|
||||
regardless of any ancestor's padding, and (like most players' theater mode)
|
||||
covers the sidebar while watching - confirmed via computed style
|
||||
(`position: fixed`, all four insets `0px`) rather than just asserted.
|
||||
|
||||
## Assumptions to verify
|
||||
|
||||
**Confirmed against the real server** (not a guess — actually exercised,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const {
|
|||
} = require('./embyAuth');
|
||||
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
|
||||
const { PartyManager } = require('./partyManager');
|
||||
const { attachSocketServer } = require('./socketHandler');
|
||||
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||
|
|
@ -309,13 +309,10 @@ function toItemDto(item, serverId) {
|
|||
}
|
||||
|
||||
// --- Parties ---------------------------------------------------------------
|
||||
|
||||
app.get('/api/parties', requireSession, (req, res) => {
|
||||
// Everyone on the server sees every active party here - that's the point:
|
||||
// it's how the home page's "watch parties en cours" list works, so people
|
||||
// can join without needing a code passed to them out of band.
|
||||
res.json({ parties: PartyManager.listActive().map((p) => PartyManager.toPublicState(p, req.embySession.userId)) });
|
||||
});
|
||||
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
||||
// list is live, over Socket.IO's "lobby" room (socketHandler.js) - see
|
||||
// docs/architecture.md. No join codes either - see partyManager.js's class
|
||||
// comment for why a live list makes one redundant.
|
||||
|
||||
app.post('/api/parties', requireSession, async (req, res) => {
|
||||
const itemId = req.body && req.body.itemId;
|
||||
|
|
@ -332,7 +329,7 @@ app.post('/api/parties', requireSession, async (req, res) => {
|
|||
const item = await embyRes.json();
|
||||
|
||||
// Starting a party from an episode queues the rest of the series from
|
||||
// there on, so the host can hit "next" (see wsHandler.js) instead of
|
||||
// there on, so the host can hit "next" (see socketHandler.js) instead of
|
||||
// everyone having to separately start a new party per episode.
|
||||
let queue = null;
|
||||
if (item.Type === 'Episode' && item.SeriesId) {
|
||||
|
|
@ -361,6 +358,7 @@ app.post('/api/parties', requireSession, async (req, res) => {
|
|||
`/api/catalog/image/${itemId}`,
|
||||
queue
|
||||
);
|
||||
broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||
});
|
||||
|
||||
|
|
@ -372,20 +370,23 @@ function formatEpisodeName(episode) {
|
|||
return episode.Name;
|
||||
}
|
||||
|
||||
app.post('/api/parties/:joinCode/join', requireSession, (req, res) => {
|
||||
const party = PartyManager.getByJoinCode(req.params.joinCode);
|
||||
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||
const party = PartyManager.getById(req.params.partyId);
|
||||
if (!party) {
|
||||
res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' });
|
||||
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
||||
return;
|
||||
}
|
||||
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
|
||||
broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||
});
|
||||
|
||||
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
||||
const party = PartyManager.getById(req.params.partyId);
|
||||
if (party) {
|
||||
PartyManager.leave(party, req.embySession.userId);
|
||||
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
||||
// handoff notification and the lobby refresh only live in one place.
|
||||
applyLeave(io, party, req.embySession.userId);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,18 +2,6 @@
|
|||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// No 0/O/1/I - easy to read aloud / type on a phone.
|
||||
const JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
function generateJoinCode() {
|
||||
const bytes = crypto.randomBytes(6);
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += JOIN_CODE_ALPHABET[bytes[i] % JOIN_CODE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
|
||||
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
|
||||
|
|
@ -21,11 +9,15 @@ function generateJoinCode() {
|
|||
* everyone else's client just applies whatever the host reports. Nothing is
|
||||
* persisted to disk; a server restart clears every party, which is fine - a
|
||||
* party is meant to live for one watch session, not persist for weeks.
|
||||
*
|
||||
* No join codes: the home page's live "watch parties en cours" list (see
|
||||
* socketHandler.js's lobby broadcast) is the only way to find a party, and
|
||||
* since that's real-time, a code that has to be copied/typed out of band
|
||||
* doesn't add anything - see docs/architecture.md.
|
||||
*/
|
||||
class PartyManager {
|
||||
constructor() {
|
||||
this.partiesById = new Map();
|
||||
this.partyIdByJoinCode = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,12 +26,11 @@ class PartyManager {
|
|||
* one item; for "start watching this series from here", index.js builds
|
||||
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
|
||||
* always mirror queue[queueIndex] - kept as top-level fields too since
|
||||
* that's what most of the UI (and the old single-item design) reads.
|
||||
* that's what most of the UI reads.
|
||||
*/
|
||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue) {
|
||||
const party = {
|
||||
id: crypto.randomUUID(),
|
||||
joinCode: generateJoinCode(),
|
||||
itemId,
|
||||
itemName,
|
||||
itemImageUrl,
|
||||
|
|
@ -54,7 +45,6 @@ class PartyManager {
|
|||
|
||||
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
|
||||
this.partiesById.set(party.id, party);
|
||||
this.partyIdByJoinCode.set(party.joinCode, party.id);
|
||||
return party;
|
||||
}
|
||||
|
||||
|
|
@ -62,11 +52,6 @@ class PartyManager {
|
|||
return this.partiesById.get(partyId) || null;
|
||||
}
|
||||
|
||||
getByJoinCode(joinCode) {
|
||||
const id = this.partyIdByJoinCode.get((joinCode || '').toUpperCase());
|
||||
return id ? this.getById(id) : null;
|
||||
}
|
||||
|
||||
join(party, userId, userName) {
|
||||
const member = this._newMember(userId, userName);
|
||||
party.members.set(userId, member);
|
||||
|
|
@ -74,21 +59,31 @@ class PartyManager {
|
|||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a member. If they were the host, promotes whoever's left
|
||||
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
||||
* left, the party is destroyed outright. Returns what happened so callers
|
||||
* (socketHandler.js's applyLeave) can notify the new host and refresh the
|
||||
* home page's live list - see docs/architecture.md.
|
||||
*/
|
||||
leave(party, userId) {
|
||||
const wasHost = party.hostUserId === userId;
|
||||
party.members.delete(userId);
|
||||
party.lastActivityUtc = Date.now();
|
||||
|
||||
if (party.hostUserId === userId) {
|
||||
// Host left: promote whoever's left (arbitrary but deterministic-ish;
|
||||
// good enough for v1 - matches the old plugin's reference-fallback logic).
|
||||
let newHostUserId = party.hostUserId;
|
||||
if (wasHost) {
|
||||
const next = party.members.keys().next();
|
||||
party.hostUserId = next.done ? null : next.value;
|
||||
newHostUserId = next.done ? null : next.value;
|
||||
party.hostUserId = newHostUserId;
|
||||
}
|
||||
|
||||
if (party.members.size === 0) {
|
||||
const destroyed = party.members.size === 0;
|
||||
if (destroyed) {
|
||||
this.partiesById.delete(party.id);
|
||||
this.partyIdByJoinCode.delete(party.joinCode);
|
||||
}
|
||||
|
||||
return { hostChanged: wasHost && newHostUserId != null, newHostUserId, destroyed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -124,7 +119,6 @@ class PartyManager {
|
|||
for (const party of this.partiesById.values()) {
|
||||
if (party.lastActivityUtc < cutoff) {
|
||||
this.partiesById.delete(party.id);
|
||||
this.partyIdByJoinCode.delete(party.joinCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,10 +133,10 @@ class PartyManager {
|
|||
const host = party.members.get(party.hostUserId);
|
||||
return {
|
||||
partyId: party.id,
|
||||
joinCode: party.joinCode,
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
createdUtc: party.createdUtc,
|
||||
hasNext: party.queueIndex < party.queue.length - 1,
|
||||
queueLength: party.queue.length,
|
||||
queueIndex: party.queueIndex,
|
||||
|
|
|
|||
|
|
@ -3,28 +3,23 @@
|
|||
const { PartyManager } = require('./partyManager');
|
||||
|
||||
/**
|
||||
* Realtime side of a party, over Socket.IO instead of a raw WebSocket.
|
||||
* Realtime side of the app: one party's own room, plus a shared "lobby" room
|
||||
* that powers the home page's live "watch parties en cours" list.
|
||||
*
|
||||
* Why the switch: Socket.IO gives us two things a raw `ws` server needs
|
||||
* hand-rolled - automatic client-side reconnection, and (the important one)
|
||||
* server-side heartbeat-based dead-connection detection. Without that,
|
||||
* a client that vanishes without a clean close (laptop sleep, network drop,
|
||||
* a killed tab that doesn't get to send a close frame) never fires a
|
||||
* disconnect event on a raw `ws` server - its party membership and the
|
||||
* dead socket reference stay in memory forever. Socket.IO's built-in
|
||||
* ping/pong (`pingInterval`/`pingTimeout`) closes those out on its own.
|
||||
*
|
||||
* Auth comes from the session cookie sent during the initial HTTP handshake,
|
||||
* same as the raw-WebSocket version - exposed here via
|
||||
* socket.request.headers.cookie.
|
||||
* Auth comes from the session cookie sent during the initial HTTP handshake -
|
||||
* exposed here via socket.request.headers.cookie.
|
||||
*
|
||||
* Events (client -> server unless noted):
|
||||
* "join" {partyId}
|
||||
* "members" (server -> client) [...]
|
||||
* "subscribeLobby" joins the "lobby" room
|
||||
* "parties" (server -> client) [...] full snapshot, sent on subscribe
|
||||
* and again whenever any party changes
|
||||
* "join" {partyId} joins that party's own room
|
||||
* "members" (server -> client) [...] that party's member list
|
||||
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
||||
* "playstate" (server -> client, to everyone but the sender)
|
||||
* "playstate" (server -> client, to everyone in the party but the sender)
|
||||
* "next" (host only - advance the queue)
|
||||
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||
* "hostChanged" (server -> client, to the new host only, no payload)
|
||||
* "leave"
|
||||
* "errorMessage" (server -> client) {message}
|
||||
* ("error" is a reserved Socket.IO event name - using our own to avoid
|
||||
|
|
@ -44,6 +39,11 @@ function attachSocketServer(io, { parseCookies, getSession }) {
|
|||
const userId = session.userId;
|
||||
let party = null;
|
||||
|
||||
socket.on('subscribeLobby', () => {
|
||||
socket.join('lobby');
|
||||
socket.emit('parties', lobbySnapshot());
|
||||
});
|
||||
|
||||
socket.on('join', (msg) => {
|
||||
const p = PartyManager.getById(msg && msg.partyId);
|
||||
const member = p && p.members.get(userId);
|
||||
|
|
@ -88,22 +88,42 @@ function attachSocketServer(io, { parseCookies, getSession }) {
|
|||
|
||||
socket.on('leave', () => {
|
||||
if (!party) return;
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(io, party);
|
||||
socket.disconnect(true);
|
||||
applyLeave(io, party, userId);
|
||||
party = null;
|
||||
});
|
||||
|
||||
// Fires on a clean "leave", a dead-connection timeout (see the class
|
||||
// comment above), or literally any other way the socket goes away -
|
||||
// this is the one cleanup path that always runs, unlike relying on the
|
||||
// client to always get a chance to emit "leave" first.
|
||||
// Fires on a clean "leave", a dead-connection timeout (Socket.IO's own
|
||||
// heartbeat - see docs/architecture.md), or any other way the socket
|
||||
// goes away - the one cleanup path that always runs, unlike relying on
|
||||
// the client to always get a chance to emit "leave" first.
|
||||
socket.on('disconnect', () => {
|
||||
if (party) {
|
||||
PartyManager.leave(party, userId);
|
||||
if (party) applyLeave(io, party, userId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `userId` from `party` and handles everything that follows from
|
||||
* it: if they were the host, the new host (if any) gets a personal
|
||||
* "hostChanged" so their player can promote itself without a page reload;
|
||||
* either way, the party's own room and the lobby both get refreshed.
|
||||
* Shared by the socket "leave"/"disconnect" handlers above and index.js's
|
||||
* REST leave endpoint so this logic - especially the host handoff - only
|
||||
* has to be right in one place.
|
||||
*/
|
||||
function applyLeave(io, party, userId) {
|
||||
const result = PartyManager.leave(party, userId);
|
||||
if (!result.destroyed) {
|
||||
broadcastMembers(io, party);
|
||||
if (result.hostChanged) {
|
||||
const newHost = party.members.get(result.newHostUserId);
|
||||
if (newHost && newHost.connection) {
|
||||
newHost.connection.emit('hostChanged');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
broadcastLobby(io);
|
||||
return result;
|
||||
}
|
||||
|
||||
function broadcastMembers(io, party) {
|
||||
|
|
@ -116,4 +136,23 @@ function broadcastMembers(io, party) {
|
|||
io.to(party.id).emit('members', members);
|
||||
}
|
||||
|
||||
module.exports = { attachSocketServer };
|
||||
function lobbySnapshot() {
|
||||
return PartyManager.listActive().map((party) => {
|
||||
const host = party.members.get(party.hostUserId);
|
||||
return {
|
||||
partyId: party.id,
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
hostUserName: host ? host.userName : null,
|
||||
memberCount: party.members.size,
|
||||
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastLobby(io) {
|
||||
io.to('lobby').emit('parties', lobbySnapshot());
|
||||
}
|
||||
|
||||
module.exports = { attachSocketServer, broadcastLobby, applyLeave };
|
||||
|
|
|
|||
Loading…
Reference in a new issue