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;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue