diff --git a/README.md b/README.md
index 1a15b0e..1f2de89 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/client/css/app.css b/client/css/app.css
index a0cca9c..020d2d5 100644
--- a/client/css/app.css
+++ b/client/css/app.css
@@ -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; }
diff --git a/client/js/party.js b/client/js/party.js
index 5b36904..4207328 100644
--- a/client/js/party.js
+++ b/client/js/party.js
@@ -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();
diff --git a/client/js/views/player.js b/client/js/views/player.js
index 3a4500f..73321e7 100644
--- a/client/js/views/player.js
+++ b/client/js/views/player.js
@@ -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) {
+
+
+
@@ -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 `
+
${escapeHtml(initialsFor(m.userName))}
`;
+ }).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();
diff --git a/docs/architecture.md b/docs/architecture.md
index 9430aab..8a86391 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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`, "`` 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,
diff --git a/server/src/socketHandler.js b/server/src/socketHandler.js
index be79f88..7ceda21 100644
--- a/server/src/socketHandler.js
+++ b/server/src/socketHandler.js
@@ -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);