Sample CodevisionOSReviewed 2026-11-01View on Apple Developer

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.swift holds two types: the @MainActor renderer and the VideoSamplePump actor 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+Feature file name (MeshResource+Blob.swift). Logger.swift centralizes the logging subsystem so the subsystem string “can’t drift” (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/Extension/Logger.swift:11).
  • The target opts into SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor with Swift 6 (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay.xcodeproj/project.pbxproj:180). This is why the source spells out nonisolated exceptions rather than @MainActor everywhere.

Overall architecture

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

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. pauseCoordinatorOnEnd captures it with [weak newCoordinator] (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/GroupActivities/PlaybackSessionController.swift:133), so clearing playbackCoordinator in resetSession() is enough to release it.
  • Teardown chain (observation): resetSession() releases the coordinator. Its isolated deinit calls continuation.finish() (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackCoordinator.swift:36-38). That ends the renderer’s for await command in commands loop (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:81). The renderer’s own isolated deinit removes the time observer and cancels drainTask and statusTask (SyncCustomVideoPlaybackWithSharePlay/SyncCustomVideoPlaybackWithSharePlay/PlaybackEngine/PlaybackRenderer.swift:57-63).
  • Inference: PlaybackCoordinator keeps the delegate in a strong let because 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

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:

  • PlaybackCoordinator is named after AVDelegatingPlaybackCoordinator. It does not coordinate navigation or app flow, so it is not labeled a Coordinator pattern.
  • Command is 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

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. …Controller owns the session lifecycle. …Coordinator wraps the AV playback coordinator. …Renderer owns the synchronizer and material. …Source produces readers. …Pump pushes samples. …Activity is the GroupActivity. …Delegate adopts the AV delegate protocol.
  • Direction-named methods: outbound calls are coordinatePlay/Pause/Seek under // MARK: - Outbound Coordination. Inbound calls are applyPlay/Pause/Seek/Buffering under // 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-style make prefix), drain(commands:), observeStatus(_:), awaitDuration().
  • State names: immersiveSpaceState with closed/inTransition/open. renderStatus: SampleSourceStatus. Boolean isPlaying and isDecoding.
  • Files: one primary type per file, except the pump inside PlaybackRenderer.swift. Extensions use MeshResource+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 AsyncStream and 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() → coordinator deinit → continuation.finish() → drain loop ends. Weak capture keeps the renderer from extending the coordinator’s lifetime.
  • Store every long-running Task in the owner that cancels it (listenTask, stateMonitorTask, drainTask, statusTask, durationTask, renderTask). Cancel the previous one before replacing it.
  • Read actor annotations precisely. @MainActor plus SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor keeps most code on the main actor. The only separate isolation domain is the VideoSamplePump actor, and a comment saying “background” does not make a @MainActor task 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