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:hover { background: var(--accent-bright); }
button[disabled] { opacity: .5; cursor: default; } 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; background: none;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@ -96,7 +102,7 @@ button.linklike {
padding: 4px 0; 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; } .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; } .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 { .episodeList {
display: flex; display: flex;
@ -429,23 +475,37 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
object-fit: contain; 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; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 16px; gap: 10px;
padding: 12px 16px; padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent); background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
z-index: 5;
}
.actionBar {
display: flex;
align-items: center;
gap: 16px;
color: white; color: white;
font-size: 13px; font-size: 13px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.overlay .itemTitle { font-weight: 600; } .actionBar .itemTitle { font-weight: 600; }
.overlay .members { color: var(--muted); flex: 1; } .actionBar .members { color: var(--muted); flex: 1; }
/* Settings: a small gear icon in the action bar, not a permanently-visible /* Settings: a small gear icon in the action bar, not a permanently-visible
block of dropdowns - opens a compact popup on click. */ 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; } .playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
.followerNotice { .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; position: absolute;
bottom: 24px; bottom: 24px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin: 0;
background: rgba(0,0,0,.6); background: rgba(0,0,0,.6);
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px; border-radius: 20px;
@ -531,20 +598,15 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
color: var(--muted); color: var(--muted);
} }
/* Presence overlay: one circle per party member, bottom-left so it never /* Presence overlay: one circle per party member. Lives inside .topOverlay,
fights the top action bar (title/next/settings/leave, which already wraps right below the action bar - see renderMembers() in player.js. */
on narrow screens) or the centered .followerNotice pill. See
renderMembers() in player.js. */
.presenceOverlay { .presenceOverlay {
position: absolute;
bottom: 24px;
left: 16px;
display: flex; display: flex;
gap: 8px; gap: 8px;
z-index: 5;
} }
.avatar { .avatar {
position: relative; /* anchors .avatarPauseBadge */
width: 36px; width: 36px;
height: 36px; height: 36px;
flex-shrink: 0; flex-shrink: 0;
@ -573,28 +635,38 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
transform: scale(1.08); transform: scale(1.08);
} }
/* Feedback right after clicking your own avatar - stays up as long as the /* Shown to EVERY member (including the host and the requester themselves)
server's own per-member throttle blocks a repeat request anyway (see on the matching person's circle while their "pauseRequested" is active -
PAUSE_REQUEST_COOLDOWN_MS in player.js / socketHandler.js). */ see onPauseRequested() in player.js. Orange border + a small pause-icon
.avatarSent { 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; opacity: 1;
border-color: var(--accent); border-color: #ff9800;
} }
/* Shown to EVERY member (including the host) on the matching person's .avatarPauseBadge {
circle when their "pauseRequested" comes in - see onPauseRequested(). */ position: absolute;
.avatarPulse { bottom: -4px;
animation: avatarPulse 1s ease-in-out 2; 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 { .avatarPauseRequested .avatarPauseBadge { display: flex; }
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 { .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; position: absolute;
top: 64px; bottom: 72px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: rgba(0, 0, 0, .75); 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 TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js 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 // "Quality" is really "max transcoding bitrate", the same simplification
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate // 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() || '?'; 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) { export function renderPartyPlayer(root, params) {
const partyId = params.id; const partyId = params.id;
@ -44,10 +55,10 @@ export function renderPartyPlayer(root, params) {
<button id="playerErrorRetry" class="linklike">Réessayer</button> <button id="playerErrorRetry" class="linklike">Réessayer</button>
</div> </div>
<div id="presenceOverlay" class="presenceOverlay"></div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div> <div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="overlay"> <div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span> <span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span> <span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button> <button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
@ -64,6 +75,9 @@ export function renderPartyPlayer(root, params) {
<button id="leaveBtn" class="linklike">Quitter</button> <button id="leaveBtn" class="linklike">Quitter</button>
</div> </div>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p> <p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>`; </div>`;
@ -259,6 +273,9 @@ export function renderPartyPlayer(root, params) {
// (no photos - Emby accounts don't reliably have one). Your own circle // (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 // 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. // 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) { function renderMembers(members) {
membersEl.textContent = members membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : '')) .map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
@ -273,7 +290,7 @@ export function renderPartyPlayer(root, params) {
style="background:${colorForName(m.userName)}" style="background:${colorForName(m.userName)}"
data-user-id="${escapeHtml(m.userId)}" data-user-id="${escapeHtml(m.userId)}"
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}" 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(''); }).join('');
if (isHost) return; // the host pauses directly, no need for the request affordance 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; if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + PAUSE_REQUEST_COOLDOWN_MS; pauseRequestCooldownUntil = now + PAUSE_REQUEST_COOLDOWN_MS;
conn.requestPause(); 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) { function onPauseRequested(msg) {
pauseBanner.textContent = `${msg.userName} demande une pause`; pauseBanner.textContent = `${msg.userName} demande une pause`;
pauseBanner.classList.remove('hide'); pauseBanner.classList.remove('hide');
if (pauseBannerTimer) clearTimeout(pauseBannerTimer); 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)}"]`); const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
if (avatar) { if (avatar) {
avatar.classList.add('avatarPulse'); avatar.classList.add('avatarPauseRequested');
setTimeout(() => avatar.classList.remove('avatarPulse'), 2000); setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS);
} }
} }

