creaturesurvive/swiftcast
A pure-Swift Google Cast (Chromecast) sender for iOS, iPadOS, macOS, tvOS and visionOS.
Features
- Discovery of devices and speaker groups over Bonjour (
CastDiscovery, observableCastDeviceBrowser). - Connection management: TLS, framing, virtual connections, heartbeat with dead-peer detection,
request/response correlation with timeouts and cancellation.
- Receiver control: launch, join and stop apps, device volume and mute, app availability.
- Media control: load, queue, play/pause/stop/seek, playback rate, stream volume, text/audio
track selection with caption styling, queue navigation and editing, HLS segment formats, live streams, customData, and status streaming with position interpolation.
- Custom receivers: send and receive messages on any namespace. This covers Jellyfin, your own
receiver apps, and anything else.
CastSession: an@Observablelayer for SwiftUI that joins running sessions and reconnects
automatically with backoff.
- SwiftUI views:
CastButtonandCastDevicePicker. castctl: a command-line controller for scripting and debugging.- Swift 6 language mode with strict concurrency throughout. Unknown protocol values decode as
.unknown(_) instead of failing.
Installation
Add SwiftCast to your Package.swift:
dependencies: [
.package(url: "https://github.com/CreatureSurvive/SwiftCast.git", from: "1.0.1"),
],
targets: [
.target(name: "MyApp", dependencies: ["SwiftCast"]),
]Or in Xcode, choose File › Add Package Dependencies… and enter https://github.com/CreatureSurvive/SwiftCast.
Requirements
| Platform | Minimum | | --- | --- | | iOS | 17.0 | | macOS | 14.0 | | tvOS | 17.0 | | visionOS | 1.0 |
Swift 6.1 (Xcode 16.4) or later, in Swift 6 language mode. No third-party dependencies.
Info.plist (iOS, tvOS, visionOS)
Local network access has to be declared, or discovery and connections fail silently:
<key>NSLocalNetworkUsageDescription</key>
<string>Find and control Cast devices on your network.</string>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
</array>Usage
Discovery
// One-shot scan
let devices = await CastDiscovery.scan(for: .seconds(3))
// Continuous
for await devices in CastDiscovery.devices() {
print(devices.map(\.name))
}
// SwiftUI
@State private var browser = CastDeviceBrowser()
List(browser.devices) { Text($0.name) }
.task { await browser.run() }CastDevice(host:port:) connects to a known address without discovery.
SwiftUI with CastSession
struct PlayerView: View {
@State private var cast = CastSession() // Default Media Receiver
var body: some View {
VStack {
TimelineView(.periodic(from: .now, by: 0.5)) { context in
ProgressView(value: cast.estimatedTime(at: context.date) ?? 0,
total: cast.duration ?? 1)
}
Button(cast.isPlaying ? "Pause" : "Play") {
Task { try await cast.togglePlayPause() }
}
}
.toolbar { CastButton(session: cast) }
}
func castMovie() async throws {
try await cast.load(MediaInformation(
url: movieURL,
contentType: "video/mp4",
metadata: .movie(title: "Movie", images: [CastImage(url: posterURL)]),
tracks: [.webVTTSubtitles(id: 1, url: subtitlesURL, name: "English", language: "en")]
), activeTrackIDs: [1])
}
}CastSession publishes connectionState, receiverStatus, mediaStatus, volume, isPlaying and duration. It reconnects after network blips and re-attaches to the running app.
Media
let media = try await client.launchMediaReceiver() // joins if already running
try await media.load(info, startTime: 120)
try await media.seek(to: 300, resumeState: .playbackStart)
try await media.setPlaybackRate(1.5)
try await media.setActiveTracks([2], textTrackStyle: TextTrackStyle(edgeType: .dropShadow, fontScale: 1.3))
for await status in await media.statusUpdates() {
print(status?.playerState, status?.currentTime)
}Queues:
try await media.loadQueue(tracks.map { QueueItem(media: $0) }, startIndex: 0, repeatMode: .all)
try await media.next()HLS from a media server such as Jellyfin or Plex:
var info = MediaInformation(url: hlsURL, contentType: "application/x-mpegurl")
info.hlsSegmentFormat = .fmp4
info.hlsVideoSegmentFormat = .fmp4Custom receiver apps and namespaces
let app = try await client.receiver.launch(CastAppID("ABCD1234"))
try await client.openVirtualConnection(to: app.transportId)
let namespace = CastNamespace("urn:x-cast:com.example.player")
let messages = client.messages(in: namespace)
let reply = try await client.request(["type": "HELLO"] as JSONValue, namespace: namespace, to: app.transportId)request correlates replies by requestId. Use send for fire-and-forget messages.
Errors
Every failure surfaces as a CastError. Standard Cast error replies (LAUNCH_ERROR, LOAD_FAILED, INVALID_REQUEST, INVALID_PLAYER_STATE, …) are mapped to typed cases.
`castctl`
swift run castctl scan
swift run castctl status 192.168.1.20
swift run castctl watch 192.168.1.20 30
swift run castctl play 192.168.1.20 https://example.com/video.mp4 video/mp4
swift run castctl pause|resume|stop 192.168.1.20
swift run castctl volume 192.168.1.20 0.3Architecture
| Layer | Type | Responsibility | | --- | --- | --- | | Wire | CastMessage, FrameDecoder | Dependency-free protobuf codec and length-prefixed framing | | Transport | CastTransport, NetworkTransport | TLS byte stream (injectable for tests) | | Protocol | CastClient (actor) | Virtual connections, heartbeat, routing, request correlation | | Controllers | ReceiverController, MediaController (actor) | Typed receiver and media APIs | | UI | CastSession, CastButton, CastDevicePicker | Observable state, reconnection, SwiftUI |
Testing
swift test runs the suite against an in-memory mock receiver. It covers wire-format conformance (verified against reference protobuf bytes), framing under arbitrary chunking, request correlation, timeouts, cancellation, heartbeat failure detection, reconnection, and session lifecycle. The suite is clean under Thread Sanitizer.
castctl was verified against an onn. 4K Streaming Box (Google TV): discovery, launch, HLS load, seek, pause, resume, volume, and control from separate connections while playback continues.
Limitations
- SwiftCast is a sender, not a receiver.
- It doesn't do screen mirroring or Cast Connect (Android TV receiver handoff credentials beyond
credentials in LOAD).
- It doesn't perform device authentication, which is optional for senders. Certificates are not
validated because Cast devices present self-signed certificates.
Changelog
See CHANGELOG.md. Releases follow Semantic Versioning.
Contributing
Issues and pull requests are welcome. Please run swift test before opening a pull request, and add tests for new behavior.
License
Available under the MIT license. See LICENSE for details.
Google Cast and Chromecast are trademarks of Google LLC. This project is not affiliated with Google.
Package Metadata
Repository: creaturesurvive/swiftcast
Default branch: main
README: README.md