diff --git a/README.md b/README.md index 4580f9a..a049e06 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 27057e1..b35e4ab 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -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(path: string, options: RequestInit = {}): Promise { 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'); }, diff --git a/client/src/types.ts b/client/src/types.ts index 65903b1..c1146e8 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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; +} diff --git a/client/src/views/player/Player.css b/client/src/views/player/Player.css index cad6f09..54c1482 100644 --- a/client/src/views/player/Player.css +++ b/client/src/views/player/Player.css @@ -120,17 +120,36 @@ .playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; } -.followerNotice { - /* margin:0 overrides the

'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 +

'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; } diff --git a/client/src/views/player/Player.html b/client/src/views/player/Player.html index 89a3a51..315c361 100644 --- a/client/src/views/player/Player.html +++ b/client/src/views/player/Player.html @@ -7,8 +7,6 @@ -

-
@@ -30,5 +28,18 @@
-

L'hôte contrôle la lecture.

+ +
+
+

L'hôte contrôle la lecture.

+
+ + +
+
diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index 7963c4c..71da66d 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -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 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 + // 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) => `
${escapeHtml(initialsFor(m.userName))}${PAUSE_ICON_SVG}
`; - }).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))}${PAUSE_ICON_SVG}` + ).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(); diff --git a/docs/architecture.md b/docs/architecture.md index a3051cb..2116ab9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/server/src/realtime/SocketGateway.ts b/server/src/realtime/SocketGateway.ts index 07507a3..dca1cdd 100644 --- a/server/src/realtime/SocketGateway.ts +++ b/server/src/realtime/SocketGateway.ts @@ -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); diff --git a/server/src/types.ts b/server/src/types.ts index 500ba2f..815f715 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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 diff --git a/server/test/socketGateway.test.ts b/server/test/socketGateway.test.ts index 7d1ddcf..8f78222 100644 --- a/server/test/socketGateway.test.ts +++ b/server/test/socketGateway.test.ts @@ -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);