View file

@ -18,7 +18,14 @@ export async function renderSeries(root, params) {
root.innerHTML = ` root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button> <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> <nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`; <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, 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 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 - **Presence**: `client/js/views/player.js`'s `renderMembers()` — already
responsible for the text member list in the top bar — now also rebuilds responsible for the text member list — now also rebuilds
`#presenceOverlay`, one circle per member, positioned bottom-left so it `#presenceOverlay`, one circle per member. Each circle shows the first two
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 characters of `userName`, uppercased (`initialsFor`), on a background color
picked deterministically from a small fixed palette by hashing the name picked deterministically from a small fixed palette by hashing the name
(`colorForName`) — same person, same color, every render, with no need for (`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 `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 socket directly bypasses a client-only check); the client one just avoids
a pointless click doing nothing. a pointless click doing nothing.
- Receiving `"pauseRequested"` shows a short banner - Receiving `"pauseRequested"` shows a short banner (`#pauseRequestBanner`,
(`#pauseRequestBanner`, "`<name>` demande une pause", auto-clearing after "`<name>` demande une pause") and flags the matching person's circle
4s) and pulses the matching person's circle (`.avatarPulse`, a 1s (`.avatarPauseRequested`: an orange `#ff9800` border plus a small pause-icon
box-shadow keyframe run twice, matched by the event's `userId` against badge in its corner — `.avatarPauseBadge`, a plain inline two-bar SVG
each circle's `data-user-id`) for every member, including the requester rather than a Unicode glyph, since pause/play characters default to emoji
and the host — deliberately not host-only, so a request landing while the presentation on several platforms and look muddy at 10px) — both matched
host isn't looking at the tab is still visible to everyone else, and the by the event's `userId` against each circle's `data-user-id`, both clearing
requester gets confirmation their click registered beyond the local automatically after `PAUSE_REQUEST_HIGHLIGHT_MS` (4s), for every member,
`.avatarSent` feedback ring. 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" - Nothing about this feature actually pauses anything automatically — "request"
is literal, the host still has to act on it. Adding an actual forced-pause 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 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. wasn't asked for.
Verified end to end with mocked `members`/`pauseRequested` Socket.IO events Verified end to end with mocked `members`/`pauseRequested` Socket.IO events
against the real running local server (see session history): initials/colors against the real running local server (see session history): the presence
render correctly for a two-member party, the host's circle never gets row renders directly under the action bar; initials/colors render correctly
`.avatarClickable` (neither when viewed by themselves nor by others), rapid for a two-member party; the host's circle never gets `.avatarClickable`
repeat clicks on your own circle within the cooldown collapse to exactly one (neither when viewed by themselves nor by others); a `pauseRequested` event
`requestPause` emission (confirmed by issuing three synchronous clicks in one gives the right circle `rgb(255, 152, 0)` as its computed border color and
script and checking the emitted-event count increased by exactly one — an `display: flex` on its badge (confirmed via computed styles, not just class
earlier two-separate-clicks check had looked like a cooldown failure at names) while leaving every other circle untouched; rapid repeat clicks on
first, but that was real wall-clock time between two separate tool calls your own circle within the cooldown collapse to exactly one `requestPause`
exceeding the 5s window, not a code bug), and a `pauseRequested` event emission (three synchronous clicks in one script → the emitted-event count
correctly banners and pulses the matching circle. The host-only controls increased by exactly one — an earlier two-separate-clicks check had looked
(`becomeHost`, settings menu, next-episode button) were re-checked after this like a cooldown failure at first, but that was real wall-clock time between
change and still work — this pass only added to `renderMembers()` and the two separate tool calls exceeding the 5s window, not a code bug); and the
`connectParty(...)` callback list in `init()`, it didn't touch either of banner/`.followerNotice` gap measured 15px after the margin fix, versus 2px
those paths. before it. The host-only controls (`becomeHost`, settings menu,
next-episode button) were re-checked after this change and still work.
## Assumptions to verify ## Assumptions to verify
@ -403,11 +429,21 @@ generic grid:
backend builds from `EMBY_SERVER_URL` + the session's `serverId`). backend builds from `EMBY_SERVER_URL` + the session's `serverId`).
`:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)` `: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. 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 (`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 an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's
`/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own `/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" ### Episode queue / "next episode"
@ -446,9 +482,12 @@ on/off.
Driving an actual browser against this (see session history) confirms the Driving an actual browser against this (see session history) confirms the
**rendering and interaction logic**: sidebar highlighting, home carousels **rendering and interaction logic**: sidebar highlighting, home carousels
(parties + one row per non-music library), library grid dispatch, and the (parties + one row per non-music library), library grid dispatch (including
series page's season-tab switching + episode list all render and navigate a `tvshows` library's Series tiles linking to `/series/:id` both via the
correctly. That test mocked every `/api/...` response (canned JSON), since 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** 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: confirmed yet is the shape of the real Emby responses this now depends on:
`Views` items actually carrying a `CollectionType` field the way assumed, `Views` items actually carrying a `CollectionType` field the way assumed,