watchparty/client/css/app.css
Nicolas f6a8010647
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Move presence overlay to the top, orange badge for pause requests, series detail page
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>
2026-09-14 00:22:07 +02:00

680 lines
15 KiB
CSS

/* Colors pulled live from this Emby server's own active theme (dark), via
its CSS custom properties (--theme-primary-color-*, --background-*, etc.)
- see docs/architecture.md for how these were read directly off the
running server rather than guessed. */
:root {
color-scheme: dark;
--bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */
--bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */
--panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */
--text: #ffffff;
--muted: rgba(255, 255, 255, .6);
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app { min-height: 100%; }
/* Login is a small centered form - everything else lives inside the app
shell (sidebar + .mainContent), see below. */
.wrap {
max-width: 420px;
margin: 0 auto;
padding: 32px 20px 80px;
}
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 16px; margin: 0 0 12px; }
.panel {
background: var(--bg-header);
border-radius: 12px;
padding: 20px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: var(--panel);
color: var(--text);
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 12px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:hover { background: var(--accent-bright); }
button[disabled] { opacity: .5; cursor: default; }
/* 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;
font-weight: 400;
text-decoration: underline;
padding: 4px 0;
}
.linklike:hover { background: none; color: var(--accent-bright); }
.code { color: var(--accent-bright); letter-spacing: .1em; }
/* ---- App shell: sidebar + routed main content ---- */
.appShell {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--bg-header);
padding: 20px 12px;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebarBrand {
font-size: 18px;
font-weight: 700;
padding: 0 12px 20px;
}
.sidebarNav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.navSectionLabel {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
padding: 16px 12px 6px;
}
.navItem {
display: block;
padding: 10px 12px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.navItem:hover { background: rgba(255, 255, 255, .06); }
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
.mainContent {
flex: 1;
min-width: 0;
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
/* Sidebar becomes a horizontal scrollable topbar on narrow screens - same
"scroll instead of wrap" treatment as .tabs below. */
@media (max-width: 720px) {
.appShell { flex-direction: column; }
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
align-items: center;
padding: 10px 12px;
overflow-x: auto;
overflow-y: hidden;
}
.sidebarBrand { padding: 0 12px 0 0; }
.sidebarNav { flex-direction: row; flex: none; }
.navSectionLabel { display: none; }
.sidebarLogout { margin: 0 0 0 12px; }
}
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.joinInline { display: flex; gap: 8px; align-items: center; }
.joinInline input { width: 140px; margin: 0; }
.joinInline button { padding: 10px 14px; }
.section { margin-bottom: 32px; }
/* ---- Horizontal carousels (home page) ---- */
.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 10px;
}
.carousel .tile { flex: 0 0 160px; }
.carousel .partyCard { flex: 0 0 280px; }
.partyCard {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.partyCard img {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.partyCard img.noImage { visibility: hidden; }
.partyCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
/* Tabs: one per Emby library (library.js) or per season (series.js). */
.tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,.1);
}
.tab {
background: none;
color: var(--muted);
font-weight: 600;
padding: 8px 4px;
border-radius: 0;
white-space: nowrap;
border-bottom: 2px solid transparent;
}
.tab:hover { background: none; color: var(--text); }
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
/* ---- Tile grid (library.js) + tiles (shared with the carousels) ---- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px 16px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
/* Without this, a long title (or an image's intrinsic size before it
loads) can force this flex/grid item wider than its declared size -
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
stay exactly the track/flex-basis size regardless of content. */
min-width: 0;
}
.tile img {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
display: block;
}
.tile img.noImage { visibility: hidden; }
.tileLink {
position: absolute;
inset: 0 0 26px 0;
z-index: 1;
}
.tileLabel {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hover-reveal overlay with the two actions, on devices that actually have
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
see the (hover: none) block below, since :hover is unreachable there. */
.tileOverlay {
position: absolute;
inset: 0 0 26px 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 8px;
padding: 10px;
background: rgba(0, 0, 0, .55);
border-radius: 8px;
opacity: 0;
transition: opacity .15s ease;
pointer-events: none;
}
.tile:hover .tileOverlay,
.tile:focus-within .tileOverlay {
opacity: 1;
pointer-events: auto;
}
.tileBtn {
padding: 8px 10px;
font-size: 12px;
text-align: center;
text-decoration: none;
}
.tileBtnLink {
background: var(--panel);
color: var(--text);
border-radius: 8px;
font-weight: 600;
}
.tileBtnLink:hover { background: #333; }
@media (hover: none) {
.tileOverlay {
opacity: 1;
pointer-events: auto;
inset: auto 0 26px 0;
flex-direction: row;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
padding: 6px;
}
.tileBtn { font-size: 11px; padding: 6px 4px; }
}
/* ---- 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;
flex-direction: column;
gap: 12px;
}
.episodeRow {
display: flex;
gap: 14px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.episodeRow img {
width: 120px;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.episodeRow img.noImage { visibility: hidden; }
.episodeInfo { flex: 1; min-width: 0; }
.episodeInfo strong { display: block; margin-bottom: 4px; }
.episodeInfo p {
margin: 0;
color: var(--muted);
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.episodeRow button { flex-shrink: 0; }
@media (max-width: 600px) {
.episodeRow { flex-wrap: wrap; }
.episodeInfo { order: 3; flex-basis: 100%; }
}
/* ---- Player ---- */
.playerWrap {
/* Fixed + inset so it fills the actual viewport regardless of
.mainContent's own padding (which used to add to 100vh and push the
whole page taller than the screen, causing an outer scrollbar). Also
covers the sidebar while playing, like most video players' theater
mode - "Quitter" is how you get back to it. */
position: fixed;
inset: 0;
z-index: 20;
background: #000;
}
.playerWrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 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;
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;
}
.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. */
.settingsWrap { position: relative; }
.iconBtn {
background: none;
padding: 4px 8px;
font-size: 16px;
line-height: 1;
border-radius: 6px;
}
.iconBtn:hover { background: rgba(255, 255, 255, .12); }
.settingsPanel {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-header);
padding: 12px;
border-radius: 10px;
font-size: 12px;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
z-index: 5;
}
.settingsPanel label {
color: var(--muted);
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsPanel select {
background: var(--panel);
color: var(--text);
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
.loadingNotice {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--muted);
font-size: 14px;
margin: 0;
}
.playerError {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 360px;
text-align: center;
background: rgba(0, 0, 0, .75);
padding: 20px;
border-radius: 10px;
}
.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;
font-size: 13px;
color: var(--muted);
}
/* Presence overlay: one circle per party member. Lives inside .topOverlay,
right below the action bar - see renderMembers() in player.js. */
.presenceOverlay {
display: flex;
gap: 8px;
}
.avatar {
position: relative; /* anchors .avatarPauseBadge */
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: white;
opacity: .75; /* "overlay en semi transparence" - background color comes from inline style, see colorForName() in player.js */
border: 2px solid rgba(255, 255, 255, .3);
user-select: none;
}
/* Only the viewer's own avatar gets this, and only while not host - see
renderMembers(). The host has direct controls, so their own circle is
presence-only. */
.avatarClickable {
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.avatarClickable:hover {
opacity: 1;
transform: scale(1.08);
}
/* 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: #ff9800;
}
.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;
}
.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;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
z-index: 6;
}
.hide { display: none; }