All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Two features requested together: a semi-transparent overlay showing who's
in the party, and a way for a non-host to ask the host to pause.
- server/src/socketHandler.js: new "requestPause" event (anyone, throttled
5s per member server-side via member.lastPauseRequestUtc - the real
guard, independent of whatever the client does) broadcasts
"pauseRequested" {userId, userName} to the whole party room, not just the
host, so everyone sees why playback is about to pause.
- client/js/party.js: connectParty() grows an onPauseRequested callback and
the returned connection a requestPause() method, same shape as the
existing next()/sendPlaystate().
- client/js/views/player.js: renderMembers() now also rebuilds a
#presenceOverlay of one circle per member (initials from userName,
colorForName() picks a deterministic background from a small palette by
hashing the name - same person, same color, no server-side assignment
needed). "Which circle is mine" compares userName against the auth
singleton embyApi.js already populates, matching how the rest of the
client avoids keying UI logic on userId directly. Your own circle, only
when you're not host, is clickable and calls requestPause() (client-side
cooldown too, purely to avoid a click that would visibly do nothing - the
server-side one is what actually matters). Receiving pauseRequested shows
a short auto-clearing banner and pulses the matching circle for everyone,
including the requester and the host.
- client/css/app.css: .presenceOverlay/.avatar (bottom-left, opacity .75 is
the "semi transparence" itself) and the clickable/sent/pulse states,
.pauseRequestBanner.
Verified with mocked members/pauseRequested Socket.IO events against the
real running local server (see session history): initials/colors render
correctly, the host's own circle never gets the clickable affordance,
rapid repeat clicks collapse to exactly one requestPause emission (three
synchronous clicks -> one new emitted event), a pauseRequested event
banners and pulses the right circle. Re-checked the pre-existing host path
(becomeHost, settings menu, next-episode button) still works unchanged -
this pass only touched renderMembers() and init()'s connectParty() call.
docs/architecture.md has the full writeup. Also fixed two roadmap
checkboxes in README.md that should have been checked off by the previous
commit (live lobby/host handoff/no join codes) but were missed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
608 lines
13 KiB
CSS
608 lines
13 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);
|
|
}
|
|
|
|
/* Presence overlay: one circle per party member, bottom-left so it never
|
|
fights the top action bar (title/next/settings/leave, which already wraps
|
|
on narrow screens) or the centered .followerNotice pill. See
|
|
renderMembers() in player.js. */
|
|
.presenceOverlay {
|
|
position: absolute;
|
|
bottom: 24px;
|
|
left: 16px;
|
|
display: flex;
|
|
gap: 8px;
|
|
z-index: 5;
|
|
}
|
|
|
|
.avatar {
|
|
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);
|
|
}
|
|
|
|
/* Feedback right after clicking your own avatar - stays up as long as the
|
|
server's own per-member throttle blocks a repeat request anyway (see
|
|
PAUSE_REQUEST_COOLDOWN_MS in player.js / socketHandler.js). */
|
|
.avatarSent {
|
|
opacity: 1;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* Shown to EVERY member (including the host) on the matching person's
|
|
circle when their "pauseRequested" comes in - see onPauseRequested(). */
|
|
.avatarPulse {
|
|
animation: avatarPulse 1s ease-in-out 2;
|
|
}
|
|
|
|
@keyframes avatarPulse {
|
|
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 {
|
|
position: absolute;
|
|
top: 64px;
|
|
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; }
|