All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Three requested changes plus one found-in-passing bug fix: 1. Home page's "watch parties en cours" is now a live Socket.IO subscription (connectLobby -> "lobby" room), not a one-shot fetch - updates the instant anything changes anywhere (create/join/leave). Host name + member count come from the snapshot; elapsed time ticks purely client-side from a createdUtc timestamp (no point pushing a recomputed duration every second for every viewer). home.js returns this subscription from its router cleanup, same leak-prevention pattern as the player's socket. 2. Host handoff is now live end to end, not just correct in the data model. PartyManager.leave already promoted another member and destroyed an empty party (that part predates this change) - what was missing was any way for an already-open follower player to find out it became host. applyLeave (shared by the socket leave/ disconnect handlers and the REST leave endpoint, so this and the lobby broadcast only live in one place) now emits a personal "hostChanged" to the new host's own socket; player.js's becomeHost() unlocks controls live. becomeHost is written to tolerate being called more than once safely (addEventListener no-ops on a repeat identical listener; the heartbeat setInterval is explicitly guarded since it has no such idempotence - calling it twice would leak the first interval, the exact bug class the previous pass fixed). 3. No more join codes - PartyManager no longer generates or tracks one. The live lobby list is the only discovery mechanism now; a code copied out of band doesn't add anything on top of a list that's already always current. POST /api/parties/:partyId/join replaces :joinCode; the "Rejoindre avec un code" UI is gone. Found while touching this: the home page's "Rejoindre" button used to navigate to the player WITHOUT ever calling the join endpoint first - it loaded fine (GET /api/parties/:id never checked membership) but the realtime "join" was silently rejected server-side (not an actual member), so sync never worked for anyone joining this way. Fixed to await joinParty() before navigating, matching how the library grid's tiles already did it. Also: .playerWrap was width:100%/height:100vh inside .mainContent's own padding, so the two stacked and made the page taller than the viewport - an outer scrollbar despite the video already being full- height. Changed to position:fixed/inset:0 so it fills the real viewport regardless of ancestor padding. Verified live against a real running server (see session history): lobby subscription renders host/member-count/ticking-elapsed correctly from a faked "parties" push (createdUtc 2:05 in the past rendered "2:06" after ~1s more, proving the client-side tick is real, not static); the join-then-navigate fix calls the join endpoint before navigating; .playerWrap computes to position:fixed with all four insets at 0. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
534 lines
11 KiB
CSS
534 lines
11 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; }
|
|
|
|
button.linklike {
|
|
background: none;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
text-decoration: underline;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
button.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: seasons tabs + episode list ---- */
|
|
|
|
.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;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 12px 16px;
|
|
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
|
|
color: white;
|
|
font-size: 13px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.overlay .itemTitle { font-weight: 600; }
|
|
.overlay .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 {
|
|
position: absolute;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0,0,0,.6);
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.hide { display: none; }
|