Two bugs reported together, both surfaced by actually trying this for
real.
Casting: "the .m3u8 needs to be passed instead of a raw [url]". The
master playlist itself was fine - the initial master.m3u8?castToken=...
request already carries the token. What broke immediately after: every
URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy, and that rewrite never carried the castToken forward. A browser
never noticed (it's still sending its cookie regardless of the URL), but
a Chromecast has no cookie to fall back on, so every segment/variant-
playlist fetch after the first one 401'd, breaking playback moments
after the receiver appeared to connect. Fixed by threading the same
castToken into every rewritten line (rewritePlaylist(text, routeKey,
castToken?), sourced from req.query.castToken right inside
proxyResponse). The self-hosted burn-in path had the identical gap for
a different reason - ffmpeg's own bare relative segment filenames drop
any query string when resolved against a URL that had one, per ordinary
URL-resolution rules - fixed the same way with a new pure
appendCastTokenToSegments(text, castToken), extracted specifically so
it's unit-testable without a real ffmpeg session.
Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix
from two commits ago hadn't actually landed for anyone. Real bug, not a
browser quirk this time: becomeHost's 3-second heartbeat got wired to
the same onDriverStateChanged function real discrete play/pause/seeked
events use, which also re-arms the auto-hide countdown - correct for a
genuine event, wrong for a heartbeat that fires unconditionally every
3s regardless of any real change. With the hide delay also at 3000ms,
every heartbeat tick cancelled and restarted the countdown on the exact
cadence it needed to run uninterrupted to ever complete - so for a
host, on any platform, the bar could never actually finish hiding.
Fixed by giving the heartbeat its own function, reportHeartbeat - same
state sync, without the scheduleControlsHide call.
Verified: full typecheck/test/build (122/122 server tests, including
new coverage for rewritePlaylist's token propagation - had none at all
before this - and appendCastTokenToSegments). Confirmed the heartbeat
fix directly: 3 simulated heartbeat ticks call the hide-timer's reset
zero times, one simulated real event still calls it once. Not verified:
a real Chromecast fetching real rewritten segment URLs - no device
reachable from this sandbox.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>