Replace avatar-click with a request toolbar; add "request resume"
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 17s

Asked for directly: a visible bottom toolbar for non-host members
instead of clicking your own small avatar circle (never a very
discoverable affordance), and a second request alongside pause -
"please resume".

- server/src/types.ts, server/src/realtime/SocketGateway.ts: new
  requestResume/resumeRequested, byte-for-byte the same shape and
  handler pattern as requestPause/pauseRequested, independently
  throttled (its own lastResumeRequestUtc - not a shared budget with
  pause). Kept as a plain broadcast signal, same as pause always was -
  no accept/deny state tracked server-side, the host sees the same
  banner/avatar highlight as everyone else and acts with their own
  existing controls.
- client/src/api/party.ts, client/src/types.ts: requestResume() on
  PartyConnection, onResumeRequested callback, a ResumeRequestedMsg type
  (same shape as PauseRequestedMsg, kept separate so a callback's own
  signature says which request it's for).
- client/src/views/player/Player.{html,css,ts}: the avatar is presence-
  only now (no click handler, no "- demander une pause" title) - a new
  .bottomOverlay wraps the request banner, the follower notice, and a
  new .requestToolbar (Demander une pause / Demander une reprise) as one
  flex column, same "stack in normal flow instead of hand-picking a
  pixel offset per box" fix .topOverlay already needed. Pause and resume
  share one showRequestBanner() - they only ever differed by which word
  appears in the banner text. Renamed the shared CSS classes
  (avatarPauseRequested/avatarPauseBadge -> avatarActionRequested/
  avatarRequestBadge) since they're no longer pause-specific.
- server/test/socketGateway.test.ts: new case confirming requestResume's
  throttle is independent from requestPause's own.

Verified against the real compiled bundle: the toolbar shows for a
follower and stays hidden for the host; each button's own client-side
cooldown collapses a rapid double-click to one emission; a pushed
resumeRequested shows the banner with the right text and highlights the
right avatar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 17:09:01 +02:00
parent 838244ec41
commit c14d0bc08b
10 changed files with 217 additions and 91 deletions

View file

@ -28,9 +28,9 @@ including why this replaced an earlier Emby-plugin attempt (still in
(their player unlocks live, no reload needed) — only closing once
everyone's gone.
5. Everyone present shows up as a small avatar overlay on the video (their
initials, on the player itself). If you're not the host, clicking your
own avatar asks the host to pause — everyone sees the request, including
the host, without needing chat.
initials, on the player itself). If you're not the host, a small
toolbar lets you ask the host to pause or resume — everyone sees the
request, including the host, without needing chat.
6. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for
playback — anyone can add more tracks to the shared queue at any time,

View file

@ -7,7 +7,7 @@
//
// No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md.
import type { ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, SocketMember } from '../types';
import type { ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
@ -76,6 +76,7 @@ export interface PartyConnectionCallbacks {
onItemChanged?: (msg: ItemChangedMsg) => void;
onHostChanged?: () => void;
onPauseRequested?: (msg: PauseRequestedMsg) => void;
onResumeRequested?: (msg: ResumeRequestedMsg) => void;
onQueueUpdated?: (state: MusicPublicState) => void;
onError?: (message: string) => void;
onClose?: () => void;
@ -88,6 +89,9 @@ export interface PartyConnection {
/** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void;
/** Same idea, the other direction - "please resume". Independently
* throttled server-side from requestPause (see SocketGateway). */
requestResume(): void;
/** Music only: vote to skip the current track - see MusicQueueService
* on the server for the majority threshold. */
voteSkip(): void;
@ -103,7 +107,7 @@ export interface PartyConnection {
* alive via closures, never gets released.
*/
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose } = callbacks;
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onError, onClose } = callbacks;
const socket = io();
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
@ -118,6 +122,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg));
if (onResumeRequested) socket.on('resumeRequested', (m) => onResumeRequested(m as ResumeRequestedMsg));
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose());
@ -132,6 +137,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
requestPause() {
socket.emit('requestPause');
},
requestResume() {
socket.emit('requestResume');
},
voteSkip() {
socket.emit('voteSkip');
},

