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