Skip to content

Swift ​

OpenWatchParty is a Swift SDK that keeps players in different apps in sync in one room. Each person in the room plays their own copy of the title, and a relay keeps the copies together.

The SDK implements the client behaviour in spec/SDK.md. This page names the rules the SDK follows, and SDK.md explains them in full.

Install ​

With SwiftPM, add the package and the product to Package.swift:

swift
.package(url: "https://github.com/edde746/openwatchparty", from: "1.0.0"),
// target dependency:
.product(name: "OpenWatchParty", package: "openwatchparty"),

In Xcode, choose File → Add Package Dependencies, enter https://github.com/edde746/openwatchparty and add the product OpenWatchParty. SwiftPM takes the version from the git tag.

Requirements ​

The package runs on these platforms:

PlatformMinimum
iOS16
macOS13
tvOS16
watchOS9
visionOS1
LinuxSwift 6 toolchain

It needs Swift tools 6.0 and builds in the Swift 6 language mode. It has no dependencies beyond Foundation, and FoundationNetworking on Linux.

Quick start ​

This example joins a room by its code, or creates one and shares a title as host. It then attaches the player each time the SDK chooses a file, until the party ends. The resolver here knows one file, where yours searches your app's library.

swift
import Foundation
import OpenWatchParty

/// The app's player as the party sees it: read on every tick, commanded by the party.
@MainActor
final class VideoPlayer: PlayerAdapter {
    var positionMs: Int64 = 0
    var durationMs: Int64? = 10_200_000
    var paused = true
    var buffering = false
    var firstFrameShown = true

    func play() async throws { paused = false } // throw when the player refuses to start
    func pause() async throws { paused = true }
    func seek(to positionMs: Int64) async throws { self.positionMs = positionMs }
}

@MainActor
func watchTogether(code: String?) async throws {
    let heat = URL(fileURLWithPath: "/Movies/Heat.mkv")
    let party = try WatchParty<URL>(
        relays: .public(),
        client: "Example/1.0",
        name: "Anna",
        resolver: Resolver { media in
            // The files in the app's library that are this title, best first.
            media.title == "Heat" ? [Candidate(id: "heat", ref: heat, durationMs: 10_200_000)] : []
        }
    )
    // Subscribe before create or join: a stream gets what is published from then on, nothing earlier.
    let states = party.states()
    let events = party.events()
    Task {
        for await event in events {
            if event is PlayBlockedEvent { print("Tap play to start the video") }
        }
    }
    if let code {
        try await party.join(code) // "k7m-q2x" works too
    } else {
        let code = try await party.create()
        print("Room code: \(try formatCode(code))")
        let media = Media(kind: .movie, title: "Heat", durationMs: 10_200_000, year: 1995, ids: ["imdb": "tt0113277"])
        try party.setMedia(media, ref: heat)
    }
    let player = VideoPlayer()
    var opened: URL?
    for await state in states {
        let file = state.choice?.ref
        if let file, file != opened {
            opened = file // open `file` in the player, then:
            party.attachPlayer(player, ref: file)
        }
        if state.connection == .ended { break }
    }
    await party.close().value
}

Relays ​

A Relays object tells your parties which relays exist and which one owns a room code. Create one for the whole app and pass it to every party.

There are two kinds of list:

  • Relays.public() uses the public relays. It starts from the copy of relays.json compiled into the SDK.
  • try Relays.at("https://relay.example.com") uses one self-hosted relay for every room.
MemberWhat it does
Relays.public(httpGet:clock:)Creates a list of the public relays.
Relays.at(_:) throwsCreates a list of one self-hosted relay.
listThe relays in use.
relayFor(_:)Finds the relay that owns a room code.
refresh() async -> BoolFetches the public list now, and returns whether a valid list came back.

The GET of the public list defaults to URLSession, and you may replace it with httpGet:. When refresh() returns false because the source gave no valid list, the list in use stays.

Relays.at throws invalid_message on a bad base URL. Its refresh() does nothing and returns true.

WatchParty ​

A WatchParty is one participant's session. It connects to a relay, keeps the room's state, matches the room's media to the user's own files and drives your player.

Creating a party ​

The throwing initializer is WatchParty<Ref>(relays:client:name:resolver:connect:clock:random:). Ref is the type of the app's ref in candidates, the value your app uses to open a file. It must be Equatable & Sendable, and refs compare with ==.

ParameterTypeWhat it is
relaysRelaysThe app's relay list.
clientStringYour app and its version, as <app>/<version>.
nameStringThe user's display name, 1–32 characters.
resolverResolver<Ref>?Your search for the user's own files. Defaults to nil.
connectWebSocketConnect?Opens the WebSockets. Defaults to urlSessionConnect.
clockany PartyClockLocal time and timers.
randomany RandomNumberGeneratorDrives the spread of reconnects.

The initializer throws a PartyError with the code invalid_message for a bad client or name. Without a resolver, the party never searches, so match is unavailable at once for other participants' media.

State and session ​

MemberWhat it does
private(set) var state: PartyState<Ref>Everything the UI shows, as one value.
states() -> AsyncStream<PartyState<Ref>>A new stream of every state from now on.
events() -> AsyncStream<any PartyEvent>A new stream of every event from now on.
create() async throws -> StringCreates a room, joins it as host and returns its code.
join(_:) async throwsJoins a room by a typed or pasted code, such as k7m-q2x.
leave()Leaves the room.
close() -> Task<Void, Never>Leaves the room and releases everything.
roomPositionMs() -> Int64?The room's position now.

Matching and the player ​

MemberWhat it does
choose(_:) async -> BoolTakes a file the user picked by hand.
acceptMismatch()Plays the offered file, though its duration differs.
declineMismatch()Declines the offered file.
resolveAgain()Searches again for the current media.
attachPlayer(_:ref:)Lets the SDK drive your player, which has the file of ref open.
detachPlayer(_:) -> Task<Void, Never>Stops driving the player when the user leaves it.
playerChanged()Tells the SDK the player's state changed, so it reads it now.

Player and resolver explains how to use the player members.

Playback ​

These commands act on the whole room. Send the user's presses through them, not straight to the player.

MemberWhat it does
play() throwsAsks the room to play.
pause() throwsPauses the room.
seek(to:) throwsMoves the room to a position in the user's own file.
setRate(_:) throwsSets the room's rate, from 0.5 to 2.0.
setOffset(_:) throwsSets your local offset from the room's position.

The offset applies only to your own player. It may be at most 604800000 ms, or 7 days, either way.

Host controls ​

Only the host may use these.

MemberWhat it does
setMedia(_:ref:positionMs:) throwsShares a title, with ref for your own file of it.
clearMedia() throwsStops the media.
setControl(_:) throwsSets who may play, pause, seek and set the rate.
transferHost(_:) throwsMakes another participant the host.
kick(_:) throwsRemoves a participant from the room.
lock(_:) throwsRefuses or allows new joins.
end() throwsEnds the room for everyone.

Party state ​

state is a PartyState. It changes on events, never on every tick.

PartyState fieldWhat it holds
connectionThe session status.
peerIdYour own peer ID.
roomThe last snapshot of the room.
mediaThe current descriptor.
matchHow the room's media matched the user's files.
choiceThe file to open.
mismatchThe file offered while the user decides.
candidatesThe ranked files.
offsetMsYour local offset.
playerThe player state reported to the room.
endReasonWhy the party ended.
meYour own entry among the participants.
isHostWhether you are the host.
canControlWhether you may control playback.

Events ​

Events conform to the protocol PartyEvent.

EventWhen it comes
ActionEventA room change carried an action, such as someone pressing play.
ParticipantJoinedEventSomeone joined the room.
ParticipantLeftEventSomeone left the room.
ErrorEventThe relay refused a command.
ResolveFailedEventThe resolver failed.
PlayBlockedEventThe player refused to start.
EndedEventThe party ended.

Because PartyEvent is a protocol, a switch over an event needs a default branch.

Media descriptors ​

A Media value is a media descriptor: it names a title, and each participant finds their own copy of it. Media, Series and MediaFile are value types, and Media.ext is [String: JSONValue]?.

Player and resolver ​

Your app gives the party two pieces of its own: a player adapter for your player, and a resolver for your library.

The player adapter ​

