From f6a801064727bfb1a4f07bc369076956ed75ac59 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 00:22:07 +0200 Subject: [PATCH] Move presence overlay to the top, orange badge for pause requests, series detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rounds of feedback on the previous commit, plus a related feature request: 1. Presence overlay moved from bottom-left to the top of the player. client/js/views/player.js: the top gradient bar is now .topOverlay, a column of two stacked rows - .actionBar (what used to be the whole bar: title/members-text/next/settings/leave) and #presenceOverlay directly below it. Stacking them in normal flow means the avatar row always lands right after the action bar regardless of how tall that bar renders (it wraps on narrow screens), instead of a second absolutely-positioned block at a guessed pixel offset. 2. "Someone requested a pause" is now a specific orange border (.avatarPauseRequested, #ff9800) plus a small pause-icon badge in the circle's corner (.avatarPauseBadge - a plain inline two-bar SVG, not a Unicode glyph, since pause/play characters default to emoji presentation on several platforms and look muddy at 10px) - replacing the previous generic box-shadow pulse. This also absorbed the separate "click feedback" ring that used to live only on the requester's own circle (.avatarSent): since the server already broadcasts "pauseRequested" back to the requester too, one state driven purely by that broadcast is simpler than two overlapping ones. Found while repositioning the banner above .followerNotice: the notice is a

, and for an absolutely positioned element "bottom" places the MARGIN edge, so its default ~1em user-agent margin was quietly rendering it 13px higher than its declared bottom:24px ever suggested. Gave it margin:0. 3. Series detail page (client/js/views/series.js): the page now opens with an actual detail header - poster, title + year, overview, "Voir sur Emby" - above the season tabs/episode list, instead of a bare