View file

@ -151,3 +151,11 @@ export interface PauseRequestedMsg {
userId: string;
userName: string;
}
/** Same shape as PauseRequestedMsg, kept as its own named type anyway
* (not reused) so a callback's own type signature says which request it's
* actually for - see api/party.ts's PartyConnectionCallbacks. */
export interface ResumeRequestedMsg {
userId: string;
userName: string;
}

View file

@ -120,17 +120,36 @@
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
.followerNotice {
/* margin:0 overrides the <p>'s default UA margin - for an absolutely
positioned box, "bottom" places the MARGIN edge, so the default ~1em
bottom margin was quietly pushing the pill 13px higher than its
declared bottom:24px ever suggested. Harmless on its own, but it made
.pauseRequestBanner's bottom offset above it read as barely any gap
at all once that one was measured against this element's real position. */
/* One column, bottom-anchored, gap between whichever children are
actually visible - same "stack in normal flow instead of guessing a
fixed pixel offset per absolutely-positioned block" fix .topOverlay's
own actionBar+presenceOverlay grouping already needed (see its own
comment, and docs/architecture.md for the exact bug this avoided: a
<p>'s default margin silently throwing off a hand-picked bottom offset
on the element above it - not repeatable once nothing is positioned
independently any more). */
.bottomOverlay {
position: absolute;
bottom: 24px;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 6;
max-width: calc(100% - 32px);
}
.requestBanner {
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
}
.followerNotice {
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
@ -139,30 +158,39 @@
color: var(--muted);
}
/* Only the viewer's own avatar gets this, and only while not host - see
renderMembers() in Player.ts. The host has direct controls, so their
own circle is presence-only. */
.avatarClickable {
cursor: pointer;
transition: opacity .15s, transform .15s;
/* The non-host's way to ask the host for something - see
Player.ts's requestPauseBtn/requestResumeBtn. Pill buttons matching
the banner/notice's own dark-overlay look above, not the plain-text
.linklike used in the (opaque) top action bar - this sits directly on
top of the video image, same as the rest of .bottomOverlay. */
.requestToolbar {
display: flex;
gap: 8px;
}
.avatarClickable:hover {
opacity: 1;
transform: scale(1.08);
.requestBtn {
background: rgba(0, 0, 0, .6);
color: white;
padding: 8px 14px;
border-radius: 20px;
font-size: 12.5px;
}
.requestBtn:hover { background: rgba(0, 0, 0, .8); }
/* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their "pauseRequested" is active -
see onPauseRequested() in Player.ts. Orange border + a small pause-icon
badge, not just a generic pulse, so it reads unambiguously even to
someone glancing at the video rather than the banner text. */
.avatarPauseRequested {
on the matching person's circle while their pause/resume request is
active - see onPauseRequested()/onResumeRequested() in Player.ts.
Orange border + a small icon badge, not just a generic pulse, so it
reads unambiguously even to someone glancing at the video rather than
the banner text. Same treatment for either request - the banner text
is what says which one. */
.avatarActionRequested {
opacity: 1;
border-color: #ff9800;
}
.avatarPauseBadge {
.avatarRequestBadge {
position: absolute;
bottom: -4px;
right: -4px;
@ -176,20 +204,4 @@
justify-content: center;
}
.avatarPauseRequested .avatarPauseBadge { display: flex; }
.pauseRequestBanner {
/* Bottom-anchored, clear of the presence row now living up in
.topOverlay - stacked above .followerNotice (bottom: 24px) rather than
on top of it, since a non-host sees both at once. */
position: absolute;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
z-index: 6;
}
.avatarActionRequested .avatarRequestBadge { display: flex; }

View file

@ -7,8 +7,6 @@
<button id="playerErrorRetry" class="linklike">Réessayer</button>
</div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
@ -30,5 +28,18 @@
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
<!-- Stacked as one column (same reasoning as .topOverlay's own
actionBar+presenceOverlay grouping - see Player.css) so the
transient banner, the passive notice and the request toolbar
always sit in the right order relative to each other regardless
of which are visible at a given moment, instead of each guessing
a fixed pixel offset from the bottom edge. -->
<div class="bottomOverlay">
<div id="requestBanner" class="requestBanner hide"></div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
<div id="requestToolbar" class="requestToolbar hide">
<button id="requestPauseBtn" class="requestBtn">Demander une pause</button>
<button id="requestResumeBtn" class="requestBtn">Demander une reprise</button>
</div>
</div>
</div>

View file

@ -7,13 +7,13 @@ import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, SocketMember } from '../../types';
import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
const REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway - shared by both request types, each throttled independently
const REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
// hls.js's own defaults (10s manifest/level/frag load timeouts) assume a
// file server or a source already being direct-played/remuxed - too tight
@ -131,9 +131,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const itemTitleEl = root.querySelector('#itemTitle')!;
const membersEl = root.querySelector('#members')!;
const presenceOverlay = root.querySelector('#presenceOverlay')!;
const pauseBanner = root.querySelector('#pauseRequestBanner')!;
const requestBanner = root.querySelector('#requestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const followerNotice = root.querySelector('#followerNotice')!;
const requestToolbar = root.querySelector('#requestToolbar')!;
const requestPauseBtn = root.querySelector('#requestPauseBtn') as HTMLButtonElement;
const requestResumeBtn = root.querySelector('#requestResumeBtn') as HTMLButtonElement;
const settingsWrap = root.querySelector('#settingsWrap')!;
const settingsBtn = root.querySelector('#settingsBtn')!;
const settingsPanel = root.querySelector('#settingsPanel')!;
@ -168,7 +171,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat: number | null = null;
let pauseRequestCooldownUntil = 0;
let pauseBannerTimer: number | null = null;
let resumeRequestCooldownUntil = 0;
let requestBannerTimer: number | null = null;
function showError(message: string, detail?: unknown) {
loadingNotice.classList.add('hide');
@ -214,6 +218,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.controls = true;
settingsWrap.classList.remove('hide');
followerNotice.classList.add('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued);
video.addEventListener('play', sendLocalState);
@ -368,62 +373,78 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
loadVideo(msg.itemId, 0, true);
}
// Presence overlay: one semi-transparent circle per member, initials only
// (no photos - Emby accounts don't reliably have one). Your own circle
// doubles as the "request a pause" button when you're not the host - the
// host already has direct controls, so their own circle is just presence.
// The pause-badge <span> is always in the markup, just hidden by
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
// thing that ever shows it.
// Presence overlay: one semi-transparent circle per member, initials
// only (no photos - Emby accounts don't reliably have one). Purely
// presence now - asking the host for something is the requestToolbar's
// job (see below), not a click on your own circle any more (a small
// avatar was never a very discoverable button). The request-badge
// <span> is always in the markup, just hidden by .avatarActionRequested's
// CSS - see showRequestBanner(), the only thing that ever shows it.
function renderMembers(members: SocketMember[]) {
membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', ');
presenceOverlay.innerHTML = members.map((m) => {
const isMe = m.userName === auth.userName;
const clickable = isMe && !m.isHost;
return `
presenceOverlay.innerHTML = members.map((m) => `
<div
class="avatar${clickable ? ' avatarClickable' : ''}"
class="avatar"
style="background:${colorForName(m.userName)}"
data-user-id="${escapeHtml(m.userId)}"
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}"
>${escapeHtml(initialsFor(m.userName))}<span class="avatarPauseBadge">${PAUSE_ICON_SVG}</span></div>`;
}).join('');
if (isHost) return; // the host pauses directly, no need for the request affordance
const mine = presenceOverlay.querySelector('.avatarClickable');
if (mine) {
mine.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + PAUSE_REQUEST_COOLDOWN_MS;
conn.requestPause();
});
}
title="${escapeHtml(m.userName)}"
>${escapeHtml(initialsFor(m.userName))}<span class="avatarRequestBadge">${PAUSE_ICON_SVG}</span></div>`
).join('');
}
// Broadcast to everyone in the party, including the requester and the
// host (see SocketGateway) - so this always flags the right circle no
// matter whose screen it renders on.
function onPauseRequested(msg: PauseRequestedMsg) {
pauseBanner.textContent = `${msg.userName} demande une pause`;
pauseBanner.classList.remove('hide');
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
pauseBannerTimer = window.setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS);
// matter whose screen it renders on. Shared by onPauseRequested/
// onResumeRequested below - same visual treatment either way, the
// banner text is what actually says which one happened.
function showRequestBanner(userId: string, userName: string, actionLabel: string) {
requestBanner.textContent = `${userName} demande ${actionLabel}`;
requestBanner.classList.remove('hide');
if (requestBannerTimer) clearTimeout(requestBannerTimer);
requestBannerTimer = window.setTimeout(() => requestBanner.classList.add('hide'), REQUEST_HIGHLIGHT_MS);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(userId)}"]`);
if (avatar) {
avatar.classList.add('avatarPauseRequested');
setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS);
avatar.classList.add('avatarActionRequested');
setTimeout(() => avatar.classList.remove('avatarActionRequested'), REQUEST_HIGHLIGHT_MS);
}
}
function onPauseRequested(msg: PauseRequestedMsg) {
showRequestBanner(msg.userId, msg.userName, 'une pause');
}
function onResumeRequested(msg: ResumeRequestedMsg) {
showRequestBanner(msg.userId, msg.userName, 'une reprise');
}
// The toolbar itself - see .bottomOverlay/.requestToolbar in Player.css
// for why this replaced the old "click your own avatar" affordance.
// Each button has its own client-side cooldown (mirroring the server's
// own independent per-request throttle in SocketGateway) purely so a
// rapid double-click doesn't even bother emitting the second request.
requestPauseBtn.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause();
});
requestResumeBtn.addEventListener('click', () => {
const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume();
});
async function init() {
itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost);
requestToolbar.classList.toggle('hide', party.youAreHost);
await loadTrackOptions(party.itemId);
loadVideo(party.itemId, 0, false);
@ -434,6 +455,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
onItemChanged,
onHostChanged: () => becomeHost(true),
onPauseRequested,
onResumeRequested,
onError: (message) => alert(message),
onClose: () => {}
});
@ -452,7 +474,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() {
if (heartbeat) clearInterval(heartbeat);
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);
conn?.leave();

