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');
+ });
});