diff --git a/client/src/types.ts b/client/src/types.ts index b56e832..e513eb4 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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 { diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index ae2169b..4d8f9ae 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -48,7 +48,12 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void { ${escapeHtml(p.itemName || '')} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} - · --:-- + · --:-- @@ -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('.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); diff --git a/docs/architecture.md b/docs/architecture.md index 1c60767..275978f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`, diff --git a/server/src/realtime/SocketGateway.ts b/server/src/realtime/SocketGateway.ts index bc5b6b8..8e62605 100644 --- a/server/src/realtime/SocketGateway.ts +++ b/server/src/realtime/SocketGateway.ts @@ -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 }; }); } diff --git a/server/src/types.ts b/server/src/types.ts index 429253a..fb92dce 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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 { diff --git a/server/test/socketGateway.test.ts b/server/test/socketGateway.test.ts index 42d56cd..fda4d25 100644 --- a/server/test/socketGateway.test.ts +++ b/server/test/socketGateway.test.ts @@ -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'); + }); });