Replace avatar-click with a request toolbar; add "request resume"
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:
parent
838244ec41
commit
c14d0bc08b
10 changed files with 217 additions and 91 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue