watchparty/client/src/views/player/Player.css
Nicolas b4c81d820c
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 16s
Cast a party's video to a Chromecast
Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.

A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.

- New CastTokenService (same shape as AuthService: in-memory Map, opaque
  crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
  {partyId, userId}, standing in for that member's own EmbySession.
  New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
  party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
  fallback (sessionForStream in index.ts): try the session cookie first,
  same as ever, then a castToken query param - either way the result is
  the same EmbySession type, so PartyStreamService/TranscodeService/
  EmbyClient needed zero changes. The image/subtitle routes stay
  cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
  member held, unconditionally - a token dies the instant its owner
  actually leaves the party regardless of TTL or whether the client got
  a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
  (loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
  exposes a small PlaybackDriver interface (currentTime/paused/play/
  pause) with two implementations, one over the local <video>, one over
  CAF's RemotePlayer/RemotePlayerController. This was the only new
  abstraction the feature needed, not a new sync mechanism: Player.ts's
  existing sendLocalState/applyRemoteState already read and write "the
  player" in one place each, so pointing them at whichever driver is
  active carried the host-only gate, drift-tolerance reconciliation, and
  3s heartbeat over unchanged. Seek was already "the host's next
  broadcast" with no separate event, so seeking on the TV is party-wide
  for free; a non-host caster already only ever received state, so
  redirecting that to the cast driver reproduces "still dependent on the
  host" automatically.
- Cast button in the player's action bar, shown to any member (not just
  the host) once cast.framework reports a receiver on the network.
  Starting a cast tears down the local hls.js instance; ending one
  (user-stopped, or the receiver dropping off on its own) resyncs local
  playback via the same loadVideo path an audio/quality change already
  uses. A real reload for any other reason (item change, burn-in/
  subtitle/stream-options change) simply ends an active cast first,
  rather than keeping a live session in sync with every kind of party
  transition - not worth the complexity for v1.

Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.

Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:13:12 +02:00

249 lines
7 KiB
CSS

.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 in styles/presence.css. 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); }
/* #castBtn while actively casting - see Player.ts's toggleCast. Same
"clearly-on" treatment as .avatarActionRequested's orange, not just a
background tint, so it reads at a glance whether the TV or this screen
is the one actually playing right now. */
.iconBtn[aria-pressed="true"] {
color: #ff9800;
background: rgba(255, 152, 0, .18);
}
.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;
}
.settingsPanel select:disabled {
opacity: .5;
}
/* .hint's own bottom margin (base.css) would stack with the panel's own
flex `gap` below it - zeroed here so it's spaced exactly like every
other row in the column, not a wider gap after this specific one. */
.burnInHint {
margin: 0;
max-width: 220px;
}
.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; }
/* One column, bottom-anchored, gap between whichever children are
actually visible - same "stack in normal flow instead of guessing a
fixed pixel offset per absolutely-positioned block" fix .topOverlay's
own actionBar+presenceOverlay grouping already needed (see its own
comment, and docs/architecture.md for the exact bug this avoided: a
<p>'s default margin silently throwing off a hand-picked bottom offset
on the element above it - not repeatable once nothing is positioned
independently any more). Centered on purpose - this is exactly where
a native <track> subtitle renders too, but both children here are
passive text (a transient banner, a static notice), not something a
viewer needs to click through subtitle text to reach - unlike
.requestToolbar below, which used to live in this same column and
was moved out for exactly that reason. */
.bottomOverlay {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 6;
max-width: calc(100% - 32px);
}
.requestBanner {
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
}
.followerNotice {
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: var(--muted);
}
/* The non-host's way to ask the host for something - see
Player.ts's requestPauseBtn/requestResumeBtn. Pill buttons matching
the banner/notice's own dark-overlay look above, not the plain-text
.linklike used in the (opaque) top action bar - this sits directly on
top of the video image, same as .bottomOverlay.
Its own bottom-RIGHT corner, independently of .bottomOverlay -
deliberately not centered-bottom any more (see .bottomOverlay's own
comment): that's also where a native <track> subtitle renders, and
these are real buttons someone needs to read and click, not passive
text a subtitle could just as easily sit behind. Stacked as a column
(not a row, unlike the old centered layout) so two pill buttons next
to each other never has to fight a narrow phone screen for width -
each just takes however much it needs, right-aligned under the other. */
.requestToolbar {
position: absolute;
right: 16px;
bottom: 20px;
z-index: 6;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
max-width: calc(50% - 16px);
}
.requestBtn {
background: rgba(0, 0, 0, .6);
color: white;
padding: 8px 14px;
border-radius: 20px;
font-size: 12.5px;
white-space: nowrap; /* keeps the pill shape even in .requestToolbar's own narrow corner on a phone-width screen */
}
.requestBtn:hover { background: rgba(0, 0, 0, .8); }
/* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their pause/resume request is
active - see onPauseRequested()/onResumeRequested() in Player.ts.
Orange border + a small icon badge, not just a generic pulse, so it
reads unambiguously even to someone glancing at the video rather than
the banner text. Same treatment for either request - the banner text
is what says which one. */
.avatarActionRequested {
opacity: 1;
border-color: #ff9800;
}
.avatarRequestBadge {
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;
}
.avatarActionRequested .avatarRequestBadge { display: flex; }