Supporting Continuity Camera in your tvOS app
At a glance
| Item | Summary |
|---|---|
| Purpose | Connects an iPhone or iPad to Apple TV and captures photos, video, and two-way audio. |
| App architecture | The SwiftUI app is the composition root for one capture-session manager and three media-specific capturers; ContentView observes and commands them. |
| Main patterns | Observable service objects, framework delegates, observer adapters, closure callback, and feature extensions. |
| Project style | One tvOS target grouped by app entry, camera coordination, media export, observers, and reusable views. |
Project structure
App/
└── ContinuityCameraApp.swift
Camera/
├── CaptureManager.swift
├── CaptureManager+ContinuityCamera.swift
├── CaptureManager+Observers.swift
└── CaptureType.swift
Media export/
├── AudioCapturer.swift
├── PhotoCapturer.swift
└── VideoCapturer.swift
Observers/
├── CaptureDeviceNotificationObserver.swift
├── PreferredCameraObserver.swift
└── VideoEffectsObserver.swift
Views/
├── ContentView.swift
├── CameraPreview.swift
├── AudioControls.swift
└── CaptureButton.swift
Structure observations
- Folders follow responsibilities rather than generic MVC layers.
CaptureManagerextensions separate device selection and observation mechanics without creating additional owners.- Each exported medium has a dedicated capturer, while the shared camera session remains centralized.
Overall architecture
flowchart LR
App["ContinuityCaptureApp"] --> Manager["CaptureManager"]
App --> Photo["PhotoCapturer"]
App --> Video["VideoCapturer"]
App --> Audio["AudioCapturer"]
App --> View["ContentView"]
View --> Manager
View --> Photo
View --> Video
View --> Audio
Manager --> Session["AVCaptureSession"]
Photo --> PhotoOutput["AVCapturePhotoOutput"]
Video --> MovieOutput["AVCaptureMovieFileOutput"]
PhotoOutput --> Session
MovieOutput --> Session
Reference code
App/ContinuityCameraApp.swift:31 — the app root wires media outputs into the shared session, then injects every owner into the main view.
init() {
captureManager.addOutput(photoCapturer.photoOutput)
captureManager.addOutput(videoCapturer.movieFileOutput)
}
var body: some Scene {
WindowGroup {
ContentView(captureManager: captureManager,
photoCapturer: photoCapturer,
videoCapturer: videoCapturer,
audioCapturer: audioCapturer)
}
}Interpretation
The view composes the UI but does not construct capture infrastructure. Camera input and session configuration belong to CaptureManager; media-specific capture, export, and published state belong to the corresponding capturer.
Ownership and state
classDiagram
ContinuityCaptureApp *-- CaptureManager : creates
ContinuityCaptureApp *-- PhotoCapturer : creates
ContinuityCaptureApp *-- VideoCapturer : creates
ContinuityCaptureApp *-- AudioCapturer : creates
CaptureManager *-- AVCaptureSession : owns
CaptureManager *-- PreferredCameraObserver : owns
CaptureManager *-- VideoEffectsObserver : owns
CaptureManager *-- CaptureDeviceNotificationObserver : owns
PhotoCapturer *-- AVCapturePhotoOutput : owns
VideoCapturer *-- AVCaptureMovieFileOutput : owns
AVCaptureSession o-- AVCaptureOutput : attaches
ContentView --> CaptureManager : observes
ContentView --> PhotoCapturer : observes
ContentView --> VideoCapturer : observes
ContentView --> AudioCapturer : observes
Ownership evidence
Camera/CaptureManager.swift:22 — the manager creates the session, derives its preview layer from that session, and retains observation adapters.
let session = AVCaptureSession()
let previewLayer: AVCaptureVideoPreviewLayer
var videoEffectsObvserver = VideoEffectsObserver()
var notificationObserver = CaptureManager.makeNotificationObserver()
var preferredCameraObserver = PreferredCameraObserver()
override init() {
previewLayer = AVCaptureVideoPreviewLayer(session: session)
super.init()
preferredCameraObserver.handler = updateSystemPreferredCamera(_:)
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
| App root | Manager and three capturers | Creates, retains, and injects | Each service mutates its own state |
CaptureManager |
Session, active input, preview layer, observers, isActive |
Creates and coordinates | Manager and its same-type extensions |
PhotoCapturer |
Photo output and completion closure | Creates and retains | Capturer/delegate callbacks; view installs completion |
VideoCapturer |
Movie output and capture state | Creates and retains | Commands and recording-delegate callbacks |
AudioCapturer |
Audio engine/session-facing state and recorded file | Creates and retains | Audio service methods and notifications |
ContentView |
Mode, picker, flash, and preview UI state | Locally owns with @State |
View actions only |
Class and protocol design
| Type or protocol | Responsibility | Depends on |
|---|---|---|
CaptureManager |
Select inputs, configure the shared video session, publish connection state | AVFoundation capture APIs and observers |
PhotoCapturer / AVCapturePhotoCaptureDelegate |
Request still capture, normalize orientation, and save to Photos | Photo output and photo-library API |
VideoCapturer / AVCaptureFileOutputRecordingDelegate |
Start/stop movie output and save completed recordings | Capture manager, movie output, Photos |
AudioCapturer |
Configure voice processing, record, and replay audio | AVAudioEngine and AVAudioSession |
| Observer types | Translate KVO or notifications into typed handlers/published state | AVFoundation and NotificationCenter |
ContentView |
Choose capture mode and dispatch user intent | Four observable service objects |
The sample uses protocol orientation at framework boundaries through AVFoundation delegate conformances. It favors concrete app services and small typed closures over app-defined service protocols.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
CaptureManager.activeInput |
explicit internal |
Same-module files and CaptureManager extensions can replace the active device. |
Device-disconnection logic in another file needs mutation access. |
| Preferred-camera update and authorization helper | private |
Only CaptureManager.swift can invoke these implementation details directly. |
Keep permission and callback plumbing behind manager commands. |
AudioCapturer engine, session, nodes, and recorded URL |
private |
Views cannot rewire the audio graph or storage location. | Protect audio-engine lifecycle invariants. |
| Audio published properties | private(set) |
Views observe state but cannot assign it. | Maintain a one-way command/state boundary. |
| Capturer commands and output handles | public |
Other same-target files can start work and attach outputs; the enclosing classes are still implicit internal. |
Mark intended collaborator-facing operations in the sample, not a public framework API. |
ContentView transient state and helpers |
mostly private |
Capture mode UI details do not become module-wide symbols. | Keep view implementation local. |
Reference code
Camera/CaptureManager.swift:33 — activeInput is intentionally module-visible, but it centralizes the remove/add side effects in one property.
internal var activeInput: AVCaptureDeviceInput? {
willSet {
if let oldInput = activeInput { session.removeInput(oldInput) }
}
didSet {
if let newInput = activeInput { session.addInput(newInput) }
isActive = (activeInput != nil)
}
}Media export/AudioCapturer.swift:27 — mutable engine internals are private, while UI-facing state is read-only outside the capturer.
private let avAudioEngine = AVAudioEngine()
private let avAudioSession = AVAudioSession.sharedInstance()
@Published private(set) var playingBackgroundNoise = false
@Published private(set) var canPlayRecordedAudio = false
@Published private(set) var isPlayingRecordedAudio = falseLogic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Session input/output configuration and permission | CaptureManager |
Shared AV capture authority. |
| Default Continuity Camera discovery | CaptureManager+ContinuityCamera.swift |
Feature-specific extension of the same owner. |
| KVO and device-notification routing | Observer types plus CaptureManager+Observers.swift |
Isolates framework observation mechanics from core session commands. |
| Still/movie/audio capture and export | Corresponding capturer | Keeps medium-specific APIs and state independent. |
| Mode selection and shutter dispatch | ContentView |
User-intent translation belongs at the presentation boundary. |
| Photo preview and flash | ContentView |
Transient visual feedback is view state, not capture state. |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Composition root | App/ContinuityCameraApp.swift:12 |
Makes object creation and cross-service wiring visible in one place. |
| Observable service object | Camera/CaptureManager.swift:18 |
SwiftUI observes long-lived framework coordination without owning it. |
| Specialized services | Media export/PhotoCapturer.swift:13 and Media export/VideoCapturer.swift:13 |
Separates capture/export lifecycles by medium. |
| Observer adapter | Observers/PreferredCameraObserver.swift:16 |
Encapsulates KVO registration/removal and emits a typed camera callback. |
| Framework delegate | Media export/VideoCapturer.swift:84 |
Keeps asynchronous recording completion with the object that owns the output. |
| Extension-based decomposition | Camera/CaptureManager+ContinuityCamera.swift:10 |
Splits feature mechanics without splitting state ownership. |
Main application flow
sequenceDiagram
actor User
participant View as ContentView
participant Manager as CaptureManager
participant Capturer as Selected capturer
participant Output as AVFoundation output
User->>View: Connect device or press shutter
View->>Manager: select camera / start session
User->>View: Capture photo, video, or audio
View->>Capturer: capture command
Capturer->>Output: begin capture
Output-->>Capturer: delegate completion
Capturer-->>View: publish state or photo callback
Reference code
Views/ContentView.swift:209 — one UI action dispatches to the service that owns the selected medium.
private func onShutterButton() {
switch captureType {
case .photo:
photoCapturer.onPhotoCompletion = { image in
showScreenFlash = true
previewImage = image
}
photoCapturer.capture()
case .video:
toggleVideoCapture()
case .audio:
toggleAudioCapture()
}
}Naming conventions
- Owner types use role suffixes:
CaptureManager,PhotoCapturer,VideoCapturer, andPreferredCameraObserver. - Commands use concrete verbs:
activate,setActive,addOutput,startRecording,stopRecording, andwriteToPhotoLibrary. - UI Boolean state uses
is,can, andshow; callbacks use theon...Completionform. +Featurefilenames identify extensions of the same owner, while folders name domain responsibilities.- Framework objects retain their Apple terminology (
session,input,output,previewLayer) instead of introducing aliases.
Architecture takeaways
- Construct long-lived capture collaborators at the app boundary and pass them into SwiftUI views as observed dependencies.
- Keep one shared session owner, then give each media format a focused capture/export owner.
- Hide audio and session mutation behind commands while exposing observable state read-only where practical.
- Use small observer adapters to contain KVO and notification cleanup.
- Split a large owner by feature extensions only when ownership and invariants remain unambiguous.
Source map
| Source file | Relevant symbols |
|---|---|
App/ContinuityCameraApp.swift |
Composition root and output wiring |
Camera/CaptureManager.swift |
Session, input, output, permission, and core ownership |
Camera/CaptureManager+ContinuityCamera.swift |
Default Continuity Camera activation |
Camera/CaptureManager+Observers.swift |
Camera observer coordination |
Media export/PhotoCapturer.swift |
Photo delegate and photo-library export |
Media export/VideoCapturer.swift |
Movie output, recording state, and export delegate |
Media export/AudioCapturer.swift |
Audio engine and observable audio state |
Views/ContentView.swift |
Capture-mode UI and command dispatch |