Skill v1.0.1
currentAutomated scan100/100+3 new
version: "1.0.1" name: chromecast-casting description: Google Cast Protocol v2, message framing, debugging, and test scripts. Use when working on Chromecast casting, debugging Cast protocol issues, or implementing Cast commands.
Chromecast Implementation
NullPlayer implements Google Cast Protocol v2 for casting audio and video to Chromecast devices.
Key Files
| File | Purpose | |
|---|---|---|
Casting/CastProtocol.swift | Protocol implementation, message encoding/decoding, session controller | |
Casting/ChromecastManager.swift | Device discovery (mDNS), connection management, public API | |
Casting/CastManager.swift | High-level casting coordinator for all device types | |
scripts/test_chromecast.swift | Standalone test script for debugging |
Discovery
Chromecast devices are discovered via mDNS (Bonjour):
- Service type:
_googlecast._tcp - Domain:
local. - Uses
NWBrowserfor discovery - Resolves to IP:port (default port 8009)
- Device identity must use the stable Cast TXT
idrecord when available, falling back to the Bonjour service instance name. Do not key Chromecast devices by resolved IP/port: different Macs can resolve the same service via different address forms, which creates duplicate menu entries during refresh because refresh keeps existing devices visible.
Protocol Overview
Google Cast Protocol v2:
- TLS Connection — Port 8009, self-signed certificate (must accept)
- Protobuf Framing — 4-byte big-endian length prefix + protobuf message
- Namespaces — Different message types use different namespace URNs
- Session Flow:
- CONNECT to
receiver-0 - Start heartbeat (PING every 5 seconds)
- LAUNCH Default Media Receiver (appId:
CC1AD845) - Wait for RECEIVER_STATUS with
transportId - CONNECT to the transportId
- LOAD media with URL and metadata
Message Namespaces
| Namespace | Purpose | |
|---|---|---|
urn:x-cast:com.google.cast.tp.connection | Connection management (CONNECT, CLOSE) | |
urn:x-cast:com.google.cast.tp.heartbeat | Keep-alive (PING, PONG) | |
urn:x-cast:com.google.cast.receiver | App lifecycle (LAUNCH, STOP, GET_STATUS) | |
urn:x-cast:com.google.cast.media | Media control (LOAD, PLAY, PAUSE, SEEK, STOP) |
CastSessionController
The CastSessionController class manages a single Chromecast session:
- Thread-safe with
NSLockfor state access - Uses
NWConnectionfor TLS socket - Completion-based async API (bridged to async/await in ChromecastManager)
- Implements
CastSessionControllerDelegateprotocol for status updates
Position Synchronization
Position tracking uses CastSession fields, not AudioEngine-local variables:
activeSession.position— last known position reported by ChromecastactiveSession.playbackStartDate—Date()when playback last transitioned to PLAYING;nilwhen paused or bufferingCastManager.currentTimeinterpolates:session.position + Date().timeIntervalSince(session.playbackStartDate)
Status polling (CastSessionController.startStatusPolling()) polls GET_STATUS every second. Each MEDIA_STATUS response updates session.position and resets session.playbackStartDate = Date() when PLAYING, or sets playbackStartDate = nil when not playing.
This handles buffering: when playerState == BUFFERING, playbackStartDate is cleared so the interpolation freezes at session.position until PLAYING resumes.
Both video and audio casts use the same session-based tracking. The audio branch in handleChromecastMediaStatusUpdate mirrors the video branch exactly.
Stopping Playback and Closing the App
When the user stops casting Chromecast:
ChromecastManager.stop()sendsSTOPto the media session — stops media but leaves the Default Media Receiver app running on the TV.ChromecastManager.stopApp()sendsSTOPto `receiver-0` (receiver namespace) — closes the Default Media Receiver app entirely, triggering HDMI-CEC to dismiss the cast overlay from the TV screen.- A 200 ms sleep between
stop()anddisconnect()ensures STOP bytes flush before the socket is cancelled. Without this,connection.cancel()races with the outbound STOP bytes and the Chromecast never processes the command, leaving the video paused on screen.
// In ChromecastManager.stop():sessionController?.stop() // STOP to media sessionsessionController?.stopApp() // STOP to receiver-0// In CastManager.stopCasting():chromecastManager.stop()try? await Task.sleep(nanoseconds: 200_000_000) // 200 ms flush delaychromecastManager.disconnect()
Pre-Load IDLE Guard (chromecastHasSeenActivePlayback)
Chromecast sends an initial IDLE status immediately after a new media session is created, before the LOAD command is processed. Without a guard, this IDLE would be interpreted as "media ended" and trigger stopCasting().
CastManager maintains:
private var chromecastHasSeenActivePlayback: Bool = false
Lifecycle:
- Reset to
falseat every cast start — incast()beforechromecastManager.cast(), incastNewTrack()beforechromecastManager.cast(), and instopCasting(). - Set to
trueon firstPLAYINGorBUFFERINGstatus for both video and audio casts. - IDLE is only treated as "media ended" when
chromecastHasSeenActivePlayback == true. - IDLE with
chromecastHasSeenActivePlayback == falseis logged and ignored (pre-load IDLE).
Critical: The reset must happen before calling chromecastManager.cast(), not after. The Chromecast sends its initial IDLE as soon as the new media session is created (during LOAD), which can arrive before any MainActor.run block scheduled after the cast call executes.
// WRONG — reset races with arriving IDLE:try await chromecastManager.cast(url: url, metadata: metadata)await MainActor.run { self.chromecastHasSeenActivePlayback = false }// CORRECT — reset before LOAD is issued:chromecastHasSeenActivePlayback = falsetry await chromecastManager.cast(url: url, metadata: metadata)
This applies to both CastManager.cast() (full connect+cast path) and CastManager.castNewTrack() (reuse-existing-session path).
.loaded CastState
CastState has a .loaded case meaning "LOAD acknowledged by the receiver, awaiting first status update":
- Chromecast video/audio cast:
activeSession.state = .loadedimmediately afterLOADis sent. - On first
PLAYINGorBUFFERINGstatus:activeSession.state = .casting. - IDLE arriving while
chromecastHasSeenActivePlayback == false→ ignored (pre-load IDLE). - IDLE arriving while
chromecastHasSeenActivePlayback == true→ media ended, triggersstopCasting(). - DLNA/UPnP (no status updates):
activeSession.state = .castingimmediately after LOAD.
UI timers in CastManager and VideoPlayerWindowController skip updates while activeSession.state == .loaded to prevent showing stale time before the receiver has confirmed position.
Notification Thread Safety
ChromecastManager and UPnPManager are not @MainActor. Their callbacks (NWConnection receive, async HTTP tasks) run on background threads. Never call `NotificationCenter.default.post` directly from these classes — observers that touch AppKit will crash with NSWindow geometry should only be modified on the main thread.
Use CastManager.postNotificationOnMain(name:userInfo:) for all cast notifications from these classes:
// WRONG — may be on background thread:NotificationCenter.default.post(name: CastManager.sessionDidChangeNotification, object: nil)// CORRECT — guaranteed to arrive on main:CastManager.postNotificationOnMain(name: CastManager.sessionDidChangeNotification)
postNotificationOnMain dispatches asynchronously if called off main, synchronously if already on main. Posts inside await MainActor.run {} or DispatchQueue.main.async {} blocks are already safe and can use NotificationCenter.default.post directly.
Cast Architecture — Single Owner Model
CastManager.activeSession is the single authoritative description of the active cast. Every other component reads from activeSession and subscribes to CastManager.sessionDidChangeNotification for updates.
currentCast Enum
public enum CurrentCast: Sendable { case none; case audio; case video }
CastManager.currentCast derives from activeSession:
.none—activeSession == nil.audio—activeSession.metadata?.mediaType == .audio(or nil).video—activeSession.metadata?.mediaType == .video
Use currentCast for the overall audio/video branch. Video controls still need to account for both initiation paths: the player-window path uses VideoPlayerWindowController.isCastingVideo, while library/menu casts may have no player window and should route through CastManager.shared.isVideoCasting / currentCast == .video.
Inflight Task Serialization
cast() serializes concurrent calls via a private inflight: Task<Void, Error>? chain:
let task = Task { @MainActor intry? await inflight?.value // wait for any in-flight cast first// ... actual cast logic}inflight = tasktry await task.value
Media Type Teardown
If cast() is called while a session is active and the new media type differs (e.g., audio → video), stopCastingAndAwaitTeardown() runs before the new LOAD. This ensures the prior session is fully stopped and LocalMediaServer files are unregistered before the new session starts.
Window Controller Ownership (didInitiateCast)
VideoPlayerWindowController has a private didInitiateCast: Bool flag, true only when the cast was started from the player window's own cast button. windowWillClose only stops the cast if case .video = CastManager.shared.currentCast, didInitiateCast. This allows closing an unrelated player window without interrupting a library-menu cast.
Video-to-Audio Cast Transition (Auto-Close Video Player)
When castNewTrack or cast() successfully starts an audio cast while the video player window is open:
- `castNewTrack` path:
isCastingVideois stilltrueat this point.WindowManager.closeVideoPlayerForCastTransition()checksisCastingVideoand callsVideoPlayerWindowController.closeForCastTransition(). - `cast()` path:
stopCastingAndAwaitTeardown()already ran, which postssessionDidChangeNotificationwithcurrentCast == .none, triggeringhandleCastSessionChange()which clearsisCastingVideo.WindowManager.closeVideoPlayerForCastTransition()falls back to checkingwindow?.isVisible.
closeForCastTransition() closes the video player without calling CastManager.stopCasting(). It sets isClosing = true before calling close() so windowWillClose skips its cleanup block entirely.
Video Cast Routing
Video playback routes to casting only when casting is already active:
WindowManagervideo entry points callrouteToVideoCastIfNeeded(...)before creating/loading the local player.- If
case .video = CastManager.shared.currentCast, the next video is cast to the active session's device. - If no video cast is active, videos load into the local player even when
preferredVideoCastDeviceIDis set.
preferredVideoCastDeviceID is durable UI preference state, not playback ownership. It may select the initial device in an explicit cast menu action, but it must never be used by ordinary video entry points to auto-cast after relaunch or after a previous cast. This invariant applies equally to local files, HTTP streams, Plex, Jellyfin, Emby, and mixed playlists. Keeping the local player window as the default is also what preserves video metadata and an accessible stop-casting control.
Mixed-Type Playlists (castNewTrack)
castNewTrack(track:) dispatches by track.mediaType:
- Video tracks →
castVideoURL(...)(requires an active video-capable cast session) - Audio tracks → existing audio cast path
Do not assume all playlist tracks are audio. Video items can appear in audio playlists.
Audio Is Separate
Audio casting remains explicit: if audio is not already casting, playback stays local until the user picks a cast device. preferredVideoCastDeviceID is never used for audio.
CoreAudio Route Churn
Chromecast and other cast sessions can still trigger AVAudioEngineConfigurationChange notifications in local AudioEngine during receiver, room, Zoom, AirPlay-style, or Wi-Fi route changes. Local graph rebuilds must be deferred while CastManager.shared.activeSession exists or AudioEngine.isAnyCastingActive is true. See skills/audio-system/audio-pipelines.md — Cast Route-Change Safety.
Media Loading
LOAD Message Format
{"type": "LOAD","media": {"contentId": "http://...","contentType": "video/mp4","streamType": "BUFFERED","metadata": {"type": 0,"metadataType": 0,"title": "Movie Title","subtitle": "Artist/Description"}},"autoplay": true,"requestId": 1}
Playback Control
After successful LOAD, use the transportId for media commands:
| Command | Payload | |
|---|---|---|
| PLAY | {"type":"PLAY","mediaSessionId":1,"requestId":N} | |
| PAUSE | {"type":"PAUSE","mediaSessionId":1,"requestId":N} | |
| STOP | {"type":"STOP","mediaSessionId":1,"requestId":N} | |
| SEEK | {"type":"SEEK","mediaSessionId":1,"currentTime":30.5,"requestId":N} | |
| GET_STATUS | {"type":"GET_STATUS","requestId":N} |
To close the Default Media Receiver app (dismiss from TV screen), send STOP to receiver-0 on the receiver namespace:
{"type":"STOP","requestId":N} // to: "receiver-0", namespace: urn:x-cast:com.google.cast.receiver
MEDIA_STATUS Response
{"type": "MEDIA_STATUS","status": [{"mediaSessionId": 1,"currentTime": 42.5,"playerState": "PLAYING","media": { "duration": 180.0 }}],"requestId": N}
Volume Control
{"type":"SET_VOLUME","volume":{"level":0.5},"requestId":N}{"type":"SET_VOLUME","volume":{"muted":true},"requestId":N}
Serving Local Files (LocalMediaServer) — Stream, Never Buffer
LocalMediaServer serves registered local files over HTTP for cast devices. Never load a whole file into memory to serve it. A cast device (Chromecast especially) opens playback with Range: bytes=0-, i.e. it asks for the entire file, and it also issues plain full-file GETs. Both the range handler and the full-file handler must stream.
- Serving with
Data(contentsOf:)(full file) orFileHandle.readData(ofLength: Int(length))(a
bytes=0- range spans the whole file) allocates the entire media in RAM. For a multi-GB movie the OS OOM-kills the process with no crash log — it looks like the app silently vanishes the instant the cast starts. Small files fit in RAM, so the bug only shows on large media.
- Both handlers stream via
FileByteStream(aAsyncBufferedSequenceofUInt8backed by a
FileHandle, 256 KB chunks) wrapped in HTTPBodySequence(from:count:). This mirrors the URLSessionByteStream used for the Subsonic/Jellyfin proxy path. Memory stays flat regardless of file size, and Content-Length/Content-Range still describe the exact byte count so seeking works.
- Open and initial seek must succeed before returning
200/206; otherwise return500. Do not
suppress those errors with try?, because a response that advertises Content-Length and then yields no bytes leaves the cast client waiting for a body that will never arrive. Subsequent read errors propagate through the body sequence so the connection fails instead of ending as a false successful short response.
Key Implementation Gotcha: Data Slice Indexing
Swift Data slices maintain original indices. When processing a receive buffer:
// WRONG:let byte = buffer[0]let slice = buffer[4..<total]// CORRECT:let byte = buffer[buffer.startIndex]let startIdx = buffer.startIndex + 4let endIdx = buffer.startIndex + totallet slice = buffer[startIdx..<endIdx]
Protocol Debugging
Standalone Test Script
swift scripts/test_chromecast.swift
Common Issues
| Symptom | Cause | Fix | |
|---|---|---|---|
| Silent crash on receive | Data slice indexing | Use startIndex explicitly | |
| TLS connection fails | Certificate rejection | Accept self-signed in verify block | |
| No devices found | mDNS not working | Check network, firewall | |
| Second cast fails with immediate IDLE teardown | chromecastHasSeenActivePlayback not reset | Reset flag before calling chromecastManager.cast(), also in stopCasting() | |
| Audio cast play controls do nothing | Session still in .loaded state | Use currentCast == .audio not isCasting to detect audio | |
| Seek bar progresses while paused | playbackStartDate not cleared on pause | Set playbackStartDate = nil when not PLAYING | |
NSWindow geometry should only be modified on the main thread crash | Posting cast notification directly from ChromecastManager or UPnPManager off-main | Use CastManager.postNotificationOnMain(name:) instead of NotificationCenter.default.post | |
| Stop leaves video paused on TV | STOP delivered to media but app still running | Call stopApp() after stop(); add 200 ms delay before disconnect() | |
| Stop command not delivered | Socket closed before bytes flush | Sleep 200 ms between stop() and disconnect() | |
clearVideoTrackInfo() not called | wasVideoCast captured after activeSession set to nil | Capture wasVideoCast = currentCast == .video before disconnect | |
| Video player stays open after switching to audio cast | Not calling closeForCastTransition() | Call WindowManager.closeVideoPlayerForCastTransition() after audio cast succeeds | |
| Timer drifts during buffering | Not pausing interpolation on BUFFERING | Set playbackStartDate = nil on BUFFERING; resume on PLAYING | |
| Controls stop working | CLOSE message received | Check castSessionDidClose() delegate callback | |
| Video opens on Chromecast with no player window | Persisted preferredVideoCastDeviceID was treated as an automatic route | Route only when currentCast == .video with an active session; keep the preference for explicit cast-menu defaults |
References
- Google Cast SDK Documentation
- OpenCastSwift - Reference implementation
- node-castv2 - Node.js implementation