. The season/episode browsing and per-episode "Lancer une watch party" already worked end to end (library grid -> Series tile -> this route); this pass only made the page look like a detail page. Reused toItemDto's existing fields (GET /api/catalog/item/:id), no backend changes needed. Widened .linklike's CSS selector from `button.linklike` to `.linklike` so the new "Voir sur Emby" picks it up too - app.css has no global `a` reset, so an unclassed anchor would otherwise render with the browser's default blue/underline. Verified with mocked Socket.IO events / API responses against the real running local server (see session history): presence row renders directly under the action bar; a pauseRequested event gives the right circle rgb(255, 152, 0) as its computed border color and display:flex on its badge while leaving every other circle untouched (checked via computed styles, not just class names); rapid repeat clicks on your own circle still collapse to exactly one requestPause emission; banner/followerNotice gap measured 15px after the margin fix, versus 2px before it; the series page's header renders correctly, season-tab switching swaps the episode list, a tvshows library's Series tiles link to /series/:id via both the poster and "Voir les épisodes", and starting an episode's party correctly creates it and navigates to the player. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 --- client/css/app.css | 132 +++++++++++++++++++++++++++++--------- client/js/views/player.js | 54 ++++++++++------ client/js/views/series.js | 9 ++- docs/architecture.md | 105 ++++++++++++++++++++---------- 4 files changed, 218 insertions(+), 82 deletions(-) diff --git a/client/css/app.css b/client/css/app.css index 020d2d5..19fb7c0 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -87,7 +87,13 @@ button { button:hover { background: var(--accent-bright); } button[disabled] { opacity: .5; cursor: default; } -button.linklike { +/* Class-only (not tag-qualified) so an - e.g. the + "Voir sur Emby" link on the series detail page - gets the same treatment + as the -
-
- - - +
+
+ + + -
- -
- - - +
+ +
+ + + +
+ +
- +

L'hôte contrôle la lecture.

@@ -259,6 +273,9 @@ export function renderPartyPlayer(root, params) { // (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. function renderMembers(members) { membersEl.textContent = members .map((m) => m.userName + (m.isHost ? ' (hôte)' : '')) @@ -273,7 +290,7 @@ export function renderPartyPlayer(root, params) { style="background:${colorForName(m.userName)}" data-user-id="${escapeHtml(m.userId)}" title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}" - >${escapeHtml(initialsFor(m.userName))}
`; + >${escapeHtml(initialsFor(m.userName))}${PAUSE_ICON_SVG}
`; }).join(''); if (isHost) return; // the host pauses directly, no need for the request affordance @@ -284,22 +301,23 @@ export function renderPartyPlayer(root, params) { 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); }); } } + // Broadcast to everyone in the party, including the requester and the + // host (see socketHandler.js) - so this always flags the right circle no + // matter whose screen it renders on. 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); + pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS); const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`); if (avatar) { - avatar.classList.add('avatarPulse'); - setTimeout(() => avatar.classList.remove('avatarPulse'), 2000); + avatar.classList.add('avatarPauseRequested'); + setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS); } } diff --git a/client/js/views/series.js b/client/js/views/series.js index 89e6ad3..ae9c2df 100644 --- a/client/js/views/series.js +++ b/client/js/views/series.js @@ -18,7 +18,14 @@ export async function renderSeries(root, params) { root.innerHTML = ` - +

Chargement...

`; diff --git a/docs/architecture.md b/docs/architecture.md index 8a86391..80df52d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -258,13 +258,24 @@ covers the sidebar while watching - confirmed via computed style 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). +(deliberately out of scope — see Non-goals). Revised once after the first +pass, based on concrete feedback: the overlay moved from bottom-left to the +top of the player, and the "someone requested a pause" indicator became a +specific orange border + pause-icon badge rather than a generic pulse +animation. +- **Layout**: the player's top gradient bar is now `.topOverlay`, a column + of two stacked rows — `.actionBar` (title/members-text/next/settings/leave, + what used to be the whole bar) and, directly below it, `#presenceOverlay`. + Stacking them in normal flow inside one column means the avatar row always + lands right after the action bar regardless of how tall that bar renders + (it wraps to two lines on narrow screens) — deliberately not a second + absolutely-positioned block at a guessed pixel offset, which would either + overlap the action bar when it wraps or leave an inconsistent gap when it + doesn't. - **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 + responsible for the text member list — now also rebuilds + `#presenceOverlay`, one circle per member. 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 @@ -291,15 +302,29 @@ and a way for a non-host to ask the host to pause without a chat feature `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. +- Receiving `"pauseRequested"` shows a short banner (`#pauseRequestBanner`, + "`` demande une pause") and flags the matching person's circle + (`.avatarPauseRequested`: an orange `#ff9800` border plus a small pause-icon + badge in its corner — `.avatarPauseBadge`, a plain inline two-bar SVG + rather than a Unicode glyph, since pause/play characters default to emoji + presentation on several platforms and look muddy at 10px) — both matched + by the event's `userId` against each circle's `data-user-id`, both clearing + automatically after `PAUSE_REQUEST_HIGHLIGHT_MS` (4s), 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 own click + registered. This replaced an earlier plain box-shadow pulse animation and + a separate immediate-local-feedback ring on just the requester's own + circle (`.avatarSent`) — collapsed into one unambiguous visual language + driven purely by the server broadcast (which the requester also receives, + so there's no need for a separate client-only echo). +- `.followerNotice` picked up a `margin: 0` it was missing: it's a `

`, + and for an absolutely positioned element `bottom` places the *margin* + edge, so its default ~1em user-agent margin was quietly rendering it 13px + higher than its declared `bottom: 24px` ever suggested. Harmless on its + own until `.pauseRequestBanner` needed to sit a specific gap above it — + found by measuring both rects and seeing the gap read as 2px instead of + the ~15px the two `bottom` values alone implied. - 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 @@ -307,20 +332,21 @@ and a way for a non-host to ask the host to pause without a chat feature 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. +against the real running local server (see session history): the presence +row renders directly under the action bar; initials/colors render correctly +for a two-member party; the host's circle never gets `.avatarClickable` +(neither when viewed by themselves nor by others); a `pauseRequested` event +gives the right circle `rgb(255, 152, 0)` as its computed border color and +`display: flex` on its badge (confirmed via computed styles, not just class +names) while leaving every other circle untouched; rapid repeat clicks on +your own circle within the cooldown collapse to exactly one `requestPause` +emission (three synchronous clicks in one script → 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 the +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. ## Assumptions to verify @@ -403,11 +429,21 @@ generic grid: backend builds from `EMBY_SERVER_URL` + the session's `serverId`). `:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)` keeps it always visible on touch devices, which have no hover state at all. -- **`views/series.js`** (route `/series/:id`): season tabs +- **`views/series.js`** (route `/series/:id`): a detail header — poster, + title + year, overview, a "Voir sur Emby" link — above season tabs (`GET /api/catalog/series/:id/seasons`, Emby's `/Shows/{id}/Seasons`) and an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's `/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own - party. + party. The header is just `toItemDto`'s existing fields + (`GET /api/catalog/item/:id`, the same DTO shape as everything else) — + the season/episode browsing itself predates this pass and was already + wired end to end (library grid → Series tile → this route → "Lancer une + watch party" per episode); what this pass added was making the page + actually look like a detail page instead of a bare `

` above the tabs. + `.linklike`'s CSS selector was widened from `button.linklike` to + `.linklike` so the new "Voir sur Emby" `` picks it up too — there's no + global `a` reset in `app.css`, so an unclassed anchor would otherwise + fall back to the browser's default blue/underline against this dark theme. ### Episode queue / "next episode" @@ -446,9 +482,12 @@ on/off. Driving an actual browser against this (see session history) confirms the **rendering and interaction logic**: sidebar highlighting, home carousels -(parties + one row per non-music library), library grid dispatch, and the -series page's season-tab switching + episode list all render and navigate -correctly. That test mocked every `/api/...` response (canned JSON), since +(parties + one row per non-music library), library grid dispatch (including +a `tvshows` library's Series tiles linking to `/series/:id` both via the +poster and the "Voir les épisodes" button), and the series page's detail +header + season-tab switching + episode list + starting a party from an +episode all render and navigate correctly. That test mocked every +`/api/...` response (canned JSON), since exercising it for real needs a logged-in session — so what's **not** confirmed yet is the shape of the real Emby responses this now depends on: `Views` items actually carrying a `CollectionType` field the way assumed,