View file

@ -385,6 +385,30 @@ banner/`.followerNotice` gap measured 15px after the margin fix, versus 2px
before it. The host-only controls (`becomeHost`, settings menu,
next-episode button) were re-checked after this change and still work.
**Much later: a request toolbar replaced the avatar click, and grew a
second request.** Asked for directly: a visible toolbar for non-host
members instead of "click your own small circle" (never a very
discoverable affordance), and a second request alongside pause -
"please resume". `SocketGateway` gained `requestResume`/`resumeRequested`,
byte-for-byte the same shape as `requestPause`/`pauseRequested` and
independently throttled (its own `lastResumeRequestUtc`, not a shared
budget with pause - asking to pause right after asking to resume, or vice
versa, isn't the "immediate repeat" this throttle exists to stop). Kept
as a plain signal, deliberately, same as pause always was: broadcast to
everyone, no accept/deny state tracked anywhere - the host sees the same
banner/avatar highlight as everyone else and acts with their own existing
controls. `Player.ts`'s markup gained a `.bottomOverlay` wrapping the
banner, the follower notice, and the new toolbar as one flex column
(same "stack in normal flow, don't hand-pick a pixel offset per box"
fix `.topOverlay` already needed - see just above) - the two request
buttons, the banner, and the avatar highlight all share one
`showRequestBanner()` now, since pause and resume only ever differed by
which word appears in the banner text. Verified against the real bundle:
the toolbar shows for a follower and stays hidden for the host; each
button's own client-side cooldown collapses a rapid double-click to one
`requestResume` emission; a pushed `resumeRequested` shows the banner
with the right text and highlights the right avatar.
## Player track selection: forcing our pick onto Emby's own negotiation
First real-world bug report once someone actually watched something end to

View file

@ -38,11 +38,16 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
*
* -- both --
* "hostChanged" (server -> client, to the new host only, no payload)
* "requestPause" (throttled per member - see below;
* "requestPause"/"requestResume" (each throttled per member,
* independently - see below;
* video only - a music party has no
* host to ask, everyone already has
* direct pause/vote-skip access)
* "pauseRequested" (server -> client, everyone in the party)
* "pauseRequested"/"resumeRequested" (server -> client, everyone in the
* party) - a plain signal, same as each other: broadcast and done, no
* accept/deny state tracked server-side. The host sees the same
* banner/avatar highlight as everyone else and acts (or doesn't) with
* their own existing playstate controls - see docs/architecture.md.
* "leave"
* "errorMessage" (server -> client)
* ("error" is a reserved Socket.IO event name - using our own to avoid
@ -178,6 +183,20 @@ class SocketGatewayClass {
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
}));
// Same shape as "requestPause" above, independently throttled (its
// own lastResumeRequestUtc, not shared) - the mini "request..."
// toolbar in the player (views/player/Player.ts) offers both.
socket.on('requestResume', this.safe(() => {
if (!party || party.mediaType === 'music') return;
const member = party.members.get(userId);
if (!member) return;
const now = Date.now();
if (member.lastResumeRequestUtc && now - member.lastResumeRequestUtc < 5000) return;
member.lastResumeRequestUtc = now;
Logger.party(`resumeRequested partyId=${party.id} user=${Logger.who(userId, member.userName)}`);
io.to(party.id).emit('resumeRequested', { userId, userName: member.userName });
}));
socket.on('leave', this.safe(() => {
if (!party) return;
this.applyLeave(io, party, userId);

View file

@ -27,6 +27,7 @@ export interface ClientToServerEvents {
voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void;
requestPause: () => void;
requestResume: () => void;
leave: () => void;
}
@ -56,6 +57,7 @@ export interface ServerToClientEvents {
queueUpdated: (state: MusicPublicState) => void;
hostChanged: () => void;
pauseRequested: (msg: { userId: string; userName: string }) => void;
resumeRequested: (msg: { userId: string; userName: string }) => void;
errorMessage: (msg: { message: string }) => void;
}
@ -172,6 +174,7 @@ export interface PartyMember {
positionTicks: number;
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
}
/** The full internal party record - never sent to the client as-is, see

View file

@ -182,6 +182,25 @@ describe('SocketGateway', () => {
assert.equal(pauseRequestLines.length, 1, 'an immediate repeat must not produce a second line');
});
it('requestResume is throttled independently from requestPause (its own 5s window, own counter)', (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const bobSocket = fakeSocket('u-bob');
io.trigger('connection', bobSocket as unknown as AppSocket);
bobSocket.trigger('join', { partyId: party.id });
bobSocket.trigger('requestPause');
bobSocket.trigger('requestResume');
bobSocket.trigger('requestResume');
assert.equal(logged.filter((l) => l.includes(' pauseRequested ')).length, 1);
assert.equal(logged.filter((l) => l.includes(' resumeRequested ')).length, 1, 'an immediate repeat must not produce a second line, and requestPause just above must not have consumed this budget');
});
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);