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 (their player unlocks live, no reload needed) — only closing once
everyone's gone. everyone's gone.
5. Everyone present shows up as a small avatar overlay on the video (their 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 initials, on the player itself). If you're not the host, a small
own avatar asks the host to pause — everyone sees the request, including toolbar lets you ask the host to pause or resume — everyone sees the
the host, without needing chat. request, including the host, without needing chat.
6. Music works differently, on purpose: pick a track (browsing Artist → 6. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for 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, 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 // No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md. // (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> { async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
@ -76,6 +76,7 @@ export interface PartyConnectionCallbacks {
onItemChanged?: (msg: ItemChangedMsg) => void; onItemChanged?: (msg: ItemChangedMsg) => void;
onHostChanged?: () => void; onHostChanged?: () => void;
onPauseRequested?: (msg: PauseRequestedMsg) => void; onPauseRequested?: (msg: PauseRequestedMsg) => void;
onResumeRequested?: (msg: ResumeRequestedMsg) => void;
onQueueUpdated?: (state: MusicPublicState) => void; onQueueUpdated?: (state: MusicPublicState) => void;
onError?: (message: string) => void; onError?: (message: string) => void;
onClose?: () => void; onClose?: () => void;
@ -88,6 +89,9 @@ export interface PartyConnection {
/** Asks the host (and tells everyone else) "please pause". Video only - /** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */ * a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void; 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 /** Music only: vote to skip the current track - see MusicQueueService
* on the server for the majority threshold. */ * on the server for the majority threshold. */
voteSkip(): void; voteSkip(): void;
@ -103,7 +107,7 @@ export interface PartyConnection {
* alive via closures, never gets released. * alive via closures, never gets released.
*/ */
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection { 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(); const socket = io();
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this // 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 (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
if (onHostChanged) socket.on('hostChanged', () => onHostChanged()); if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg)); 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 (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 (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose()); if (onClose) socket.on('disconnect', () => onClose());
@ -132,6 +137,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
requestPause() { requestPause() {
socket.emit('requestPause'); socket.emit('requestPause');
}, },
requestResume() {
socket.emit('requestResume');
},
voteSkip() { voteSkip() {
socket.emit('voteSkip'); socket.emit('voteSkip');
}, },

View file

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

View file

@ -7,8 +7,6 @@
<button id="playerErrorRetry" class="linklike">Réessayer</button> <button id="playerErrorRetry" class="linklike">Réessayer</button>
</div> </div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay"> <div class="topOverlay">
<div class="actionBar"> <div class="actionBar">
<span id="itemTitle" class="itemTitle"></span> <span id="itemTitle" class="itemTitle"></span>
@ -30,5 +28,18 @@
<div id="presenceOverlay" class="presenceOverlay"></div> <div id="presenceOverlay" class="presenceOverlay"></div>
</div> </div>
<!-- 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> <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> </div>

View file

@ -7,13 +7,13 @@ import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles'; 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'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway const REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway - shared by both request types, each throttled independently
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up 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 // 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 // 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 itemTitleEl = root.querySelector('#itemTitle')!;
const membersEl = root.querySelector('#members')!; const membersEl = root.querySelector('#members')!;
const presenceOverlay = root.querySelector('#presenceOverlay')!; const presenceOverlay = root.querySelector('#presenceOverlay')!;
const pauseBanner = root.querySelector('#pauseRequestBanner')!; const requestBanner = root.querySelector('#requestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement; const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const followerNotice = root.querySelector('#followerNotice')!; 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 settingsWrap = root.querySelector('#settingsWrap')!;
const settingsBtn = root.querySelector('#settingsBtn')!; const settingsBtn = root.querySelector('#settingsBtn')!;
const settingsPanel = root.querySelector('#settingsPanel')!; 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 suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat: number | null = null; let heartbeat: number | null = null;
let pauseRequestCooldownUntil = 0; let pauseRequestCooldownUntil = 0;
let pauseBannerTimer: number | null = null; let resumeRequestCooldownUntil = 0;
let requestBannerTimer: number | null = null;
function showError(message: string, detail?: unknown) { function showError(message: string, detail?: unknown) {
loadingNotice.classList.add('hide'); loadingNotice.classList.add('hide');
@ -214,6 +218,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.controls = true; video.controls = true;
settingsWrap.classList.remove('hide'); settingsWrap.classList.remove('hide');
followerNotice.classList.add('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); nextBtn.classList.toggle('hide', !hasNextQueued);
video.addEventListener('play', sendLocalState); video.addEventListener('play', sendLocalState);
@ -368,62 +373,78 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
loadVideo(msg.itemId, 0, true); loadVideo(msg.itemId, 0, true);
} }
// Presence overlay: one semi-transparent circle per member, initials only // Presence overlay: one semi-transparent circle per member, initials
// (no photos - Emby accounts don't reliably have one). Your own circle // only (no photos - Emby accounts don't reliably have one). Purely
// doubles as the "request a pause" button when you're not the host - the // presence now - asking the host for something is the requestToolbar's
// host already has direct controls, so their own circle is just presence. // job (see below), not a click on your own circle any more (a small
// The pause-badge <span> is always in the markup, just hidden by // avatar was never a very discoverable button). The request-badge
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only // <span> is always in the markup, just hidden by .avatarActionRequested's
// thing that ever shows it. // CSS - see showRequestBanner(), the only thing that ever shows it.
function renderMembers(members: SocketMember[]) { function renderMembers(members: SocketMember[]) {
membersEl.textContent = members membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : '')) .map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', '); .join(', ');
presenceOverlay.innerHTML = members.map((m) => { presenceOverlay.innerHTML = members.map((m) => `
const isMe = m.userName === auth.userName;
const clickable = isMe && !m.isHost;
return `
<div <div
class="avatar${clickable ? ' avatarClickable' : ''}" class="avatar"
style="background:${colorForName(m.userName)}" style="background:${colorForName(m.userName)}"
data-user-id="${escapeHtml(m.userId)}" data-user-id="${escapeHtml(m.userId)}"
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}" title="${escapeHtml(m.userName)}"
>${escapeHtml(initialsFor(m.userName))}<span class="avatarPauseBadge">${PAUSE_ICON_SVG}</span></div>`; >${escapeHtml(initialsFor(m.userName))}<span class="avatarRequestBadge">${PAUSE_ICON_SVG}</span></div>`
}).join(''); ).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();
});
}
} }
// Broadcast to everyone in the party, including the requester and the // Broadcast to everyone in the party, including the requester and the
// host (see SocketGateway) - so this always flags the right circle no // host (see SocketGateway) - so this always flags the right circle no
// matter whose screen it renders on. // matter whose screen it renders on. Shared by onPauseRequested/
function onPauseRequested(msg: PauseRequestedMsg) { // onResumeRequested below - same visual treatment either way, the
pauseBanner.textContent = `${msg.userName} demande une pause`; // banner text is what actually says which one happened.
pauseBanner.classList.remove('hide'); function showRequestBanner(userId: string, userName: string, actionLabel: string) {
if (pauseBannerTimer) clearTimeout(pauseBannerTimer); requestBanner.textContent = `${userName} demande ${actionLabel}`;
pauseBannerTimer = window.setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS); 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) { if (avatar) {
avatar.classList.add('avatarPauseRequested'); avatar.classList.add('avatarActionRequested');
setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS); 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() { async function init() {
itemTitleEl.textContent = party.itemName || ''; itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext; hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost); followerNotice.classList.toggle('hide', party.youAreHost);
requestToolbar.classList.toggle('hide', party.youAreHost);
await loadTrackOptions(party.itemId); await loadTrackOptions(party.itemId);
loadVideo(party.itemId, 0, false); loadVideo(party.itemId, 0, false);
@ -434,6 +455,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
onItemChanged, onItemChanged,
onHostChanged: () => becomeHost(true), onHostChanged: () => becomeHost(true),
onPauseRequested, onPauseRequested,
onResumeRequested,
onError: (message) => alert(message), onError: (message) => alert(message),
onClose: () => {} onClose: () => {}
}); });
@ -452,7 +474,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// actual leak: previously, only the "Quitter" button ran any of this. // actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() { return function cleanup() {
if (heartbeat) clearInterval(heartbeat); if (heartbeat) clearInterval(heartbeat);
if (pauseBannerTimer) clearTimeout(pauseBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy(); if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
conn?.leave(); 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, before it. The host-only controls (`becomeHost`, settings menu,
next-episode button) were re-checked after this change and still work. 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 ## Player track selection: forcing our pick onto Emby's own negotiation
First real-world bug report once someone actually watched something end to 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 -- * -- both --
* "hostChanged" (server -> client, to the new host only, no payload) * "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 * video only - a music party has no
* host to ask, everyone already has * host to ask, everyone already has
* direct pause/vote-skip access) * 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" * "leave"
* "errorMessage" (server -> client) * "errorMessage" (server -> client)
* ("error" is a reserved Socket.IO event name - using our own to avoid * ("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 }); 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(() => { socket.on('leave', this.safe(() => {
if (!party) return; if (!party) return;
this.applyLeave(io, party, userId); this.applyLeave(io, party, userId);

View file

@ -27,6 +27,7 @@ export interface ClientToServerEvents {
voteSkip: () => void; voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void; musicPlaystate: (msg: { isPaused: boolean }) => void;
requestPause: () => void; requestPause: () => void;
requestResume: () => void;
leave: () => void; leave: () => void;
} }
@ -56,6 +57,7 @@ export interface ServerToClientEvents {
queueUpdated: (state: MusicPublicState) => void; queueUpdated: (state: MusicPublicState) => void;
hostChanged: () => void; hostChanged: () => void;
pauseRequested: (msg: { userId: string; userName: string }) => void; pauseRequested: (msg: { userId: string; userName: string }) => void;
resumeRequested: (msg: { userId: string; userName: string }) => void;
errorMessage: (msg: { message: string }) => void; errorMessage: (msg: { message: string }) => void;
} }
@ -172,6 +174,7 @@ export interface PartyMember {
positionTicks: number; positionTicks: number;
lastUpdatedUtc: number; lastUpdatedUtc: number;
lastPauseRequestUtc?: number; lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
} }
/** The full internal party record - never sent to the client as-is, see /** 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'); 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', () => { it('the lobby snapshot exposes the host\'s live position for a video party, and null for a music party', () => {
const io = fakeIo(); const io = fakeIo();
attach(io); attach(io);