Move presence overlay to the top, orange badge for pause requests, series detail page
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s

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 <p>, 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 <h1>.
   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" <a> 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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 00:22:07 +02:00
parent 669873e41c
commit f6a8010647
4 changed files with 218 additions and 82 deletions

View file

@ -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 <a class="linklike"> - e.g. the
"Voir sur Emby" link on the series detail page - gets the same treatment
as the <button> ones without needing its own duplicate rule; there's no
global `a` reset in this file, so an unclassed anchor would otherwise
fall back to the browser's default blue/underline against this dark
theme. */
.linklike {
background: none;
color: var(--muted);
font-size: 12px;
@ -96,7 +102,7 @@ button.linklike {
padding: 4px 0;
}
button.linklike:hover { background: none; color: var(--accent-bright); }
.linklike:hover { background: none; color: var(--accent-bright); }
.code { color: var(--accent-bright); letter-spacing: .1em; }
@ -362,7 +368,47 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.tileBtn { font-size: 11px; padding: 6px 4px; }
}
/* ---- Series page: seasons tabs + episode list ---- */
/* ---- Series page: detail header + seasons tabs + episode list ---- */
.seriesHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.seriesPoster {
width: 160px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.seriesPoster.noImage { visibility: hidden; }
.seriesInfo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.seriesYear { color: var(--muted); font-weight: 400; }
.seriesOverview {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
max-width: 640px;
}
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }
}
.episodeList {
display: flex;
@ -429,23 +475,37 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
object-fit: contain;
}
.overlay {
/* Two stacked rows, both part of the same top gradient overlay: the action
bar (title/members/next/settings/leave) and, below it, the presence
avatars - see .presenceOverlay. Stacking them in normal flow inside one
column means the avatar row always ends up right after the action bar
regardless of how tall that bar is (it wraps to two lines on narrow
screens), instead of guessing a fixed pixel offset for a second
absolutely-positioned block. */
.topOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 16px;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
z-index: 5;
}
.actionBar {
display: flex;
align-items: center;
gap: 16px;
color: white;
font-size: 13px;
flex-wrap: wrap;
}
.overlay .itemTitle { font-weight: 600; }
.overlay .members { color: var(--muted); flex: 1; }
.actionBar .itemTitle { font-weight: 600; }
.actionBar .members { color: var(--muted); flex: 1; }
/* Settings: a small gear icon in the action bar, not a permanently-visible
block of dropdowns - opens a compact popup on click. */
@ -520,10 +580,17 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
.followerNotice {
/* margin:0 overrides the <p>'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. */
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
@ -531,20 +598,15 @@ 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. */
/* Presence overlay: one circle per party member. Lives inside .topOverlay,
right below the action bar - see renderMembers() in player.js. */
.presenceOverlay {
position: absolute;
bottom: 24px;
left: 16px;
display: flex;
gap: 8px;
z-index: 5;
}
.avatar {
position: relative; /* anchors .avatarPauseBadge */
width: 36px;
height: 36px;
flex-shrink: 0;
@ -573,28 +635,38 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
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 {
/* 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.js. 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 {
opacity: 1;
border-color: var(--accent);
border-color: #ff9800;
}
/* 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;
.avatarPauseBadge {
position: absolute;
bottom: -4px;
right: -4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff9800;
border: 1px solid rgba(0, 0, 0, .5);
display: none;
align-items: center;
justify-content: center;
}
@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; }
}
.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;
top: 64px;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, .75);

View file

@ -6,6 +6,7 @@ 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
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
// "Quality" is really "max transcoding bitrate", the same simplification
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate
@ -31,6 +32,16 @@ function initialsFor(name) {
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
}
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
// specifically so this small a badge stays a crisp two-bar shape regardless
// of the platform's emoji font - pause/play glyphs default to emoji
// presentation on several platforms, which looks muddy at 10px.
const PAUSE_ICON_SVG = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`;
export function renderPartyPlayer(root, params) {
const partyId = params.id;
@ -44,24 +55,27 @@ 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>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
</div>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
@ -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 <span> 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))}</div>`;
>${escapeHtml(initialsFor(m.userName))}<span class="avatarPauseBadge">${PAUSE_ICON_SVG}</span></div>`;
}).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);
}
}

View file

@ -18,7 +18,14 @@ export async function renderSeries(root, params) {
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1>${escapeHtml(series.name)}</h1></div>
<div class="seriesHeader">
<img class="seriesPoster" src="${series.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1>${escapeHtml(series.name)}${series.year ? ` <span class="seriesYear">(${series.year})</span>` : ''}</h1>
${series.overview ? `<p class="seriesOverview">${escapeHtml(series.overview)}</p>` : ''}
<a class="linklike" href="${series.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;

View file

@ -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`, "`<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.
- Receiving `"pauseRequested"` shows a short banner (`#pauseRequestBanner`,
"`<name>` 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 `<p>`,
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 `<h1>` above the tabs.
`.linklike`'s CSS selector was widened from `button.linklike` to
`.linklike` so the new "Voir sur Emby" `<a>` 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,