# 06 — Chromecast: Receiver & Sender Design
Status: **Normative design contract** for FablePool's Cast integration.
Audience: Android app team, web client team, server team.
This document specifies how FablePool streams audio to Google Cast devices
(Chromecast, Chromecast Audio, Google/Nest speakers, Android TV) from the
Android app and the web client, and what the server must provide to make
Cast playback work against S3/WebDAV-backed libraries.
---
## 1. Goals & Constraints
| # | Requirement |
|---|-------------|
| G1 | Cast playback of any track in the user's library, including tracks that require transcoding (see `docs/04-caching-and-transcoding.md`). |
| G2 | The Cast device fetches media **directly from the FablePool server** — never from S3/WebDAV directly. The server is the only component holding storage credentials (see `docs/03-storage-abstraction.md`, §7 on the "no credential leakage" invariant). |
| G3 | Auth on Cast media URLs must not embed long-lived user credentials, because media URLs are visible in Cast device logs and on the local network. |
| G4 | Queue semantics on the Cast device must match the in-app queue, including AI auto-play of the next track (see `docs/07-recommendation-engine.md`). |
| G5 | Gapless-adjacent behavior: preload the next item so inter-track silence is < 500 ms on stock Chromecast hardware. |
| G6 | Work with the **Default Media Receiver** as a fallback, but ship a **custom CAF receiver** as the primary path (needed for G4 auto-play and styled now-playing UI). |
Non-goals (v1): multi-room grouping control beyond what Cast firmware does
natively; video; casting to AirPlay/DLNA (tracked as a possible later milestone).
---
## 2. Component Overview
```mermaid
flowchart LR
subgraph LAN["User's network"]
A[Android App
Cast Sender
Cast SDK / Media3 CastPlayer]
W[Web Client
Cast Sender
CAF Sender JS]
C[Chromecast Device
Custom CAF Receiver
HTML5 app]
end
S[FablePool Server
REST + Subsonic API
Stream endpoints]
B[(S3 / WebDAV
backends)]
A -- "Cast protocol (mDNS + TLS)" --> C
W -- "Cast protocol" --> C
C -- "HTTPS GET /api/v1/stream/{trackId}?castToken=…" --> S
A -- "HTTPS REST/Subsonic" --> S
W -- "HTTPS REST/Subsonic" --> S
S -- "ranged GET / presigned GET" --> B
```
Key consequence of G2: **the Cast device must be able to reach the FablePool
server over HTTPS with a certificate it trusts.** Chromecast firmware rejects
self-signed certificates. Deployment requirement (documented in the ops guide
later): the server must be reachable at a hostname with a valid public CA
certificate (Let's Encrypt is fine), even for LAN-only deployments
(e.g. via a DNS name resolving to a LAN IP — Chromecast accepts that as long
as the cert chain validates).
---
## 3. Receiver Application
### 3.1 Choice: Custom CAF Receiver
We register a **custom Web Receiver** built on the Cast Application Framework
(CAF Receiver SDK v3, `//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js`).
Rationale:
1. **Auto-play hook (G4):** when the receiver-side queue is about to run dry,
the receiver asks the *sender* (not the server) for the next item over a
custom namespace. The sender consults the recommendation engine and
appends. This keeps recommendation auth on the sender and keeps the
receiver stateless.
2. **Token refresh:** Cast media URLs carry short-lived `castToken`s (§5).
The receiver intercepts `LOAD` and `MEDIA_STATUS` to refresh expired
tokens via a custom-namespace round trip rather than failing playback.
3. **Branding:** album art, progress, artist/album metadata in our styling.
A **styled/default receiver fallback** must still work for basic single-track
and static-queue playback so that users can cast before the custom receiver is
approved in the Cast Developer Console, and in self-hosted setups where the
admin does not register their own receiver app ID. The server config exposes:
```yaml
cast:
receiver_app_id: "ABCD1234" # custom receiver; empty => default receiver CC1AD845
```
The sender fetches this from `GET /api/v1/system/cast-config` at startup.
### 3.2 Receiver hosting
The receiver is a static HTML+JS bundle served by the FablePool server itself at
`GET /cast/receiver/` (so self-hosters control it and version skew with the
server is impossible). The Cast Developer Console entry for a deployment points
at `https:///cast/receiver/`.
### 3.3 Custom namespace
```
urn:x-cast:org.fablepool.cast
```
Message envelope (JSON, both directions):
```json
{ "type": "", "requestId": 17, "payload": { } }
```
| Type | Direction | Payload | Purpose |
|------|-----------|---------|---------|
| `NEXT_ITEM_REQUEST` | receiver → sender | `{ "currentItemId": "trk_…", "queueRemaining": 1 }` | Receiver asks for the next auto-play item when ≤ 1 item remains and auto-play is enabled. |
| `NEXT_ITEM_RESPONSE` | sender → receiver | `{ "item": \| null }` | `null` means "stop after queue ends" (auto-play off or recommender returned nothing). |
| `TOKEN_REFRESH_REQUEST` | receiver → sender | `{ "trackId": "trk_…" }` | Media URL token expired mid-session (e.g. long pause). |
| `TOKEN_REFRESH_RESPONSE` | sender → receiver | `{ "trackId": "trk_…", "castToken": "…", "expiresAt": "ISO-8601" }` | Receiver rewrites the queue item's `contentUrl` before (re)loading. |
| `SCROBBLE_HINT` | receiver → sender | `{ "trackId": "trk_…", "positionMs": 123456, "event": "started" \| "completed" }` | Receiver-side playback progress so the *sender* posts play-history (§7). Receiver never calls authenticated history APIs itself. |
`requestId` correlates request/response; responses echo the request's id.
Senders must tolerate unknown `type`s (forward compatibility).
### 3.4 Receiver behavior (normative)
- On `LOAD`, validate that `media.contentUrl` host matches the receiver's own
origin host (defense against queue injection from other senders on the LAN
in shared environments); reject with `LOAD_FAILED` / reason `INVALID_REQUEST`
otherwise.
- Use CAF queueing (`cast.framework.QueueBase` subclass). When
`nextItems().length <= 1` **and** the load request's `customData.autoPlayNext`
is `true`, send `NEXT_ITEM_REQUEST`. Timeout 5 s; on timeout, do nothing
(queue simply ends).
- Preload: set `queueItem.preloadTime = 20` (seconds before end of current
item) to satisfy G5.
- On HTTP 401 from a media URL, attempt exactly one `TOKEN_REFRESH_REQUEST`
round trip, rewrite the URL, retry. Second failure → surface
`MEDIA_ERROR`.
- Idle timeout: standard CAF 10-minute idle teardown; do not override.
---
## 4. Media Item Contract
Every queue item the sender loads (and every item in `NEXT_ITEM_RESPONSE`)
uses this shape (CAF `MediaInformation` + `customData`):
```json
{
"contentUrl": "https://fp.example.com/api/v1/stream/trk_8f2a?castToken=v1.eyJ…&format=raw",
"contentType": "audio/flac",
"streamType": "BUFFERED",
"metadata": {
"metadataType": 3,
"title": "Song Title",
"artist": "Artist Name",
"albumName": "Album Name",
"images": [
{ "url": "https://fp.example.com/api/v1/art/alb_91c0?size=512&castToken=v1.eyJ…" }
]
},
"duration": 247.0,
"customData": {
"trackId": "trk_8f2a",
"autoPlayNext": true,
"queueSourceId": "pl_44d1",
"recommendationSeed": { "windowN": 10 }
}
}
```
Rules:
- `metadataType: 3` = `MUSIC_TRACK` (Cast `MusicTrackMediaMetadata`).
- `contentType` must be the **actual delivered** MIME type. If the server will
transcode for this device class (§6), the sender requests the stream
decision first and sets `contentType` accordingly.
- `streamType` is always `BUFFERED` (we never present live streams).
- Album art URLs also carry a `castToken` because the receiver fetches them
unauthenticated otherwise.
---
## 5. Cast Token (media URL auth)
Per G3, normal bearer tokens never appear in media URLs. Instead the sender
exchanges its session for a **cast token**:
```
POST /api/v1/cast/token
Authorization: Bearer
{ "trackIds": ["trk_8f2a", "trk_77b1"], "includeArt": true }
200 →
{
"tokens": {
"trk_8f2a": { "castToken": "v1.…", "artToken": "v1.…", "expiresAt": "2025-06-01T12:34:56Z" }
}
}
```
Properties (full token format in `docs/05-auth-and-security.md` §6, "URL-scoped
tokens"; this section binds Cast specifics):
- **Scope:** exactly one `trackId` (or one art object), `stream:read` only.
- **TTL:** 6 hours (long enough for an album + pauses; short enough to bound
replay). Refreshable via the §3.3 custom-namespace flow.
- **Stateless verification:** HMAC-signed compact token verified by the server
without a DB hit on every byte-range request; contains
`{userId, trackId, scope, exp}`.
- Tokens are **bound to the user**, so play-history attribution and per-user
transcode policy still resolve correctly server-side.
- Server emits these URLs with `Cache-Control: private` and they are excluded
from access logs' query strings (log scrubbing requirement).
Range requests: the `/stream` endpoint behind a cast token supports HTTP
`Range` identically to authenticated streaming (Chromecast issues ranged GETs
aggressively while buffering and on seek). CORS: the stream and art endpoints
must answer `OPTIONS` and set `Access-Control-Allow-Origin` to the receiver
origin (same host, but the receiver runs in a Cast-specific context — emit the
header unconditionally for `/api/v1/stream` and `/api/v1/art` with
`Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges`).
---
## 6. Format Negotiation & Transcoding for Cast
Chromecast audio support (CAF, current firmware): FLAC (≤ 96 kHz/24-bit on
most devices), AAC-LC/HE-AAC, MP3, Opus & Vorbis in WebM, WAV/LPCM. Notably
**no ALAC, no WMA, no APE, no DSD**, and Ogg-contained Opus/Vorbis support is
inconsistent across device generations — we treat Ogg containers as
unsupported on Cast and rely on transcode.
Decision procedure (sender-side, before building the queue item):
```mermaid
flowchart TD
T[Track codec/container
from track metadata] --> D{In Cast-safe set?
flac, mp3, aac/m4a, wav}
D -- yes --> R["format=raw — direct passthrough,
server proxies ranged bytes from S3/WebDAV"]
D -- no --> X["format=opus_webm&bitrate=192 →
server transcode path (doc 04 §5),
contentType audio/webm"]
R --> Q[Build queue item]
X --> Q
```
The sender calls `GET /api/v1/stream-decision/{trackId}?client=cast` which
returns `{ "format": "raw"|"opus_webm"|"mp3", "contentType": "...",
"estimatedBitrateKbps": n }`, so the codec policy lives **server-side** in one
place (admins can override, e.g. force ≤ 320 kbps MP3 for old Chromecast Audio
units via the `cast.transcode_profile` server setting). Transcoded Cast
streams follow the chunked-transcode + seekability rules of
`docs/04-caching-and-transcoding.md` §5.3 (no `Content-Length`; receiver gets
`duration` from queue-item metadata so the seek bar still renders).
---
## 7. Play-History & Scrobbling During Cast
The **sender** is authoritative for history (the receiver holds no user
credentials). Flow:
1. Receiver emits `SCROBBLE_HINT {event:"started"}` when playback of an item
first reaches 5 s.
2. Receiver emits `SCROBBLE_HINT {event:"completed"}` when position ≥ 50 % of
duration or ≥ 4 minutes (same thresholds as local playback,
`docs/02-data-model.md` §6 `play_history.completed`).
3. Sender posts `POST /api/v1/history` with `playbackContext: "cast"`.
4. If the sender app dies mid-cast (Cast sessions outlive senders), hints are
lost — acceptable v1 limitation, noted in the milestone map. Mitigation
listed for a later milestone: receiver buffers hints and replays them to
the next sender that joins the session.
---
## 8. Sender Implementations
### 8.1 Android (primary)
Stack: **Media3 `CastPlayer` (`androidx.media3:media3-cast`) + Google Cast
SDK (`com.google.android.gms:play-services-cast-framework`)**. Targeted
versions and integration detail are in `docs/08-android-app.md` §7; binding
requirements here:
- A single `Player` abstraction switches between local `ExoPlayer` and
`CastPlayer` via `SessionAvailabilityListener`, transferring queue +
position on route change (both directions: local→cast resumes at position;
cast→local likewise).
- `CastOptionsProvider` supplies the receiver app ID fetched from
`/api/v1/system/cast-config` (cached; falls back to default receiver ID).
- The custom-namespace channel (§3.3) is implemented with
`Cast.CastApi`-level `setMessageReceivedCallbacks` on the
`CastSession` (`com.google.android.gms.cast.framework.CastSession#setMessageReceivedCallbacks`).
- The Android app answers `NEXT_ITEM_REQUEST` by calling
`POST /api/v1/recommendations/next` (doc 07 §8) and then minting a cast
token for the chosen track.
### 8.2 Web client
CAF Sender (`cast_sender.js?loadCastFramework=1`), same message contract.
The web client is a later milestone; this contract is what it codes against.
### 8.3 Session ownership & multi-sender
Cast natively allows multiple senders to join a session. We accept Cast's
default behavior (last write wins on queue mutations). Auto-play
`NEXT_ITEM_REQUEST` is broadcast; **the first `NEXT_ITEM_RESPONSE` for a given
`requestId` wins**, receivers ignore duplicates.
---
## 9. End-to-End Sequence (happy path with auto-play)
```mermaid
sequenceDiagram
participant U as User
participant App as Android App (Sender)
participant FP as FablePool Server
participant CC as Chromecast (Receiver)
participant ST as S3/WebDAV
U->>App: Tap Cast icon, pick device
App->>CC: Launch receiver app (appId from /system/cast-config)
App->>FP: GET /stream-decision/trk_A?client=cast
FP-->>App: {format: raw, contentType: audio/flac}
App->>FP: POST /cast/token {trackIds:[A,B], includeArt:true}
FP-->>App: castTokens for A, B
App->>CC: queueLoad([itemA, itemB], customData.autoPlayNext=true)
CC->>FP: GET /stream/trk_A?castToken=… (Range: bytes=0-)
FP->>ST: ranged GET (S3 GetObject Range / WebDAV partial GET)
ST-->>FP: bytes
FP-->>CC: 206 Partial Content
CC-->>App: MEDIA_STATUS playing
CC->>App: SCROBBLE_HINT(started, A)
App->>FP: POST /history {trackId:A, context:cast}
Note over CC: item A ends, item B (preloaded) starts,
queueRemaining now ≤ 1
CC->>App: NEXT_ITEM_REQUEST {currentItemId:B}
App->>FP: POST /recommendations/next {windowN:user setting}
FP-->>App: {trackId: C, score: …}
App->>FP: POST /cast/token {trackIds:[C]}
FP-->>App: castToken C
App->>CC: NEXT_ITEM_RESPONSE {item C}
CC->>CC: queueInsert(C)
```
---
## 10. Failure Modes & Required Handling
| Failure | Required behavior |
|---|---|
| Server unreachable from Cast device (cert invalid / LAN isolation) | Sender detects first `MEDIA_ERROR` after `LOAD`, shows actionable error: "Your Chromecast can't reach the FablePool server — see HTTPS setup docs." Diagnostic endpoint `GET /api/v1/system/cast-reachability` returns the externally configured base URL so the app can sanity-check it before casting. |
| Cast token expired (paused > 6 h) | Receiver token-refresh flow (§3.4). |
| Sender disconnects mid-session | Playback of the loaded queue continues; auto-play requests time out silently → queue ends naturally. History hints lost (v1, §7). |
| Backend (S3/WebDAV) stalls mid-stream | Server stream proxy applies the retry/resume policy of doc 03 §6.4; if the backend read ultimately fails, server closes the response; Chromecast retries the range once, then errors; receiver skips to next queue item rather than stopping the session. |
| Recommender returns nothing (empty library edge) | `NEXT_ITEM_RESPONSE {item:null}` → queue ends. |
---
## 11. Acceptance Criteria (for the Cast implementation milestone)
1. Cast a FLAC track from the Android app to a Gen-3 Chromecast: direct
passthrough, seek works, art shows.
2. Cast an ALAC track: server transcodes to Opus/WebM, plays, seek works.
3. Enable auto-play with window N=10: queue self-extends via custom
namespace; verified in receiver debug logs.
4. Kill the Android app mid-track: Cast playback continues to queue end.
5. Pause 30 s, seek backwards: receiver issues ranged GETs; no full
re-download (verified via server logs).
6. Default receiver fallback (no custom app ID configured): single track and
static queue play; auto-play is disabled with a UI notice.
7. No long-lived credential ever appears in a media URL (log audit).