diff --git a/README.md b/README.md index 9e9364e..99201de 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,16 @@ surfaces as an HTTP failure the way the cache-invalidation logic assumes, how a real transcode's seek handling feels once several viewers depend on the exact same one). +Casting to a Chromecast (see docs/architecture.md) is the newest, +least-verified piece: the security-critical parts were checked against +the real running server directly (the party-stream/burn routes reject a +request with no auth exactly as before, and now also reject an invalid +`castToken`, both a plain 401) and the Cast SDK itself was confirmed to +load and initialize in a real browser, but there's neither a real +Chromecast nor Emby login credentials in this environment to take it +any further - the actual on-TV playback and the cast button's own +live behavior are unconfirmed. + ## Project layout Both `server/` and `client/` are TypeScript now (see @@ -289,7 +299,12 @@ Then put it behind your existing reverse proxy like everything else - [x] Fixed catalog browsing feeling slow: image thumbnails were never cacheable (Emby's own Cache-Control/ETag were silently dropped) — see docs/architecture.md +- [x] Cast a party's video to a Chromecast (Default Media Receiver), + with a play/pause/seek remote on the sender's own screen — see + docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) +- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is + confirmed here — no real device/session) - [ ] Tune transcoding target / support direct play when possible No chat, no OIDC — deliberately out of scope (Emby-only auth was the diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 40c84fb..4fdc47d 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -53,6 +53,25 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) }); } +/** + * Mints a short-lived, party-scoped token a Chromecast can use to reach + * this party's own shared stream routes, which otherwise only accept the + * sender's httpOnly session cookie - something a Chromecast can never + * carry (see server/src/services/CastTokenService.ts). Called right + * before starting a cast - see cast.ts/Player.ts. + */ +export function issueCastToken(partyId: string): Promise<{ token: string }> { + return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'POST' }); +} + +/** Invalidates whatever cast token this member currently holds for this + * party - called when a cast session ends, from either side (the user + * stopping it here, or the receiver itself ending it - see + * Player.ts/cast.ts's onCastSessionEnded). Harmless if nothing was cast. */ +export function revokeCastToken(partyId: string): Promise<{ ok: true }> { + return apiFetch(`/api/parties/${partyId}/cast-token`, { method: 'DELETE' }); +} + /** * Live "watch parties en cours" feed for the home page. `onParties` gets * called with the full current list on subscribe, and again every time any diff --git a/client/src/cast.ts b/client/src/cast.ts new file mode 100644 index 0000000..1493396 --- /dev/null +++ b/client/src/cast.ts @@ -0,0 +1,159 @@ +/** + * Google Cast (Chromecast) sender integration - the only module that talks + * to the Cast Application Framework SDK directly (a CDN global, loaded via + * client/src/index.html's + +
diff --git a/client/src/views/player/Player.css b/client/src/views/player/Player.css index e31fe2f..41ff7e1 100644 --- a/client/src/views/player/Player.css +++ b/client/src/views/player/Player.css @@ -62,6 +62,15 @@ .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%; diff --git a/client/src/views/player/Player.html b/client/src/views/player/Player.html index 1cdc540..3439352 100644 --- a/client/src/views/player/Player.html +++ b/client/src/views/player/Player.html @@ -13,6 +13,15 @@ + +
@@ -42,6 +51,7 @@

L'hôte contrôle la lecture.

+

Diffusion en cours sur le Chromecast.

diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index b20c9a0..0223001 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -2,7 +2,7 @@ import '../../styles/presence.css'; import './Player.css'; import html from './Player.html'; import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi'; -import { getParty, leaveParty, connectParty } from '../../api/party'; +import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party'; import type { PartyConnection } from '../../api/party'; import { navigate } from '../../router'; import type { CleanupFn, RouteParams } from '../../router'; @@ -10,6 +10,8 @@ import { colorForName, escapeHtml, initialsFor } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; +import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast'; +import type { PlaybackDriver } from '../../cast'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const DRIFT_TOLERANCE_SECONDS = 1.5; @@ -146,6 +148,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement; const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; const burnInHint = root.querySelector('#burnInHint')!; + const castBtn = root.querySelector('#castBtn') as HTMLButtonElement; + const castingNotice = root.querySelector('#castingNotice')!; qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).join(''); @@ -203,6 +207,71 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli // - see its own comment) can re-render the subtitle picker's host-only // gating for burn-in options without a redundant playback-options fetch. let lastPlaybackOptions: PlaybackOptionsDto | null = null; + // "The player" sendLocalState/applyRemoteState actually read and write - + // the local