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,