Synchronizing custom video playback with SharePlay
At a glance
| Item | Summary |
|---|---|
| Purpose | Keep a custom AVSampleBufferVideoRenderer pipeline in sync across SharePlay participants and show it as a RealityKit VideoMaterial on an animated blob in a shared immersive space. |
| App architecture | SwiftUI scenes read one environment-injected AppModel. That model holds a PlaybackSessionController for the app’s lifetime. For each GroupSession, the controller builds a fresh pair: a PlaybackCoordinator for outbound and inbound SharePlay commands, and a PlaybackRenderer that applies those commands to a local synchronizer. |
| Main patterns | SwiftUI environment injection; observable state owner; playback facade; delegate-to-async adapter; actor isolation; sample-buffer video bridge |
| Project style | One visionOS app target. Folders are split by responsibility: Views/, GroupActivities/, PlaybackEngine/, Extension/. |
| Execution model | The target defaults to main-actor isolation. Long-lived loops run in stored Tasks owned by @MainActor classes. One actor (VideoSamplePump) owns the decode-and-enqueue task. The time observer is scheduled on .main and asserts main-actor isolation. |
| State/event model | Swift Observation (@Observable) for UI state. AsyncStream carries coordinator commands and pump status. GroupSession.sessions() and session.$state.values are consumed with for await. |
| Key frameworks/packages | GroupActivities, AVFoundation, RealityKit, SwiftUI, Combine (only for $state.values), CoreTransferable, CoreMedia, os. No package manifest. |
Project structure
SyncCustomVideoPlaybackWithSharePlay/
├── SyncCustomVideoPlaybackWithSharePlay.xcodeproj/
└── SyncCustomVideoPlaybackWithSharePlay/
├── SyncCustomVideoPlaybackWithSharePlayApp.swift // scenes + injection
├── AppModel.swift // immersive state + session owner
├── GroupActivities/
│ ├── SyncCustomVideoPlaybackActivity.swift // GroupActivity value
│ └── PlaybackSessionController.swift // session lifecycle, UI facade
├── PlaybackEngine/
│ ├── PlaybackCoordinator.swift // AV coordinator + delegate → stream
│ ├── PlaybackRenderer.swift // synchronizer, VideoMaterial, pump actor
│ └── VideoSampleSource.swift // asset, duration, reader factory
├── Views/
│ ├── ContentView.swift / ToggleImmersiveSpaceButton.swift
│ ├── ImmersiveView.swift // RealityView blob + attachment
│ └── PlaybackPanelView.swift // transport controls
├── Extension/ Logger.swift, MeshResource+Blob.swift
└── SyncCustomVideoPlaybackWithSharePlay.entitlements
Structure observations
- The folders follow the data flow.
GroupActivities/holds SharePlay session concerns.PlaybackEngine/holds the media pipeline, which knows nothing about views.Views/reads state and sends commands back through one controller. PlaybackRenderer.swiftholds two types: the@MainActorrenderer and theVideoSamplePumpactor it owns (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:14,SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:167). The pump is an implementation detail of rendering and lives in the same file.- Extensions use the
Type+Featurefile name (MeshResource+Blob.swift).Logger.swiftcentralizes the logging subsystem so the subsystem string “can’t drift” (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Extension/Logger.swift:11). - The target opts into
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActorwith Swift 6 (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.xcodeproj/project.pbxproj:180). This is why the source spells outnonisolatedexceptions rather than@MainActoreverywhere.
Overall architecture
flowchart LR
App["SyncCustomVideoPlaybackWithSharePlayApp"] --> Model["AppModel"]
Model --> Controller["PlaybackSessionController"]
Views["ContentView / PlaybackPanelView / ImmersiveView"] -->|play, pause, seek, startSharePlay| Controller
Controller -->|for await sessions()| GS["GroupSession"]
Controller --> Coord["PlaybackCoordinator"]
Coord -->|AsyncStream of Command| Renderer["PlaybackRenderer"]
Renderer --> Pump["VideoSamplePump actor"]
Pump --> Source["VideoSampleSource"]
Renderer -->|VideoMaterial| Views
Reference code
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:123 — makePlayback(videoName:videoExtension:) wires one renderer to one coordinator
private func makePlayback(videoName: String, videoExtension: String) async
-> (renderer: PlaybackRenderer, coordinator: PlaybackCoordinator)? {
guard let url = Bundle.main.url(forResource: videoName, withExtension: videoExtension) else { return nil }
// Create new render and coordination pipelines.
let newSampleSource = VideoSampleSource(url: url)
let newRenderer = await PlaybackRenderer(sampleSource: newSampleSource)
let newCoordinator = PlaybackCoordinator()
// Wire commands of the coordinator to the renderer.
newRenderer.drain(commands: newCoordinator.commands)
newRenderer.pauseCoordinatorOnEnd = { [weak newCoordinator] in newCoordinator?.coordinatePause() }
return (newRenderer, newCoordinator)
}Interpretation
Observation: the sample has two pipelines with one-way links between them. Local intent goes out through PlaybackCoordinator.coordinatePlay/Pause/Seek, and nothing touches the synchronizer on that path. The group’s decision comes back as a Command on an AsyncStream, and only PlaybackRenderer applies it. PlaybackSessionController is the composition point. It creates both halves per session and connects them with exactly two links: the command stream, and a weakly captured end-of-media callback.
Inference: routing even a local tap through the group coordinator means every participant, including the one who tapped, applies the same timed command. That keeps playback consistent and avoids a separate “local fast path”.
Ownership and state
classDiagram
SyncCustomVideoPlaybackWithSharePlayApp *-- AppModel : @State
AppModel *-- PlaybackSessionController : let session
PlaybackSessionController *-- PlaybackRenderer : private(set), per session
PlaybackSessionController *-- PlaybackCoordinator : private, per session
PlaybackSessionController o-- GroupSession : private(set), received
PlaybackCoordinator *-- PlaybackControlDelegate : strong let
PlaybackRenderer *-- VideoSamplePump : let pump
VideoSamplePump o-- VideoSampleSource : injected
PlaybackPanelView --> PlaybackSessionController : let session (init-injected)
ImmersiveView --> AppModel : @Environment
Ownership evidence
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/AppModel.swift:10 — AppModel
@MainActor
@Observable
class AppModel {
let immersiveSpaceID = "ImmersiveSpace"
// ...
var immersiveSpaceState = ImmersiveSpaceState.closed
// The SharePlay listener for the app's lifetime.
let session = PlaybackSessionController()
}SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:117 — resetSession() is the single teardown point
private func resetSession() {
groupSession = nil
playbackRenderer = nil
playbackCoordinator = nil
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
SyncCustomVideoPlaybackWithSharePlayApp |
AppModel |
Creates in @State and injects with .environment (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlayApp.swift:14, :20, :28) |
The immersive scene’s onAppear/onDisappear set .open/.closed. Buttons set .inTransition. |
AppModel |
PlaybackSessionController |
Creates as an immutable let for the app’s lifetime (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/AppModel.swift:22) |
None. The reference never changes. |
PlaybackSessionController |
playbackRenderer, groupSession |
private(set) stored state. Replaced for each received session and cleared on invalidation (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:21, :47, :76, :86) |
PlaybackSessionController only |
PlaybackSessionController |
playbackCoordinator, listenTask, stateMonitorTask |
private. Created and cancelled by the controller (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:22, :49, :51) |
PlaybackSessionController only |
PlaybackCoordinator |
AVDelegatingPlaybackCoordinator, PlaybackControlDelegate, stream continuation |
Creates all three in init. Keeps a strong let delegate (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:19-30) |
Immutable after init |
PlaybackRenderer |
VideoMaterial, AVSampleBufferRenderSynchronizer, VideoSamplePump |
Creates in init (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:35-42) |
Playback state (currentSeconds, isPlaying, renderStatus) is private(set) |
VideoSamplePump |
renderTask |
Actor-isolated private var (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:172) |
Pump actor only |
ImmersiveView |
PlaybackPanelView |
Passes appModel.session as a plain let through ViewAttachmentComponent (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift:68-70) |
Panel calls the controller’s methods only |
Notes:
- The renderer does not own the coordinator.
pauseCoordinatorOnEndcaptures it with[weak newCoordinator](SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:133), so clearingplaybackCoordinatorinresetSession()is enough to release it. - Teardown chain (observation):
resetSession()releases the coordinator. Itsisolated deinitcallscontinuation.finish()(SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:36-38). That ends the renderer’sfor await command in commandsloop (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:81). The renderer’s ownisolated deinitremoves the time observer and cancelsdrainTaskandstatusTask(SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:57-63). - Inference:
PlaybackCoordinatorkeeps the delegate in a strongletbecause the AV coordinator is not shown to retain it. Without that property, nothing in the sample would keep the delegate alive.
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution; Sendable conformance alone does not prove thread-safe mutation.
Default isolation matters here. The target compiles with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.xcodeproj/project.pbxproj:180). Unannotated views and helpers are therefore main-actor isolated. The source opts out explicitly with nonisolated on the GroupActivity value (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/SyncCustomVideoPlaybackActivity.swift:11), the AV delegate class (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:72), static loggers, and the Logger initializer (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Extension/Logger.swift:18).
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Main-actor isolation | @MainActor classes plus isolated deinit |
AppModel, PlaybackSessionController, PlaybackCoordinator, PlaybackRenderer, and VideoSampleSource are main-actor isolated. isolated deinit lets teardown touch isolated stored tasks and tokens. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:58, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:57 |
| Session listener (async sequence) | Task { @MainActor [weak self] in await self?.listen() }. listen() runs for await session in SyncCustomVideoPlaybackActivity.sessions(). |
Created in init, stored in listenTask, cancelled in isolated deinit. Each session is joined, then bound to the AV coordinator. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:54, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:65 |
| Session-state monitor | Plain Task { [weak self] in ... } created in a main-actor method. It iterates session.$state.values, a Combine publisher exposed as an async sequence. |
The previous monitor is cancelled before replacement. The loop returns if a newer session has replaced this one (groupSession?.id == session.id), or after calling resetSession() on .invalidated. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:89, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:107 |
| Callback-to-stream handoff | A nonisolated delegate holds an immutable AsyncStream<Command>.Continuation and calls yield. |
The source does not show which queue AVFoundation uses for delegate calls. The delegate stores no other mutable state. If yield reports .terminated, it runs the completion handler immediately so the AV coordinator isn’t left waiting. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:80 |
| Serial command application | drainTask = Task { @MainActor [weak self] in for await command in commands { await self?.handle(command) } } |
Commands are applied one at a time, in arrival order, on the main actor. Each completion handler runs only after its apply… method returns. Re-draining cancels the old task. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:78, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:87 |
| Main-queue scheduling | addPeriodicTimeObserver(forInterval:queue: .main) plus MainActor.assumeIsolated |
Every 0.1 s of synchronizer time, a callback is submitted to the main queue. It asserts main-actor isolation and updates currentSeconds, then may call endReached. The token is removed in isolated deinit. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:49 |
| Shared-state safety | actor VideoSamplePump |
renderTask is actor-isolated. start(at:) cancels the previous task and awaits its value before flushing the receiver and starting a new task. Only one supply loop is active after replacement. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:188 |
| Worker task | Task(name: "render", priority: .userInitiated) { [self] in ... } inside the actor method |
The body reads receiver and clears renderTask in defer. Inference: both need pump isolation, so the task runs on the pump actor’s executor. No OS thread is established. The loop checks Task.isCancelled and reports status through an AsyncStream. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:195 |
| Cross-isolation transfer | sending parameter and result |
The pump receives the non-Sendable receiver as sending. The main-actor makeReaderAndProvider returns its reader and provider as sending, so the actor can take them over. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:178, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/VideoSampleSource.swift:68 |
| Duration load | Task { @MainActor [weak self, asset] in try await asset.load(.duration) } |
The comment says “in the background”, but the task is main-actor isolated. Only the framework’s load call suspends. awaitDuration() awaits the stored task’s value. A non-isolated deinit cancels it. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/VideoSampleSource.swift:47 |
| UI tasks | Plain Task {} and Task { @MainActor in } in view actions |
“Start SharePlay” awaits activate(). Ending the session drives dismissImmersiveSpace(). Neither task is stored or cancelled. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ContentView.swift:38, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ContentView.swift:56 |
Command is declared Sendable and every completion handler is @Sendable (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:64-69). That is a transfer contract across the delegate-to-renderer boundary, not a lock. Serialization comes from the single drainTask consumer.
Inference on lifetime: inside the listener and monitor tasks, await self?.listen() evaluates self once for the whole call. While that loop runs, the controller stays referenced, so isolated deinit cancellation would matter only after the loop exits. The AppModel comment declares an app-lifetime listener anyway (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/AppModel.swift:21). The drain and status loops re-check self? on each element instead.
Reference code
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:188 — VideoSamplePump.start(at:)
func start(at time: CMTime) async -> AsyncStream<SampleSourceStatus> {
// Replace any existing supply.
renderTask?.cancel()
_ = await renderTask?.value
receiver.flush()
return AsyncStream { continuation in
renderTask = Task(name: "render", priority: .userInitiated) { [self] in
defer { renderTask = nil; continuation.finish() }
do {
var (reader, provider) = try await sampleSource.makeReaderAndProvider(at: time)
defer { reader.cancelReading() }
while !Task.isCancelled, let sample = try await provider.next() {
// ...
}
continuation.yield(Task.isCancelled ? .suspended : .finished)
} catch is CancellationError {
continuation.yield(.suspended)
} catch {
continuation.yield(.failed(error))
}
}
}
}SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:53 — listener task ownership
init() {
self.listenTask = Task { @MainActor [weak self] in
await self?.listen()
}
}
isolated deinit {
listenTask?.cancel()
stateMonitorTask?.cancel()
}State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line; it does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| State propagation | @Observable on AppModel, PlaybackSessionController, PlaybackRenderer |
Views read appModel.session.groupSession?.state, session.isPlaying, currentSeconds, and playbackRenderer?.renderStatus. The controller’s computed properties forward to the observable renderer. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:40, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/PlaybackPanelView.swift:92 |
| State propagation | SwiftUI environment | .environment(appModel) on both scenes. Views read it with @Environment(AppModel.self). |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlayApp.swift:20, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift:20 |
| State propagation | onChange reactions |
ContentView dismisses the immersive space when groupSession becomes nil. ImmersiveView re-applies videoMaterial when the renderer’s ObjectIdentifier changes. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ContentView.swift:54, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift:82 |
| State propagation | RealityKit per-frame event | SceneEvents.Update reads appModel.session.currentSeconds and rebuilds the blob mesh at most every 1/15 s of media time. The returned subscription is discarded with _ =. Its lifetime is not source-visible. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift:53 |
| Combine use | session.$state.values |
Combine appears only to read the GroupSession state publisher as an async sequence. No receive(on:), subscribe(on:), or AnyCancellable appears. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:12, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:107 |
| Framework import | @preconcurrency import GroupActivities |
Imported with relaxed Sendable checking in the controller. Plain import elsewhere. | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:8, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/SyncCustomVideoPlaybackActivity.swift:8 |
| Framework import | AVFoundation, RealityKit | Playback engine and immersive view | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:8, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:9 |
| Framework import | CoreTransferable, CoreMedia, simd, os | Activity Transferable conformance, CMTime in the panel, blob math, logging |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/SyncCustomVideoPlaybackActivity.swift:9, SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/PlaybackPanelView.swift:10 |
| Capability | Group-session entitlement | com.apple.developer.group-session is enabled. |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.entitlements:5 |
No Swift package manifest or package reference is part of the tree.
Class and protocol design
classDiagram
class AVPlaybackCoordinatorPlaybackControlDelegate {
<<protocol>>
}
class Command {
<<enum>>
play
pause
seek
buffer
}
PlaybackControlDelegate ..|> AVPlaybackCoordinatorPlaybackControlDelegate
PlaybackCoordinator *-- PlaybackControlDelegate
PlaybackControlDelegate --> Command : yields
PlaybackRenderer --> Command : drains
SyncCustomVideoPlaybackActivity ..|> GroupActivity
SyncCustomVideoPlaybackActivity ..|> Transferable
Reference code
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:72 — PlaybackControlDelegate
private nonisolated final class PlaybackControlDelegate: NSObject, AVPlaybackCoordinatorPlaybackControlDelegate {
private let continuation: AsyncStream<Command>.Continuation
// ...
func playbackCoordinator(
_ coordinator: AVDelegatingPlaybackCoordinator,
didIssue pauseCommand: AVDelegatingPlaybackCoordinatorPauseCommand,
completionHandler: @escaping @Sendable () -> Void
) {
dispatch(.pause(completionHandler: completionHandler))
}
// ...
}| Type | Responsibility | Depends on |
|---|---|---|
SyncCustomVideoPlaybackActivity |
nonisolated value describing the activity: .watchTogether, title, video name. Also Transferable so ShareLink can donate it. |
GroupActivities, CoreTransferable |
PlaybackSessionController |
Receives sessions, configures systemCoordinator, builds and tears down per-session pipelines, exposes play/pause/seek and status to views |
PlaybackCoordinator, PlaybackRenderer, VideoSampleSource |
PlaybackCoordinator |
Wraps AVDelegatingPlaybackCoordinator. Outbound coordinate… calls. Owns the inbound command stream. |
AVFoundation, GroupActivities |
PlaybackControlDelegate |
Converts the four delegate callbacks into Command values on the stream |
AVPlaybackCoordinatorPlaybackControlDelegate |
PlaybackRenderer |
Applies commands to AVSampleBufferRenderSynchronizer. Publishes the clock and status. Exposes VideoMaterial. |
AVFoundation, RealityKit, VideoSamplePump |
VideoSamplePump (actor) |
Owns the single decode-and-enqueue task. Restarts it at a time. Reports SampleSourceStatus. |
VideoSampleSource, AVSampleBufferVideoRenderer.Receiver |
VideoSampleSource |
Loads duration and builds positioned AVAssetReaders |
AVFoundation |
No app-defined protocol exists. The only protocol boundary is AVFoundation’s delegate protocol, adopted by a file-private class.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
PlaybackSessionController.playbackRenderer, .groupSession (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:21, :47) |
private(set) |
Views can read and observe them. Only the controller can replace or clear them. | Inference: views need the material and session state, but the session lifecycle must have one writer. |
PlaybackSessionController.playbackCoordinator (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:22) |
private |
Views cannot bypass play(), which guards against playing past the end. |
Inference: keeps all outbound commands on one checked path. |
PlaybackControlDelegate (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:72) |
private at file scope, which acts as fileprivate |
Visible only inside PlaybackCoordinator.swift |
Inference: the AV delegate is an implementation detail behind commands. |
PlaybackRenderer.currentSeconds, .isPlaying, .renderStatus (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:23, :24, :31) |
private(set) |
Only the time observer, setRate, and the status task mutate them. |
Inference: playback state is derived from the synchronizer, not set by callers. |
PlaybackRenderer.pauseCoordinatorOnEnd (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:29) |
implicit internal var |
Any type in the module can assign it. Only makePlayback does. |
Inference: a late-bound callback avoids a renderer-to-coordinator type dependency. |
VideoSamplePump.renderTask (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:172) |
private plus actor isolation |
Readable outside only through the async isDecoding |
The language enforces this. |
PlayButton, TimeLabel (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/PlaybackPanelView.swift:108) |
file-scope private |
Usable only by PlaybackPanelView |
Inference: local view decomposition |
Reference code
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:14 — renderer state surface
final class PlaybackRenderer {
// ...
let videoMaterial: VideoMaterial
private let synchronizer: AVSampleBufferRenderSynchronizer
private let pump: VideoSamplePump
let durationSeconds: Double?
private(set) var currentSeconds: Double? = nil
private(set) var isPlaying: Bool = false
// ...
}Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
Scene setup, opting the window out of SharePlay with .groupActivityAssociation(nil) |
SyncCustomVideoPlaybackWithSharePlayApp |
Scene-level SharePlay policy sits where scenes are declared (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlayApp.swift:22). |
Activity metadata (.watchTogether, sceneAssociationBehavior = .none) |
SyncCustomVideoPlaybackActivity |
A value type the system can transfer (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/SyncCustomVideoPlaybackActivity.swift:17-24) |
Session join, spatial template (.sideBySide), group immersive space |
PlaybackSessionController.listen() |
Session policy is applied once per received session (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:78-85). |
| Outbound intent → group | PlaybackCoordinator.coordinatePlay/Pause/Seek |
Rate 1 or 0 and seek requests go to the AV coordinator only. |
| Inbound group command → local playback | PlaybackRenderer.handle(_:) and apply… |
One serial consumer touches the synchronizer. |
| Sample decode and enqueue, flush recovery | VideoSamplePump |
Actor isolation protects renderTask from data races; restart ordering comes from the single main-actor drain loop that awaits each handle(_:) call, because the actor is reentrant across await renderTask?.value (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:207-218) |
| End-of-media | PlaybackRenderer.endReached |
Pauses locally at once, then asks the group to pause through the callback (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:155-161). |
| Blob geometry | MeshResource+Blob.swift |
Pure static mesh generation, kept away from playback |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| SwiftUI environment injection | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlayApp.swift:20 and AppModel |
One model reaches both scenes. The panel inside a RealityKit attachment instead gets the controller through its initializer (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift:68). |
| Observable state owner | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:15 with private(set) state |
Views observe the session and renderer state. Only the controller mutates the lifecycle. |
| Playback facade | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:26 (play, pause, seek, status properties) |
Views use one object. Whether a coordinator or renderer currently exists stays hidden behind optionals. |
| delegate-to-async adapter | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:80 dispatch(_:) |
Converts AV delegate callbacks into an ordered AsyncStream<Command>, so the main-actor renderer consumes them serially and completion stays explicit. |
| Actor isolation | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:167 actor VideoSamplePump |
Protects renderTask replacement. The tradeoff is a cross-actor await for isDecoding and start(at:). |
| Sample-buffer video bridge | SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:35 (AVSampleBufferVideoRenderer → VideoMaterial) |
A custom decode path feeds a RealityKit material instead of using AVPlayer. |
Not claimed:
PlaybackCoordinatoris named afterAVDelegatingPlaybackCoordinator. It does not coordinate navigation or app flow, so it is not labeled a Coordinator pattern.Commandis a message enum that the renderer interprets, not an object that executes itself, so it is not labeled the Command pattern.- No MVVM is claimed. No type is a per-view model.
Main application flow
sequenceDiagram
actor User
participant Panel as PlaybackPanelView
participant Ctrl as PlaybackSessionController
participant Coord as PlaybackCoordinator
participant Del as PlaybackControlDelegate
participant Rend as PlaybackRenderer
User->>Panel: Tap Play
Panel->>Ctrl: play()
Ctrl->>Coord: coordinatePlay()
Coord->>Coord: coordinateRateChange(to: 1)
Del-->>Rend: yield .play(rate, time, atHostTime)
Rend->>Rend: handle → applyPlay → setRate(atHostTime)
Rend-->>Del: completionHandler()
Reference code
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:78 — inbound drain and serial apply
func drain(commands: AsyncStream<Command>) {
self.drainTask?.cancel()
self.drainTask = Task { @MainActor [weak self] in
for await command in commands {
await self?.handle(command)
}
}
}
private func handle(_ command: Command) async {
switch command {
case let .play(rate, time, atHostTime, completionHandler):
await applyPlay(rate: rate, time: time, atHostTime: atHostTime)
completionHandler()
// ...
case let .buffer(completionHandler):
await applyBuffering()
completionHandler()
}
}The local tap never sets the synchronizer rate directly. It reaches setRate only after the AV coordinator issues a play command to the delegate (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:92-103).
Naming conventions
- Types: role suffixes match responsibilities in source.
…Controllerowns the session lifecycle.…Coordinatorwraps the AV playback coordinator.…Rendererowns the synchronizer and material.…Sourceproduces readers.…Pumppushes samples.…Activityis theGroupActivity.…Delegateadopts the AV delegate protocol. - Direction-named methods: outbound calls are
coordinatePlay/Pause/Seekunder// MARK: - Outbound Coordination. Inbound calls areapplyPlay/Pause/Seek/Bufferingunder// MARK: - Inbound Coordination. Log prefixes[outbound],[apply],[session], and[activation]mirror the split (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:45,SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:75). - Lifecycle methods:
listen,monitorSessionState,resetSession,makePlayback(factory-stylemakeprefix),drain(commands:),observeStatus(_:),awaitDuration(). - State names:
immersiveSpaceStatewithclosed/inTransition/open.renderStatus: SampleSourceStatus. BooleanisPlayingandisDecoding. - Files: one primary type per file, except the pump inside
PlaybackRenderer.swift. Extensions useMeshResource+Blob.swift.
Architecture takeaways
- Separate outbound intent from inbound application. Local taps only ask the group coordinator. One serial consumer applies the group’s timed commands to the synchronizer, so every participant runs the same path.
- Bridge callback APIs into one
AsyncStreamand drain it from a single stored task. This gives ordering and a clear place to call completion handlers, including when the stream is already terminated. - Build per-session pipelines and tear them down by dropping references.
resetSession()→ coordinatordeinit→continuation.finish()→ drain loop ends. Weak capture keeps the renderer from extending the coordinator’s lifetime. - Store every long-running
Taskin the owner that cancels it (listenTask,stateMonitorTask,drainTask,statusTask,durationTask,renderTask). Cancel the previous one before replacing it. - Read actor annotations precisely.
@MainActorplusSWIFT_DEFAULT_ACTOR_ISOLATION = MainActorkeeps most code on the main actor. The only separate isolation domain is theVideoSamplePumpactor, and a comment saying “background” does not make a@MainActortask run off-main.
Source map
| Source file | Relevant symbols |
|---|---|
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlayApp.swift |
SyncCustomVideoPlaybackWithSharePlayApp, .environment(appModel), .groupActivityAssociation(nil) |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/AppModel.swift |
AppModel, ImmersiveSpaceState, session |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift |
PlaybackSessionController, listen(), monitorSessionState(_:), resetSession(), makePlayback |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/SyncCustomVideoPlaybackActivity.swift |
SyncCustomVideoPlaybackActivity, metadata |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift |
PlaybackCoordinator, Command, PlaybackControlDelegate |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift |
PlaybackRenderer, drain(commands:), handle(_:), VideoSamplePump, start(at:) |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/VideoSampleSource.swift |
VideoSampleSource, loadDuration(), makeReaderAndProvider(at:), SampleSourceStatus |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ContentView.swift |
ContentView, Start SharePlay task, session-ended onChange |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/ImmersiveView.swift |
ImmersiveView, SceneEvents.Update, ViewAttachmentComponent |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Views/PlaybackPanelView.swift |
PlaybackPanelView, PlayButton, TimeLabel |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Extension/Logger.swift |
SampleBundle, Logger.init(category:) |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.entitlements |
com.apple.developer.group-session |
SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.xcodeproj/project.pbxproj |
SWIFT_DEFAULT_ACTOR_ISOLATION, SWIFT_VERSION |