All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
The last deferred piece from the original spec. Chosen permission model (asked, not assumed): anyone can add a track to the shared queue any time; anyone can pause/resume directly; skipping the current track needs a strict majority vote of currently connected members. No host for playback - a genuinely different shape of shared state than video's "host drives, everyone follows", which is why it's a separate service (server/src/musicQueueService.js, MusicQueueService) rather than more branches in partyManager.js/socketHandler.js's existing video-shaped logic. Server: - musicQueueService.js: owns a music party's queue/current-track/pause/ vote-skip state. Server-authoritative timing (no client is "the host" reporting position) - a wall-clock startedUtc plus the track's known duration is the only state; auto-advance is one setTimeout scheduled for exactly when that runs out, not any client reporting "it ended" (which could race across several players finishing near-simultaneously with no host to arbitrate). attachIo(io, onTrackChanged), called once at startup like PartyManager's own wiring, lets it broadcast "queueUpdated" from inside that timer's callback, which has no socket/request in scope at all. - The timer is the one new leak surface this adds, given how much this project has already had to fix real leaks - destroy(party) clears it, wired into both of partyManager.js's existing destruction paths (leave's last-member case, removeIdleParties' sweep). - Party gained mediaType: 'video'|'music' (inferred server-side from the clicked item's Emby Type - the client never has to know or say which). - Three new catalog endpoints (Artists -> Albums -> Tracks), same reasoning as the existing Shows -> Seasons -> Episodes: a real Emby endpoint per level, not generic recursive /Items guessing. - Audio streams through the *same* /master.m3u8 route as video (Emby's PlaybackInfo negotiation isn't video-specific) - the one difference is the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the client tags via a mediaType=audio query hint rather than costing an extra Emby round trip to detect server-side. - Adding a track is REST (POST /api/parties/:partyId/tracks), not a socket event like voteSkip/musicPlaystate - it needs to work from the player's inline browse-and-add panel regardless of realtime-connection specifics, and MusicQueueService's own broadcast reaches everyone connected either way. Client: - player.js's renderPartyPlayer is now a small dispatcher: fetches the party once, then delegates to renderVideoPlayer or the new renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be async despite awaiting that fetch - route()'s contract needs its cleanup function back synchronously (router.js's `typeof cleanup === 'function'` check would silently skip a Promise, reintroducing the exact leak class already fixed once) - solved with a mutable box returned immediately, filled in once the fetch resolves. - musicPlayer.js: not full-viewport/theater-mode like the video player (no image to declutter around, sidebar stays up) - which is what makes its own inline Artist -> Album -> Track browser work for "add tracks while listening" without navigating away and losing player state or threading party context through query params across three route levels. - New views/artist.js, views/album.js (library.js's music branch renders an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN table generalizes the old single seriesHref special-case now that there are three drill-down targets, not two. - party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused (socket) on the existing connectParty() connection object. Real bug found while testing, not just the mock's fault: applyMusicState used to update the queue list/vote button *after* the current-track branch, which calls loadTrack() - and loadTrack()'s hls.js setup can throw. Since JS doesn't roll back earlier statements when a later one throws but does abort everything after it, an exception there took the unrelated queue list and vote tally down with it, and on the first render escaped all the way to the dispatcher's catch, which alerts and bounces back to /home - discarding the whole view over what's really just "this one track's audio failed to load". Fixed by reordering (queue/vote/icon updates - which can't fail - before the track-loading branch, mirroring how player.js's onItemChanged already updates title/next-button before calling loadVideo) and wrapping loadTrack's hls.js setup in its own try/catch so a failure there logs and stops instead of propagating. Verified server-side directly against the real shipped modules (not reimplemented mocks): 18 checks against musicQueueService.js alone (auto -start, queueing, vote-skip majority, pause/resume elapsed-time math, a real setTimeout auto-advance, timer cleanup on destroy - instrumented setTimeout/clearTimeout counting, same technique as the earlier client -side leak tests) and 7 more through socketHandler.js + partyManager.js together (no-ops on a video party, a non-host pausing/resuming, a two-vote majority skip, the resulting broadcast really reaching the room). Client-side against the real local server with mocked fetch/Hls/ io: the full browsing chain, party creation, the dispatcher choosing the right player (and still choosing video when mediaType is absent - back -compat), now-playing/vote-tally/queue rendering and updates including a live track change, play/pause and vote-skip emitting the right events, the inline browse-and-add panel POSTing the right payload, and the crash fix re-confirmed fixed via the same broken scenario that first found it. Not verified: the real Emby round trip for any of this (Artists/albums -tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior, actual playback quality) - same limit as everything else in this project tested without live Emby access. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
812 lines
19 KiB
CSS
812 lines
19 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;
|
|
}
|
|
|
|
/* Base look is the same solid, high-contrast treatment as every other
|
|
button in the app (see the global `button {}` rule above) - but that
|
|
rule is tag-scoped, so it never applied to "Voir les épisodes", the one
|
|
.tileBtn that's an <a>, not a <button> (a plain hash link, so the
|
|
browser's native navigation handles it with no JS needed). Left
|
|
unstyled beyond padding/text-align, it rendered as a bare, small,
|
|
default-blue link sitting on the overlay - much less visible than
|
|
"Lancer une watch party" (a real <button>, styled by that global rule)
|
|
or "Voir sur Emby" (an <a>, but with the explicit .tileBtnLink look
|
|
below) right next to it. Styling .tileBtn itself, tag-agnostic, fixes
|
|
that - and is the semantically right call besides: for a Series tile,
|
|
"Voir les épisodes" IS the primary action (a Series is never itself
|
|
directly playable - see WATCH_PARTY_TYPES in server/src/index.js - so
|
|
the two never appear on the same tile), not a secondary one. */
|
|
.tileBtn {
|
|
padding: 8px 10px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
text-decoration: none;
|
|
border-radius: 8px;
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.tileBtn:hover { background: var(--accent-bright); }
|
|
|
|
/* "Voir sur Emby" only: the one secondary/neutral action among the tile
|
|
buttons - overrides just the two properties that make it read as
|
|
secondary, on top of .tileBtn's shared sizing/weight/radius above. */
|
|
.tileBtnLink {
|
|
background: var(--panel);
|
|
color: var(--text);
|
|
}
|
|
|
|
.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%; }
|
|
}
|
|
|
|
/* ---- Music: album page, track lists (shared with the music player's own
|
|
queue/browse panel), now-playing header ---- */
|
|
|
|
.albumHeader {
|
|
display: flex;
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.albumCover {
|
|
width: 160px;
|
|
aspect-ratio: 1 / 1;
|
|
object-fit: cover;
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.albumCover.noImage { visibility: hidden; }
|
|
|
|
.trackList {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.trackRow {
|
|
display: grid;
|
|
grid-template-columns: 28px 1fr auto auto;
|
|
align-items: center;
|
|
gap: 12px;
|
|
background: var(--bg-header);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
|
|
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
|
|
|
|
@media (max-width: 600px) {
|
|
.albumHeader { flex-direction: column; }
|
|
.albumCover { width: 120px; }
|
|
.trackRow { grid-template-columns: 24px 1fr; }
|
|
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
|
|
}
|
|
|
|
/* Music player's "now playing" header - see musicPlayer.js. Not inside a
|
|
.playerWrap like the video player: there's no video image to declutter
|
|
around, so this renders as a normal page (sidebar stays up) rather than
|
|
theater-mode full-viewport. */
|
|
.nowPlaying {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: center;
|
|
background: var(--bg-header);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.nowPlayingImage {
|
|
width: 96px;
|
|
aspect-ratio: 1 / 1;
|
|
object-fit: cover;
|
|
border-radius: 8px;
|
|
background: var(--panel);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nowPlayingImage.noImage { visibility: hidden; }
|
|
|
|
.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
.nowPlayingControls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.nowPlayingControls .iconBtn { font-size: 20px; }
|
|
|
|
/* The presence row reused from the video player (same .avatar component -
|
|
see player.js) but in normal page flow here, not absolutely positioned
|
|
over a video frame. */
|
|
.musicPresence { margin-bottom: 16px; }
|
|
|
|
.browsePanel {
|
|
background: var(--bg-header);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.browseList {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.browseItem {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 8px 4px;
|
|
}
|
|
|
|
/* ---- 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; }
|