Implement the @MainActor protocol PlayerAdapter in a class, as the quick start does. Its commands are async throws. A protocol extension gives the last three members their defaults.

MemberWhat it is
positionMsThe current position in your own file.
durationMsThe file's duration, or nil when unknown.
pausedWhether the player is paused.
bufferingWhether the player stalled while trying to play.
firstFrameShownWhether the file is open and shows a frame.
play()Starts playing.
pause()Pauses.
seek(to:)Seeks, and returns once the seek has landed.
canSetRateWhether setRate(_:) works. Defaults to false.
seekAccuracyMsHow far from the target a seek may land, such as your player's keyframe distance. Defaults to 0.
setRate(_:)Changes the speed. Does nothing by default.

Without rate control, the SDK keeps your player in step with seeks alone.

When the player refuses to start, play throws. While the player then stays paused, the party emits PlayBlockedEvent. Ask the user to press play on the player itself.

Attaching and detaching ​

Call attachPlayer(_:ref:) each time you open a file.

close() and detachPlayer() return completions. Await them with .value, as in await party.close().value, before you dispose of the player.

The resolver ​

The resolver finds the user's own copy of the room's title. Resolver<Ref> is a struct built from a closure, @MainActor (Media) async throws -> [Candidate<Ref>], which returns candidates best first. A throw counts as no candidates.

Candidate<Ref> holds id, ref, durationMs, sizeBytes, oshash, name and an optional probe. The probe learns the duration. The SDK cancels the probe's task when it no longer needs the answer, and a failed probe counts as no duration.

Helpers ​

HelperWhat it does
formatCode(_:) throwsFormats a room code for display, such as K7M-Q2X.
sameTitle(_:_:)Tells whether two descriptors name the same title.
checkMedia(_:)Checks a descriptor before you send it, and returns the first broken rule or nil.
parseMedia(_:) throwsDecodes a descriptor with the checks a receiver makes.

Threading ​

The party is @MainActor, and it calls your adapter and resolver on the main actor.

Call states() and events() before create or join. Each call returns its own AsyncStream, which gets every change or event in order. Nothing is replayed, and both streams finish on close().

Cancelling the task that awaits create or join cancels the attempt and throws CancellationError.

Errors ​

The SDK reports failures as a PartyError. Show the user your own text for each code, with a generic fallback, because message is meant for logs only.

FieldWhat it holds
codeA PartyError.Code value, such as .roomNotFound with the raw value "room_not_found".
messageText for logs only.
reThe type of the refused message.
underlyingThe error underneath, when there is one.

PartyError.Code names these codes. Other codes arrive as raw Code values.

NameCode
invalidCodeinvalid_code
unreachableunreachable
cancelledcancelled
notInRoomnot_in_room
alreadyInRoomalready_in_room
forbiddenforbidden
invalidMessageinvalid_message
roomNotFoundroom_not_found
roomFullroom_full
roomLockedroom_locked
rateLimitedrate_limited
relayFullrelay_full
resumeFailedresume_failed
clientBlockedclient_blocked

Calling create or join after close() throws PartyClosedError. It signals a programming error, and it is not a PartyError.

ResolveFailedEvent carries a TimeoutError when the resolver took longer than 30 seconds.

Build and test ​

Run these commands at the repository root:

sh
swift build
swift test
swift test --filter VectorTests

swift test includes IntegrationTests, which runs whole parties against a relay. OWP_RELAY_BIN names the relay binary. Without it, the suite builds relay/ with Go, so Go must be installed.

On iOS and tvOS simulators, xcodebuild test runs the suites that need no processes or local server. IntegrationTests and DefaultHTTPGetTests build on macOS and Linux only.

sh
xcodebuild test -scheme OpenWatchParty -destination 'platform=iOS Simulator,name=iPhone 17'
xcodebuild test -scheme OpenWatchParty -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'

watchOS and visionOS build with these commands:

sh
xcodebuild build -scheme OpenWatchParty -destination 'generic/platform=watchOS Simulator'
xcodebuild build -scheme OpenWatchParty -destination 'generic/platform=visionOS Simulator'

CI is .github/workflows/sdk-swift.yml, with a macOS job and a Linux job in swift:6.4.