Fix home page's party "elapsed" label ignoring pauses
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:
parent
fff0b7a984
commit
a5aca4a823
6 changed files with 161 additions and 9 deletions
|
|
@ -123,6 +123,12 @@ export interface LobbyPartyDto {
|
||||||
hostUserName: string | null;
|
hostUserName: string | null;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
createdUtc: 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 {
|
export interface ItemChangedMsg {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,12 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
${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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
||||||
|
|
@ -74,9 +79,35 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
updateElapsedLabels(root);
|
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 {
|
function updateElapsedLabels(root: HTMLElement): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
root.querySelectorAll<HTMLElement>('.elapsed').forEach((el) => {
|
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);
|
const created = Number(el.dataset.created);
|
||||||
if (!created) return;
|
if (!created) return;
|
||||||
el.textContent = formatElapsed(now - created);
|
el.textContent = formatElapsed(now - created);
|
||||||
|
|
|
||||||
|
|
@ -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
|
`joinParty()` before navigating, same as the library grid's tiles already
|
||||||
did correctly.
|
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
|
## Player: no page scroll
|
||||||
|
|
||||||
`.playerWrap` was `width:100%; height:100vh` sitting inside `.mainContent`,
|
`.playerWrap` was `width:100%; height:100vh` sitting inside `.mainContent`,
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,18 @@ class SocketGatewayClass {
|
||||||
member.lastUpdatedUtc = Date.now();
|
member.lastUpdatedUtc = Date.now();
|
||||||
party.lastActivityUtc = Date.now();
|
party.lastActivityUtc = Date.now();
|
||||||
|
|
||||||
// Logged only on an actual play/pause transition, not every one of
|
// Logged (and the lobby refreshed) only on an actual play/pause
|
||||||
// these - the host's player also re-sends the current state on a
|
// transition, not every one of these - the host's player also
|
||||||
// plain 3s heartbeat (see player), which would otherwise spam this
|
// re-sends the current state on a plain 3s heartbeat (see player),
|
||||||
// line for every active party every 3 seconds regardless of
|
// which would otherwise spam this line - and every home-page
|
||||||
// whether anything actually changed.
|
// 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) {
|
if (member.isPaused !== wasPaused) {
|
||||||
const positionSeconds = Math.round(member.positionTicks / 10000000);
|
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`);
|
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 });
|
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
|
||||||
|
|
@ -119,6 +123,12 @@ class SocketGatewayClass {
|
||||||
queueLength: party.queue.length,
|
queueLength: party.queue.length,
|
||||||
hasNext: party.queueIndex < party.queue.length - 1
|
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[] {
|
private lobbySnapshot(): LobbyPartyDto[] {
|
||||||
return PartyService.listActive().map((party) => {
|
return PartyService.listActive().map((party) => {
|
||||||
const host = party.members.get(party.hostUserId);
|
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 {
|
return {
|
||||||
partyId: party.id,
|
partyId: party.id,
|
||||||
mediaType: party.mediaType,
|
mediaType: party.mediaType,
|
||||||
|
|
@ -220,7 +236,10 @@ class SocketGatewayClass {
|
||||||
itemImageUrl: party.itemImageUrl,
|
itemImageUrl: party.itemImageUrl,
|
||||||
hostUserName: host ? host.userName : null,
|
hostUserName: host ? host.userName : null,
|
||||||
memberCount: party.members.size,
|
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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,18 @@ export interface LobbyPartyDto {
|
||||||
itemImageUrl: string;
|
itemImageUrl: string;
|
||||||
hostUserName: string | null;
|
hostUserName: string | null;
|
||||||
memberCount: number;
|
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 {
|
export interface LeaveResult {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
|
||||||
import { PartyService } from '../src/services/PartyService';
|
import { PartyService } from '../src/services/PartyService';
|
||||||
import { MusicQueueService } from '../src/services/MusicQueueService';
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
||||||
import { SocketGateway } from '../src/realtime/SocketGateway';
|
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;
|
type Handler = (payload?: unknown) => void;
|
||||||
|
|
||||||
|
|
@ -181,4 +181,62 @@ describe('SocketGateway', () => {
|
||||||
const pauseRequestLines = logged.filter((l) => l.includes(' pauseRequested '));
|
const pauseRequestLines = logged.filter((l) => l.includes(' pauseRequested '));
|
||||||
assert.equal(pauseRequestLines.length, 1, 'an immediate repeat must not produce a second line');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue