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
|
(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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue