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
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:
parent
669873e41c
commit
f6a8010647
4 changed files with 218 additions and 82 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,24 +55,27 @@ 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">
|
||||||
<span id="itemTitle" class="itemTitle"></span>
|
<div class="actionBar">
|
||||||
<span id="members" class="members"></span>
|
<span id="itemTitle" class="itemTitle"></span>
|
||||||
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
<span id="members" class="members"></span>
|
||||||
|
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
||||||
|
|
||||||
<div id="settingsWrap" class="settingsWrap hide">
|
<div id="settingsWrap" class="settingsWrap hide">
|
||||||
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
||||||
<div id="settingsPanel" class="settingsPanel hide">
|
<div id="settingsPanel" class="settingsPanel hide">
|
||||||
<label>Langue <select id="audioSelect"></select></label>
|
<label>Langue <select id="audioSelect"></select></label>
|
||||||
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
||||||
<label>Qualité <select id="qualitySelect"></select></label>
|
<label>Qualité <select id="qualitySelect"></select></label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="leaveBtn" class="linklike">Quitter</button>
|
<div id="presenceOverlay" class="presenceOverlay"></div>
|
||||||
</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>
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,14 @@ export async function renderSeries(root, params) {
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<button class="linklike" id="back">← Retour</button>
|
<button class="linklike" id="back">← 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>`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue