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>
249 lines
7 KiB
CSS
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; }
|