Presence overlay (avatars) + "request pause" in the player
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Two features requested together: a semi-transparent overlay showing who's
in the party, and a way for a non-host to ask the host to pause.
- server/src/socketHandler.js: new "requestPause" event (anyone, throttled
5s per member server-side via member.lastPauseRequestUtc - the real
guard, independent of whatever the client does) broadcasts
"pauseRequested" {userId, userName} to the whole party room, not just the
host, so everyone sees why playback is about to pause.
- client/js/party.js: connectParty() grows an onPauseRequested callback and
the returned connection a requestPause() method, same shape as the
existing next()/sendPlaystate().
- client/js/views/player.js: renderMembers() now also rebuilds a
#presenceOverlay of one circle per member (initials from userName,
colorForName() picks a deterministic background from a small palette by
hashing the name - same person, same color, no server-side assignment
needed). "Which circle is mine" compares userName against the auth
singleton embyApi.js already populates, matching how the rest of the
client avoids keying UI logic on userId directly. Your own circle, only
when you're not host, is clickable and calls requestPause() (client-side
cooldown too, purely to avoid a click that would visibly do nothing - the
server-side one is what actually matters). Receiving pauseRequested shows
a short auto-clearing banner and pulses the matching circle for everyone,
including the requester and the host.
- client/css/app.css: .presenceOverlay/.avatar (bottom-left, opacity .75 is
the "semi transparence" itself) and the clickable/sent/pulse states,
.pauseRequestBanner.
Verified with mocked members/pauseRequested Socket.IO events against the
real running local server (see session history): initials/colors render
correctly, the host's own circle never gets the clickable affordance,
rapid repeat clicks collapse to exactly one requestPause emission (three
synchronous clicks -> one new emitted event), a pauseRequested event
banners and pulses the right circle. Re-checked the pre-existing host path
(becomeHost, settings menu, next-episode button) still works unchanged -
this pass only touched renderMembers() and init()'s connectParty() call.
docs/architecture.md has the full writeup. Also fixed two roadmap
checkboxes in README.md that should have been checked off by the previous
commit (live lobby/host handoff/no join codes) but were missed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b6019bd211
commit
669873e41c
6 changed files with 248 additions and 8 deletions
|
|
@ -25,6 +25,10 @@ including why this replaced an earlier Emby-plugin attempt (still in
|
|||
the host leaves, the party hands off to another member automatically
|
||||
(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.
|
||||
|
||||
Everything — catalog, images, video, sync — flows through this app's own
|
||||
backend, which proxies whatever it needs from Emby server-side. The browser
|
||||
|
|
@ -108,6 +112,9 @@ Then put it behind your existing reverse proxy like everything else
|
|||
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
|
||||
(dead-connection cleanup server-side, view teardown on navigation
|
||||
client-side) — see docs/architecture.md
|
||||
- [x] Live "watch parties en cours" list (host, elapsed time, member count),
|
||||
live host handoff, no join codes
|
||||
- [x] Presence overlay (avatars) + "request pause" in the player
|
||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||
- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
|
||||
- [ ] Tune transcoding target / support direct play when possible
|
||||
|
|
|
|||
|
|
@ -531,4 +531,78 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Presence overlay: one circle per party member, bottom-left so it never
|
||||
fights the top action bar (title/next/settings/leave, which already wraps
|
||||
on narrow screens) or the centered .followerNotice pill. See
|
||||
renderMembers() in player.js. */
|
||||
.presenceOverlay {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
opacity: .75; /* "overlay en semi transparence" - background color comes from inline style, see colorForName() in player.js */
|
||||
border: 2px solid rgba(255, 255, 255, .3);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Only the viewer's own avatar gets this, and only while not host - see
|
||||
renderMembers(). The host has direct controls, so their own circle is
|
||||
presence-only. */
|
||||
.avatarClickable {
|
||||
cursor: pointer;
|
||||
transition: opacity .15s, transform .15s;
|
||||
}
|
||||
|
||||
.avatarClickable:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Feedback right after clicking your own avatar - stays up as long as the
|
||||
server's own per-member throttle blocks a repeat request anyway (see
|
||||
PAUSE_REQUEST_COOLDOWN_MS in player.js / socketHandler.js). */
|
||||
.avatarSent {
|
||||
opacity: 1;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Shown to EVERY member (including the host) on the matching person's
|
||||
circle when their "pauseRequested" comes in - see onPauseRequested(). */
|
||||
.avatarPulse {
|
||||
animation: avatarPulse 1s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes avatarPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .6); opacity: .75; }
|
||||
50% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); opacity: 1; }
|
||||
}
|
||||
|
||||
.pauseRequestBanner {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
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;
|
||||
}
|
||||
|
||||
.hide { display: none; }
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ export function connectLobby(onParties) {
|
|||
* see router.js/player.js) - otherwise the socket, and whatever it's
|
||||
* keeping alive via closures, never gets released.
|
||||
*/
|
||||
export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onHostChanged, onError, onClose } = {}) {
|
||||
export function connectParty(partyId, {
|
||||
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onError, onClose
|
||||
} = {}) {
|
||||
const socket = window.io();
|
||||
|
||||
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
||||
|
|
@ -74,6 +76,7 @@ export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, o
|
|||
if (onPlaystate) socket.on('playstate', onPlaystate);
|
||||
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
||||
if (onHostChanged) socket.on('hostChanged', onHostChanged);
|
||||
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
|
||||
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
|
||||
if (onClose) socket.on('disconnect', onClose);
|
||||
|
||||
|
|
@ -85,6 +88,10 @@ export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, o
|
|||
next() {
|
||||
socket.emit('next');
|
||||
},
|
||||
/** Asks the host (and tells everyone else) "please pause". */
|
||||
requestPause() {
|
||||
socket.emit('requestPause');
|
||||
},
|
||||
leave() {
|
||||
socket.emit('leave');
|
||||
socket.disconnect();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { streamUrl, getPlaybackOptions } from '../embyApi.js';
|
||||
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
|
||||
import { getParty, leaveParty, connectParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { escapeHtml } from '../tiles.js';
|
||||
|
||||
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 socketHandler.js
|
||||
|
||||
// "Quality" is really "max transcoding bitrate", the same simplification
|
||||
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate
|
||||
|
|
@ -16,6 +17,20 @@ const QUALITY_PRESETS = [
|
|||
{ label: '480p (~1.5 Mbps)', value: '1500000' }
|
||||
];
|
||||
|
||||
// Small deterministic palette so the same person always gets the same
|
||||
// avatar color across a session, without needing the server to assign one.
|
||||
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
|
||||
|
||||
function colorForName(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function initialsFor(name) {
|
||||
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
export function renderPartyPlayer(root, params) {
|
||||
const partyId = params.id;
|
||||
|
||||
|
|
@ -29,6 +44,9 @@ export function renderPartyPlayer(root, params) {
|
|||
<button id="playerErrorRetry" class="linklike">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<div id="presenceOverlay" class="presenceOverlay"></div>
|
||||
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
|
||||
|
||||
<div class="overlay">
|
||||
<span id="itemTitle" class="itemTitle"></span>
|
||||
<span id="members" class="members"></span>
|
||||
|
|
@ -55,6 +73,8 @@ export function renderPartyPlayer(root, params) {
|
|||
const errorText = root.querySelector('#playerErrorText');
|
||||
const itemTitleEl = root.querySelector('#itemTitle');
|
||||
const membersEl = root.querySelector('#members');
|
||||
const presenceOverlay = root.querySelector('#presenceOverlay');
|
||||
const pauseBanner = root.querySelector('#pauseRequestBanner');
|
||||
const nextBtn = root.querySelector('#nextBtn');
|
||||
const followerNotice = root.querySelector('#followerNotice');
|
||||
const settingsWrap = root.querySelector('#settingsWrap');
|
||||
|
|
@ -90,6 +110,8 @@ export function renderPartyPlayer(root, params) {
|
|||
let currentItemId = null;
|
||||
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
|
||||
let heartbeat = null;
|
||||
let pauseRequestCooldownUntil = 0;
|
||||
let pauseBannerTimer = null;
|
||||
|
||||
function showError(message, detail) {
|
||||
loadingNotice.classList.add('hide');
|
||||
|
|
@ -233,6 +255,54 @@ export function renderPartyPlayer(root, params) {
|
|||
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.
|
||||
function renderMembers(members) {
|
||||
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 `
|
||||
<div
|
||||
class="avatar${clickable ? ' avatarClickable' : ''}"
|
||||
style="background:${colorForName(m.userName)}"
|
||||
data-user-id="${escapeHtml(m.userId)}"
|
||||
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}"
|
||||
>${escapeHtml(initialsFor(m.userName))}</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();
|
||||
mine.classList.add('avatarSent');
|
||||
setTimeout(() => mine.classList.remove('avatarSent'), PAUSE_REQUEST_COOLDOWN_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPauseRequested(msg) {
|
||||
pauseBanner.textContent = `${msg.userName} demande une pause`;
|
||||
pauseBanner.classList.remove('hide');
|
||||
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
||||
pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), 4000);
|
||||
|
||||
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
|
||||
if (avatar) {
|
||||
avatar.classList.add('avatarPulse');
|
||||
setTimeout(() => avatar.classList.remove('avatarPulse'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let party;
|
||||
try {
|
||||
|
|
@ -255,6 +325,7 @@ export function renderPartyPlayer(root, params) {
|
|||
onPlaystate: applyRemoteState,
|
||||
onItemChanged,
|
||||
onHostChanged: () => becomeHost(true),
|
||||
onPauseRequested,
|
||||
onError: (message) => alert(message),
|
||||
onClose: () => {}
|
||||
});
|
||||
|
|
@ -262,12 +333,6 @@ export function renderPartyPlayer(root, params) {
|
|||
if (party.youAreHost) becomeHost(false);
|
||||
}
|
||||
|
||||
function renderMembers(members) {
|
||||
membersEl.textContent = members
|
||||
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
|
||||
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
||||
|
||||
|
|
@ -279,6 +344,7 @@ export function renderPartyPlayer(root, params) {
|
|||
// actual leak: previously, only the "Quitter" button ran any of this.
|
||||
return function cleanup() {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
||||
if (hls) hls.destroy();
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
if (conn) conn.leave();
|
||||
|
|
|
|||
|
|
@ -254,6 +254,74 @@ regardless of any ancestor's padding, and (like most players' theater mode)
|
|||
covers the sidebar while watching - confirmed via computed style
|
||||
(`position: fixed`, all four insets `0px`) rather than just asserted.
|
||||
|
||||
## Presence overlay and "request pause"
|
||||
|
||||
Requested as a pair: a semi-transparent overlay showing who's in the party,
|
||||
and a way for a non-host to ask the host to pause without a chat feature
|
||||
(deliberately out of scope — see Non-goals).
|
||||
|
||||
- **Presence**: `client/js/views/player.js`'s `renderMembers()` — already
|
||||
responsible for the text member list in the top bar — now also rebuilds
|
||||
`#presenceOverlay`, one circle per member, positioned bottom-left so it
|
||||
never fights the top action bar (which already wraps on narrow screens) or
|
||||
the centered `.followerNotice` pill. Each circle shows the first two
|
||||
characters of `userName`, uppercased (`initialsFor`), on a background color
|
||||
picked deterministically from a small fixed palette by hashing the name
|
||||
(`colorForName`) — same person, same color, every render, with no need for
|
||||
the server to assign or track one. `opacity: .75` on `.avatar` is the
|
||||
"semi transparence" itself, applied to the whole circle (initials included)
|
||||
rather than just its background, since a chat-bubble-style translucent chip
|
||||
reads clearly enough at that opacity and avoids converting the hash color
|
||||
to an rgba string just to keep text fully opaque.
|
||||
- **Which circle is "yours"** is decided client-side by comparing
|
||||
`member.userName === auth.userName` (the same `auth` singleton
|
||||
`embyApi.js` already populates at login/session-restore) rather than
|
||||
by `userId` — consistent with the rest of the client already treating
|
||||
`userId` as a server/protocol-internal detail (see `hostChanged`'s empty
|
||||
payload) and not something to key UI logic on directly.
|
||||
- **Request pause**: your own circle — only when you're *not* the host, the
|
||||
host already has direct controls — gets `.avatarClickable` and a click
|
||||
handler that calls the party connection's `requestPause()`
|
||||
(`client/js/party.js` → `"requestPause"` socket event). The server
|
||||
(`server/src/socketHandler.js`) broadcasts `"pauseRequested"
|
||||
{userId, userName}` to **everyone** in the party, not just the host, so
|
||||
the whole group sees why playback is about to pause, and throttles it
|
||||
server-side per member (5s, `member.lastPauseRequestUtc`) independently of
|
||||
the client's own identical cooldown (`PAUSE_REQUEST_COOLDOWN_MS` in
|
||||
`player.js`) — the server-side one is the real guard (anyone poking the
|
||||
socket directly bypasses a client-only check); the client one just avoids
|
||||
a pointless click doing nothing.
|
||||
- Receiving `"pauseRequested"` shows a short banner
|
||||
(`#pauseRequestBanner`, "`<name>` demande une pause", auto-clearing after
|
||||
4s) and pulses the matching person's circle (`.avatarPulse`, a 1s
|
||||
box-shadow keyframe run twice, matched by the event's `userId` against
|
||||
each circle's `data-user-id`) for every member, including the requester
|
||||
and the host — deliberately not host-only, so a request landing while the
|
||||
host isn't looking at the tab is still visible to everyone else, and the
|
||||
requester gets confirmation their click registered beyond the local
|
||||
`.avatarSent` feedback ring.
|
||||
- Nothing about this feature actually pauses anything automatically — "request"
|
||||
is literal, the host still has to act on it. Adding an actual forced-pause
|
||||
would be a real design decision (can any member override the host's
|
||||
playback state? that cuts against "host drives, everyone follows") and
|
||||
wasn't asked for.
|
||||
|
||||
Verified end to end with mocked `members`/`pauseRequested` Socket.IO events
|
||||
against the real running local server (see session history): initials/colors
|
||||
render correctly for a two-member party, the host's circle never gets
|
||||
`.avatarClickable` (neither when viewed by themselves nor by others), rapid
|
||||
repeat clicks on your own circle within the cooldown collapse to exactly one
|
||||
`requestPause` emission (confirmed by issuing three synchronous clicks in one
|
||||
script and checking the emitted-event count increased by exactly one — an
|
||||
earlier two-separate-clicks check had looked like a cooldown failure at
|
||||
first, but that was real wall-clock time between two separate tool calls
|
||||
exceeding the 5s window, not a code bug), and a `pauseRequested` event
|
||||
correctly banners and pulses the matching circle. The host-only controls
|
||||
(`becomeHost`, settings menu, next-episode button) were re-checked after this
|
||||
change and still work — this pass only added to `renderMembers()` and the
|
||||
`connectParty(...)` callback list in `init()`, it didn't touch either of
|
||||
those paths.
|
||||
|
||||
## Assumptions to verify
|
||||
|
||||
**Confirmed against the real server** (not a guess — actually exercised,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const { PartyManager } = require('./partyManager');
|
|||
* "next" (host only - advance the queue)
|
||||
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||
* "hostChanged" (server -> client, to the new host only, no payload)
|
||||
* "requestPause" (throttled per member - see below)
|
||||
* "pauseRequested" (server -> client, everyone in the party) {userId, userName}
|
||||
* "leave"
|
||||
* "errorMessage" (server -> client) {message}
|
||||
* ("error" is a reserved Socket.IO event name - using our own to avoid
|
||||
|
|
@ -86,6 +88,22 @@ function attachSocketServer(io, { parseCookies, getSession }) {
|
|||
}
|
||||
});
|
||||
|
||||
// Anyone (not just followers - harmless either way) can ask the host to
|
||||
// pause; the presence overlay (client/js/views/player.js) is what
|
||||
// triggers this. Broadcast to the whole party, not just the host, so
|
||||
// everyone sees why playback is about to pause. Throttled per member
|
||||
// server-side (not just the client's own cooldown, which a stray extra
|
||||
// click - or anyone poking the socket directly - would bypass).
|
||||
socket.on('requestPause', () => {
|
||||
if (!party) return;
|
||||
const member = party.members.get(userId);
|
||||
if (!member) return;
|
||||
const now = Date.now();
|
||||
if (member.lastPauseRequestUtc && now - member.lastPauseRequestUtc < 5000) return;
|
||||
member.lastPauseRequestUtc = now;
|
||||
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
|
||||
});
|
||||
|
||||
socket.on('leave', () => {
|
||||
if (!party) return;
|
||||
applyLeave(io, party, userId);
|
||||
|
|
|
|||
Loading…
Reference in a new issue