Fix home page's party "elapsed" label ignoring pauses
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 15s

Reported: the time shown for an ongoing party is the movie's elapsed
time. True, but only by coincidence - the label was actually just
Date.now() - party.createdUtc, which climbs at wall-clock speed
regardless of whether the movie is actually playing. Pause it for five
minutes and the home page keeps counting through the pause the player
itself has frozen.

- server/src/types.ts, client/src/types.ts: LobbyPartyDto gains
  positionTicks/isPaused/positionUpdatedUtc - the host's own tracked
  playback state (same values SocketGateway already maintains for their
  party's own room), null for music parties, where there's no single
  "elapsed" across a rotating queue of tracks.
- server/src/realtime/SocketGateway.ts: lobbySnapshot() fills these from
  the host's member record for video parties. Two new broadcastLobby()
  trigger points to keep it reasonably fresh: an actual pause/play
  transition (not every 3s heartbeat resend - same guard already used
  for that transition's log line) and advancing the queue (advanceQueue
  already reset every member's position for the new item; nothing had
  ever pushed that to the lobby before, so a card's title/poster/position
  could sit stale until an unrelated join/leave refreshed it).
- client/src/views/home/Home.ts: updateElapsedLabels() now computes a
  live, pause-aware position for a video party (frozen while paused,
  ticking from position+timestamp while playing) instead of always
  falling back to createdUtc - which is still exactly what a music party
  (or a party missing these fields) uses.
- server/test/socketGateway.test.ts: two new cases - the lobby snapshot's
  video-vs-music shape, and that a transition/next() refreshes it while
  an unchanged heartbeat resend doesn't.

Verified against the real bundled client with a faked "parties" push:
a paused party's label held still across a real wait; a playing one, and
a music party on the unchanged createdUtc path, both kept ticking up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 13:53:10 +02:00
parent fff0b7a984
commit a5aca4a823
6 changed files with 161 additions and 9 deletions

View file

@ -123,6 +123,12 @@ export interface LobbyPartyDto {
hostUserName: string | null;
memberCount: number;
createdUtc: number;
// Video only - null for music. See Home.ts's updateElapsedLabels() for
// how these turn into a live, pause-aware "elapsed" label instead of
// just ticking wall-clock time since createdUtc regardless of pauses.
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
}
export interface ItemChangedMsg {

View file

@ -48,7 +48,12 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
· <span class="elapsed" data-created="${p.createdUtc}">--:--</span>
· <span class="elapsed"
data-created="${p.createdUtc}"
data-position-ticks="${p.positionTicks ?? ''}"
data-paused="${p.isPaused ? '1' : '0'}"
data-position-updated="${p.positionUpdatedUtc ?? ''}"
>--:--</span>
</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
@ -74,9 +79,35 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
updateElapsedLabels(root);
}
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/**
* Ticks every visible party card's "elapsed" label once a second, purely
* client-side between lobby broadcasts - same "snapshot + local tick"
* shape as the player's own position sync and MusicQueueService's
* getElapsedSeconds(), rather than the server pushing this every second
* for every party on every home-page tab.
*
* A video party (positionTicks present - see LobbyPartyDto) shows the
* host's actual playback position, frozen while paused: this used to
* just be `now - createdUtc`, which kept climbing even while the movie
* itself was paused - technically "how long the party's existed", but
* misread as (and reported as) the movie's own elapsed time, which it
* only ever matched by coincidence, before anyone paused. A music party
* (positionTicks null - there's no single "elapsed" across a rotating
* queue) still falls back to that same createdUtc-based party age.
*/
function updateElapsedLabels(root: HTMLElement): void {
const now = Date.now();
root.querySelectorAll<HTMLElement>('.elapsed').forEach((el) => {
if (el.dataset.positionTicks) {
const positionMs = (Number(el.dataset.positionTicks) / TICKS_PER_SECOND) * 1000;
const isPaused = el.dataset.paused === '1';
const updatedUtc = Number(el.dataset.positionUpdated);
const elapsedMs = isPaused ? positionMs : positionMs + (now - updatedUtc);
el.textContent = formatElapsed(elapsedMs);
return;
}
const created = Number(el.dataset.created);
if (!created) return;
el.textContent = formatElapsed(now - created);

View file

@ -253,6 +253,33 @@ 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.
**Much later: reported as "the elapsed time shown is the movie's elapsed
time" - correct, but that was the bug.** The `createdUtc`-based label
above was never wrong about what it computed (party age), but ticking
wall-clock time regardless of pause state means it only ever *reads* as
the movie's own elapsed time by coincidence, for as long as nobody pauses
- once someone does, the two drift apart, and the number on the home page
keeps climbing through a pause the actual player has frozen. Fixed by
giving `LobbyPartyDto` the host's real position instead, for video
parties only (`positionTicks`/`isPaused`/`positionUpdatedUtc` - null for
music, where there's no single "elapsed" across a rotating queue of
tracks the way there is for one movie/episode - `Home.ts` falls back to
the original `createdUtc` behavior for those). The host is already
authoritative for this inside their own party's room (`"playstate"`); the
lobby snapshot just reads the same tracked value `SocketGateway` already
had, rather than a party-level position needing to be kept in sync
separately. `broadcastLobby` gained two more trigger points to keep this
reasonably fresh - an actual pause/play transition (not every 3s
heartbeat resend, same discipline as the existing playstate-logging
guard) and advancing the queue (`"next"` - `PartyService.advanceQueue`
already reset every member's position to zero for the new item, but
nothing had ever pushed that reset to the lobby before, so the card could
show a stale position, and a stale title/poster, until some unrelated
join/leave happened to refresh it). Verified against the real bundled
client with a faked `parties` push: a paused party's label held exactly
still across a real wait; a playing one, and a music party still on the
old `createdUtc` path, both kept ticking up correctly.
## Player: no page scroll
`.playerWrap` was `width:100%; height:100vh` sitting inside `.mainContent`,

View file

@ -93,14 +93,18 @@ class SocketGatewayClass {
member.lastUpdatedUtc = Date.now();
party.lastActivityUtc = Date.now();
// Logged only on an actual play/pause transition, not every one of
// these - the host's player also re-sends the current state on a
// plain 3s heartbeat (see player), which would otherwise spam this
// line for every active party every 3 seconds regardless of
// whether anything actually changed.
// Logged (and the lobby refreshed) only on an actual play/pause
// transition, not every one of these - the host's player also
// re-sends the current state on a plain 3s heartbeat (see player),
// which would otherwise spam this line - and every home-page
// viewer's socket - for every active party every 3 seconds
// regardless of whether anything actually changed. Between
// transitions, the home page just ticks its own "elapsed" label
// locally from the last broadcast position - see Home.ts.
if (member.isPaused !== wasPaused) {
const positionSeconds = Math.round(member.positionTicks / 10000000);
Logger.party(`playstate partyId=${party.id} host=${Logger.who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`);
this.broadcastLobby(io);
}
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
@ -119,6 +123,12 @@ class SocketGatewayClass {
queueLength: party.queue.length,
hasNext: party.queueIndex < party.queue.length - 1
});
// advanceQueue() already reset every member's position/pause to
// (0, false) for the new item - refresh the lobby now so the
// home page's card picks up the new title/poster AND that reset
// immediately, rather than only whenever some other event (a
// join/leave) happens to refresh it next.
this.broadcastLobby(io);
}
});
@ -212,6 +222,12 @@ class SocketGatewayClass {
private lobbySnapshot(): LobbyPartyDto[] {
return PartyService.listActive().map((party) => {
const host = party.members.get(party.hostUserId);
// Video only - see LobbyPartyDto's own comment for why music parties
// get null here and fall back to createdUtc client-side instead.
// The host is authoritative for position/pause the same way they are
// inside the party's own room (see "playstate" below) - no separate
// "party-level" position is tracked anywhere.
const isVideo = party.mediaType === 'video';
return {
partyId: party.id,
mediaType: party.mediaType,
@ -220,7 +236,10 @@ class SocketGatewayClass {
itemImageUrl: party.itemImageUrl,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
positionTicks: isVideo && host ? host.positionTicks : null,
isPaused: isVideo && host ? host.isPaused : null,
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null
};
});
}

View file

@ -224,7 +224,18 @@ export interface LobbyPartyDto {
itemImageUrl: string;
hostUserName: string | null;
memberCount: number;
createdUtc: number;
createdUtc: number; // music parties (and any party's own age) still key off this
// Video only - null for music (no single "elapsed" makes sense across a
// rotating queue of tracks the way it does for one movie/episode). The
// host's own last-reported position/pause state (same values their
// "playstate" heartbeat already tracks - see SocketGateway) plus the
// moment they were reported, so the client can tick a live, pause-aware
// "elapsed" label itself between broadcasts instead of the home page
// just counting wall-clock time since createdUtc regardless of whether
// the movie was ever actually playing that whole time.
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
}
export interface LeaveResult {

View file

@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { SocketGateway } from '../src/realtime/SocketGateway';
import type { AppServer, AppSocket } from '../src/types';
import type { AppServer, AppSocket, LobbyPartyDto } from '../src/types';
type Handler = (payload?: unknown) => void;
@ -181,4 +181,62 @@ describe('SocketGateway', () => {
const pauseRequestLines = logged.filter((l) => l.includes(' pauseRequested '));
assert.equal(pauseRequestLines.length, 1, 'an immediate repeat must not produce a second line');
});
it('the lobby snapshot exposes the host\'s live position for a video party, and null for a music party', () => {
const io = fakeIo();
attach(io);
const videoParty = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const musicParty = PartyService.create('u-bob', 'Bob', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(musicParty, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-bob', 'Bob');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: videoParty.id });
aliceSocket.trigger('playstate', { isPaused: false, positionTicks: 50_000_000 }); // 5s in, playing
const subscriber = fakeSocket('u-eve');
io.trigger('connection', subscriber as unknown as AppSocket);
subscriber.trigger('subscribeLobby');
const parties = subscriber.emitted.find(([event]) => event === 'parties')![1] as LobbyPartyDto[];
const video = parties.find((p) => p.partyId === videoParty.id)!;
const music = parties.find((p) => p.partyId === musicParty.id)!;
assert.equal(video.positionTicks, 50_000_000);
assert.equal(video.isPaused, false);
assert.ok(video.positionUpdatedUtc, 'should carry a timestamp so the client can tick from it');
assert.equal(music.positionTicks, null, 'no single "elapsed" makes sense across a rotating music queue');
assert.equal(music.isPaused, null);
assert.equal(music.positionUpdatedUtc, null);
});
it('a play/pause transition refreshes the lobby (an unchanged heartbeat resend does not); advancing the queue does too', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
const socket = fakeSocket('u-alice');
io.trigger('connection', socket as unknown as AppSocket);
socket.trigger('join', { partyId: party.id });
const lobbyRefreshCount = () => io.roomEmits.filter((e) => e.room === 'lobby' && e.event === 'parties').length;
// The default new-member state is isPaused:true (see PartyService's
// newMember) - becoming "playing" is a real transition, so this
// should refresh the lobby once.
socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 });
assert.equal(lobbyRefreshCount(), 1);
// Same isPaused, only position moving - the player's 3s heartbeat
// resend (see Player.ts) - must NOT refresh the lobby again, same
// discipline as the "playstate logs only on a transition" test above.
socket.trigger('playstate', { isPaused: false, positionTicks: 40_000_000 });
assert.equal(lobbyRefreshCount(), 1);
socket.trigger('next');
assert.equal(lobbyRefreshCount(), 2, 'advancing the queue resets position/pause for the new item - the home page needs that immediately, not just whenever some other event next refreshes it');
});
});