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);