Technical decisions and design rationale for SwiftVLC, written for contributors and reviewers.
Looking for how to use SwiftVLC? See the DocC guides (published on Swift Package Index) or run
swift package preview-documentation --target SwiftVLClocally. This document covers the why behind the design: build/release infrastructure, C-interop patterns, isolation choices, deinit ordering, and the testing strategy.
- High-Level Overview
- Tech Stack
- Module Architecture
- C Interop Layer
- Concurrency & Threading Model
- Event System
- Memory Management
- Video Rendering
- Picture-in-Picture
- Error Handling
- Testing Strategy
- Build & Release Infrastructure
- Project Structure
flowchart TB
subgraph App["SwiftUI App"]
VV["VideoView(player)"]
PS["player.state"]
PV["player.volume"]
end
subgraph SwiftVLC["SwiftVLC Library"]
direction TB
subgraph Top["Primary Modules"]
Player["Player<br/>@Observable · @MainActor"]
Media["Media<br/>Sendable · async"]
Audio["Audio<br/>EQ · Modes"]
Video["Video<br/>VideoView · Adjustments"]
end
subgraph Bottom["Supporting Modules"]
Playlist["Playlist<br/>MediaList · ListPlayer"]
Discovery["Discovery<br/>LAN/UPnP · Renderer"]
PiP["PiP<br/>iOS native drawable or direct vmem · macOS SPI drawable"]
EventBridge["EventBridge<br/>C → AsyncStream"]
end
end
subgraph CLibVLC["CLibVLC"]
Shim["C shim + libVLC 4.0 headers"]
end
subgraph XCF["libvlc.xcframework"]
Binary["Pre-built libVLC 4.0<br/>iOS · macOS · tvOS · visionOS · Catalyst<br/>21 system frameworks · 7 system libraries"]
end
App --> SwiftVLC
Top --> Bottom
SwiftVLC --> CLibVLC
CLibVLC --> XCF
Key concepts:
- Swift-first: Direct C → Swift bindings, no Objective-C intermediary (unlike VLCKit)
- Observable state:
@Observable@MainActorPlayer drives SwiftUI updates automatically - Typed concurrency: Swift 6 strict concurrency. Main-actor types own UI-adjacent C resources, and cross-actor resource/value types are
Sendable. - One-liner rendering:
VideoView(player)covers the setup, delegate wiring, and callbacks in one call. - Typed errors:
throws(VLCError)for compile-time error handling
| Component | Choice | Why |
|---|---|---|
| Language | Swift 6.3+ with Xcode 26.4+ | Strict concurrency, typed throws, @Observable, upcoming feature flags |
| C Bindings | libVLC 4.0 C API | Direct access, no Objective-C overhead |
| State | @Observable / @MainActor |
Automatic SwiftUI integration, thread safety |
| Events | AsyncStream<PlayerEvent> |
Native structured concurrency, multi-consumer |
| Video | UIView / NSView via set_nsobject |
Platform-native rendering, zero-copy |
| PiP | iOS PiPVideoView: native drawable; direct controller: vmem → AVSampleBufferDisplayLayer; macOS view: native drawable behind SPI |
Public iOS PiP, a public direct sample-buffer route, plus explicit private-API macOS opt-in |
| Thread Safety | Mutex<T>, Sendable, nonisolated(unsafe) |
Compile-time data race prevention |
| Testing | Swift Testing framework | Modern @Test, #expect, tags, traits |
| Platforms | iOS 18+, macOS 15+, tvOS 18+, visionOS 2+, Mac Catalyst 18+ | Unified SwiftUI minimum |
Foundation types shared across all modules.
| File | Type | Purpose |
|---|---|---|
VLCInstance.swift |
final class VLCInstance: Sendable |
Manages libvlc_instance_t* lifecycle. Singleton shared or custom with arguments. Owns the per-instance dialogRegistration Mutex. |
VLCError.swift |
enum VLCError: Error, Sendable, Equatable, Hashable, LocalizedError, CustomStringConvertible |
Typed errors with hand-rolled per-case accessors (error.parseTimeout, error.mediaCreationFailed, …). Auto-synthesized Equatable/Hashable over String payloads. |
Broadcaster.swift |
final class Broadcaster<Element: Sendable> |
Internal multi-consumer fan-out used by the dialog, renderer, log, player-event, and playback-intent streams. Exposes subscribe, broadcast, finishAll (allows resubscribe) and terminate (permanent — future subscribe calls return immediately-finished streams) inside the module. Lifecycle reconciliation runs on a private serial queue. |
DialogHandler.swift |
final class DialogHandler: Sendable |
Bridges libVLC's dialog callbacks (login, question, progress, error) onto a Broadcaster<DialogEvent>. DialogID carries the dialog handle through DialogIDStorage for safe dismiss() + post calls. |
Logging.swift |
AsyncStream<LogEntry> via LogBroadcaster |
Filterable log stream backed by Broadcaster<LogEntry>. C shim formats va_list before Swift callback. LogNoiseFilter demotes known-noisy libVLC errors to warnings. |
Signposts.swift |
enum Signposts |
Process-wide OSSignposter for org.swiftvlc subsystem. Hot paths (Broadcaster.broadcast, Player.handleEvent, EventBridge.callback, PixelBufferRenderer.outputPixelBuffer) emit signposts visible in Instruments. Zero cost when no profiler is attached. |
Duration+Extensions.swift |
Extensions on Duration |
milliseconds, microseconds properties and formatted display string |
Default VLC arguments: ["--no-video-title-show", "--no-snapshot-preview"]. --no-stats is not in the defaults — leaving it on would silently zero every Media.statistics() read, which is almost never what a caller wants. On macOS the default keeps VLC's Apple sample-buffer display available for inline playback, including its paired subtitle layer. The private native-drawable PiP backend is present for explicitly opted-in non-App-Store builds, but it is SPI and disabled by default.
The central observable type that drives all playback.
| File | Type | Purpose |
|---|---|---|
Player.swift |
@Observable @MainActor class |
Wraps libvlc_media_player_t*. Lifecycle, drawable management, media loading, and core playback control. Extension files group audio, chapters, events, overlays, programs, recording, typed values, and native-lifecycle helpers. |
Player+Events.swift |
extension Player |
The libVLC event-consumer task and handleEvent(_:) dispatch — pause-transition state machine, deferred-pause command, native-state probing, media-derived state reset. |
Player+Audio.swift |
extension Player |
Audio-output device selection, role, mix mode, stereo mode. |
Player+Chapters.swift |
extension Player |
Title/chapter navigation, DVD menu actions. |
Player+ABLoop.swift |
extension Player |
A-B loop state plus checked time/position mutations. |
Player+Programs.swift |
extension Player |
DVB/MPEG-TS program selection, renderer targeting, mid-playback recast(to:), and scrambled-state polling. |
Player+Recording.swift |
extension Player |
Snapshot capture and recording start/stop. |
Player+Overlays.swift |
extension Player |
Scoped withMarquee/withLogo/withAdjustments accessors for the ~Copyable ~Escapable overlay types. |
Player+Typed.swift |
extension Player |
Typed read-only accessors for raw position/volume/rate/subtitleTextScale, plus explicit checked mutation methods such as setPlaybackRate(_:). |
PlaybackValues.swift |
5 typed wrapper structs | PlaybackPosition, Volume, PlaybackRate, SubtitleScale, EqualizerGain. Each is Sendable, Hashable, Comparable, ExpressibleByFloatLiteral, clamps finite input to its valid range, and maps NaN to a safe named default. |
EventBridge.swift |
internal class |
C callbacks → Broadcaster<PlayerEvent> multi-consumer broadcaster. |
PlayerState.swift |
enum PlayerState |
.idle, .opening, .buffering, .playing, .paused, .stopped, .stopping, .error. Buffer fill is exposed separately as Player.bufferFill so .paused players still publish progress. |
PlayerEvent.swift |
enum PlayerEvent |
Typed Swift cases mapped from libVLC's player event types. Hand-rolled per-case accessors (event.stateChanged, event.timeChanged, …). |
PlayerRole.swift |
enum PlayerRole |
Audio behavior hints: .music, .video, .communication, .game, etc. |
ABLoop.swift |
enum ABLoopState |
.none → .pointASet → .active |
NavigationAction.swift |
enum NavigationAction |
DVD/Blu-ray menu: .activate, .up, .down, .left, .right, .popup |
Program.swift |
struct Program |
DVB/MPEG-TS program: id, name, isSelected, isScrambled |
Player API surface:
// Observable properties (auto-update SwiftUI)
player.state // PlayerState
player.currentTime // Duration
player.duration // Duration?
player.isSeekable // Bool
player.isPausable // Bool
player.currentMedia // Media?
player.audioTracks // [Track]
player.videoTracks // [Track]
player.subtitleTracks // [Track]
// Playback observations
player.position // Double (0.0–1.0), read-only
player.volume // Float (0.0–2.0), read-only requested volume shadow
player.rate // Float (current libVLC rate), read-only
player.audioDelay // Duration, read-only
player.subtitleDelay // Duration, read-only
player.subtitleTextScale // Float, read-only
// Mutable properties
player.isMuted // Bool
player.selectedAudioTrack // Track?
player.selectedSubtitleTrack // Track?
player.aspectRatio // AspectRatio
// Checked mutations for read-only playback observations
try player.seek(to: PlaybackPosition(0.5))
try player.setAudioVolume(0.8)
try player.setPlaybackRate(1.5)
try player.setAudioDelay(.milliseconds(30))
try player.setSubtitleDelay(.milliseconds(-100))
player.setSubtitleScale(1.25)
// Playback control
try player.play(url: someURL)
player.pause()
player.resume()
try player.seek(to: .seconds(30))
try player.seek(by: .seconds(-10))
player.stop()
// Advanced
try player.setABLoop(a: .seconds(10), b: .seconds(20))
try player.takeSnapshot(to: path, width: 320, height: 240)
player.startRecording(to: directoryPath)
try player.updateViewpoint(Viewpoint(yaw: 90, pitch: 0, roll: 0, fieldOfView: 80))Media resource creation, parsing, and metadata.
| File | Type | Purpose |
|---|---|---|
Media.swift |
final class Media: Sendable |
Wraps libvlc_media_t*. Create from URL, path, or file descriptor. Async parsing with cancellation. |
Metadata.swift |
struct Metadata: Sendable |
libVLC's metadata keys surfaced as typed properties (title, artist, album, duration, artworkURL, genre, …) |
Track.swift |
struct Track: Sendable |
Audio/video/subtitle track info with type-specific fields (channels, resolution, encoding) |
ThumbnailRequest.swift |
Extension on Media |
thumbnail(at:width:height:crop:timeout:) async throws → Data |
MediaStatistics.swift |
struct MediaStatistics: Sendable |
Runtime stats: decoded/displayed/lost frames, bitrates, buffer counts |
Parsing flow:
sequenceDiagram
participant App
participant Media
participant libVLC
App->>Media: parse(timeout:) async throws
Media->>libVLC: libvlc_media_parse_request()
libVLC-->>Media: MediaParsedChanged event
alt Task cancelled
Media->>libVLC: libvlc_media_parse_stop()
else Timeout exceeded
Media-->>App: throws .parseTimeout
else Success
Media-->>App: returns Metadata
end
Audio output, equalization, and channel configuration.
| File | Type | Purpose |
|---|---|---|
AudioOutput.swift |
struct AudioOutput, struct AudioDevice |
Available output modules and devices. Extensions on VLCInstance and Player. |
Equalizer.swift |
@Observable @MainActor class Equalizer |
10-band EQ with preamp (-20 to +20 dB). libVLC's built-in presets. Attach via player.equalizer; mutations re-apply automatically. |
AudioChannelMode.swift |
enum StereoMode, enum MixMode |
Stereo/mono/Dolby, 4.0/5.1/7.1/binaural mixing |
Video rendering, overlays, and adjustments.
| File | Type | Purpose |
|---|---|---|
VideoView.swift |
SwiftUI UIViewRepresentable / NSViewRepresentable |
One-liner: VideoView(player). Platform-specific VideoSurface underneath. |
AspectRatio.swift |
enum AspectRatio |
.default, .ratio(w, h), .fill |
VideoAdjustments.swift |
@MainActor struct VideoAdjustments |
brightness, contrast, hue, saturation, gamma — accessed via player.adjustments |
Marquee.swift |
@MainActor struct Marquee |
Scrolling text overlay: text, color, opacity, position, timeout |
Logo.swift |
@MainActor struct Logo |
Image overlay: file path, position, opacity, animation |
Viewpoint.swift |
struct Viewpoint: Sendable |
360° video: yaw, pitch, roll, fieldOfView (degrees) |
Playlist management and sequential/looped playback.
| File | Type | Purpose |
|---|---|---|
MediaList.swift |
final class MediaList: Sendable |
Thread-safe list wrapping libvlc_media_list_t*. Append/insert/remove with internal locking. |
MediaListPlayer.swift |
@MainActor class MediaListPlayer |
Sequential playback with play(at:), next(), previous() |
PlaybackMode.swift |
enum PlaybackMode |
.default, .loop, .repeat |
Network service and renderer discovery.
| File | Type | Purpose |
|---|---|---|
MediaDiscoverer.swift |
final class MediaDiscoverer: Sendable |
Discovers media on LAN/SMB/UPnP/SAP. Returns MediaList of found items. |
RendererDiscoverer.swift |
final class RendererDiscoverer: Sendable |
Discovers renderer devices exposed by libVLC plugins. AsyncStream<RendererEvent> for add/remove. |
Picture-in-Picture uses the platform path that best matches libVLC's
video output. PiPVideoView uses libVLC's native drawable integration on
iOS. Instantiating PiPController directly instead installs public vmem
callbacks and exposes an AVSampleBufferDisplayLayer. macOS has a
native-drawable backend behind PrivateMacOSPiP SPI because the public
sample-buffer mirror crops incorrectly on supported macOS releases.
| File | Type | Purpose |
|---|---|---|
PiPController.swift |
@MainActor class PiPController |
PiP lifecycle, timebase sync, deferred-pause state machine, native-state observer task. |
PiPController+Delegate.swift |
extension + private PiPPlaybackDelegateProxy |
AVPictureInPictureControllerDelegate conformance plus the sample-buffer playback delegate proxy that breaks AVKit's strong-retain cycle (AVKit captures the playback delegate strongly even though the header declares it weak). Also hosts the pipMainActorSync helper for non-main AVKit callbacks that need synchronous main-actor answers. |
PiPController+AudioSession.swift |
extension | Configures the iOS playback category at construction, defers activation until playback/PiP intent, and retries after transient activation failure. |
PiPController+PlaybackState.swift |
extension | Payload-driven AVKit range invalidation and seekability-to-linear-playback policy that is independent of Player subscriber ordering. |
PiPControllerBindingPublication.swift |
@MainActor helper |
Defers representable binding writes, rejects stale generations, and clears only the controller identity published by that view lifecycle. |
PiPVideoView.swift |
SwiftUI representable | Owns PiPVideoView(player, controller: $binding) construction, player swaps, coordinator lifetime, and platform host attachment without embedding backend implementation details. |
PiPVideoView+iOSNative.swift |
iOS native drawable plumbing | Drawable proxy and layout, generation-gated off-main callbacks, libVLC media controls, and the backend that wraps libVLC's system PiP controller for state and control. |
PiPVideoView+MacPrivate.swift |
macOS-only private API plumbing | MacNativePiPBackend orchestrates MacPrivatePiPPresenter (dynamic loader for PIPViewController from PIP.framework), MacPrivatePiPDelegate, and MacNativePiPMediaController. All private-framework symbol references live in this one file for security/audit review. Gated by PiPController.allowsPrivateMacOSAPI SPI. |
PixelBufferRenderer.swift |
class PixelBufferRenderer: Sendable |
Direct sample-buffer path: format → lock → unlock → display. CVPixelBufferPool → CMSampleBuffer → layer. |
Sources/CLibVLC/
├── include/vlc/ # Full libVLC 4.0 C headers
│ ├── vlc.h # Main umbrella header
│ ├── libvlc.h # Instance, logging, dialogs
│ ├── libvlc_media.h # Media creation, parsing, metadata
│ ├── libvlc_media_player.h # Player, tracks, events
│ ├── libvlc_media_list.h # Playlist
│ ├── libvlc_media_discoverer.h # Network discovery
│ ├── libvlc_renderer_discoverer.h # Renderer discovery
│ ├── libvlc_picture.h # Thumbnail generation
│ └── libvlc_events.h # Event types
└── shim.c # C shim for va_list formatting
Swift cannot directly consume C variadic functions (va_list). The shim provides:
// shim.c — formats va_list into a fixed buffer before calling Swift
void swiftvlc_log_set(libvlc_instance_t *instance, void *opaque,
swiftvlc_log_cb callback);This allows the logging callback to receive a pre-formatted const char * instead of a va_list.
The xcframework links against the system frameworks and libraries libVLC needs for decoding, rendering, and platform services:
Frameworks: AudioToolbox, AudioUnit*, AVFoundation, AVKit, CoreAudio, CoreFoundation, CoreGraphics, CoreImage, CoreMedia, CoreServices, CoreText, CoreVideo, Foundation, IOKit*, IOSurface, OpenGL*, OpenGLES*, QuartzCore, Security, SystemConfiguration, VideoToolbox
Libraries: libbz2, libc++, libiconv, libresolv, libsqlite3, libxml2, libz
*Platform-conditional: AudioUnit/IOKit/OpenGL are macOS-only; OpenGLES is iOS/tvOS/visionOS-only.
flowchart LR
subgraph MainActor["@MainActor — mutable state, SwiftUI"]
MA["Player · MediaListPlayer · Equalizer<br/>VideoAdjustments · Marquee · Logo<br/>PiPController · SwiftUI Views"]
end
subgraph VLCThreads["libVLC internal threads"]
VLC["Event callbacks · Logging<br/>Decode (vmem)"]
end
subgraph Sendable["Sendable — any isolation"]
S["VLCInstance · Media · MediaList<br/>Track · Metadata · MediaStatistics<br/>PixelBufferRenderer · VLCError · all enums"]
end
VLCThreads -->|"AsyncStream yield"| MainActor
Sendable -.->|"safe to pass"| MainActor
Sendable -.->|"safe to pass"| VLCThreads
Rules:
@MainActortypes own mutable state that SwiftUI observes. All property access and mutation happens on the main actor.Sendabletypes are either immutable value types or use internal synchronization (Mutex<T>, libVLC's own locks).- C callbacks fire on libVLC's internal threads. They yield values into
AsyncStreamcontinuations (which are thread-safe) or hop to the main actor where UI-adjacent state must change. nonisolated(unsafe)is used forOpaquePointerfields that are only valid during the object's lifetime and accessed on the correct actor.
OpaquePointer and UnsafeMutableRawPointer aren't Sendable under Swift's region-based isolation, so getting them into @Sendable closures (withTaskCancellationHandler onCancel, DispatchQueue.*.async) needs one of two mechanisms:
-
nonisolated(unsafe) letlocal binding, for captures into a single closure. The local opts out of isolation checking; the pointer is trivially transferable and stays valid for the enclosing scope. Used inMedia.parse,Player.deinit,MediaListPlayer.deinit,PixelBufferRenderer, and the deinits inDialogHandler,RendererDiscoverer, andMediaDiscoverer.nonisolated(unsafe) let p = pointer DispatchQueue.global(qos: .utility).async { libvlc_media_player_release(p) }
-
Mutex<State>withState: @unchecked Sendable, for persistent storage of pointers that must be read or written from multiple threads.Mutex'ssendingsemantics requireStateto be sendable to the callee; markingState: @unchecked Sendablehonors that while theMutexitself provides the actual mutual exclusion. Used throughout the library — log-bridge installation state, thumbnail-request coordination, pixel-buffer renderer state, dialog-handler registration, broadcaster subscriber tables.private struct State: @unchecked Sendable { var selfBox: UnsafeMutableRawPointer? } private let state = Mutex(State())
Never use Int(bitPattern:) round-trips to launder pointers through Sendable. It loses type information and obscures intent.
Three-layer architecture bridging C callbacks to SwiftUI:
flowchart TB
subgraph L1["C Callbacks — libVLC thread"]
CB["Player event types attached via libvlc_event_attach<br/>playerEventCallback → mapEvent()"]
end
subgraph L2["AsyncStream Broadcasting — any thread"]
Store["EventBridge · Broadcaster<PlayerEvent><br/>Each makeStream() creates an independent consumer"]
end
subgraph L3["Observable Properties — @MainActor"]
Update["Player event consumer Task<br/>for await event → update @Observable props"]
end
CB -->|"yield to all continuations"| Store
Store -->|"consumed by"| Update
libVLC's player event types are attached to the event manager and mapped to typed Swift cases:
| Category | Swift Cases |
|---|---|
| State | stateChanged(PlayerState), mediaStopping, encounteredError |
| Time | timeChanged(Duration), positionChanged(Double), lengthChanged(Duration) |
| Capability | seekableChanged(Bool), pausableChanged(Bool) |
| Tracks | tracksChanged, mediaChanged |
| Buffering | bufferingProgress(Float) |
| Audio | volumeChanged(Float), muted, unmuted, corked, uncorked, audioDeviceChanged(String?) |
| Video | voutChanged(Int), snapshotTaken(String) |
| Chapters | chapterChanged(Int), titleListChanged, titleSelectionChanged(Int) |
| Recording | recordingChanged(isRecording:filePath:) |
| Programs | programAdded(Int), programDeleted(Int), programSelected(unselectedId:selectedId:), programUpdated(Int) |
Broadcaster<Element: Sendable> (in Sources/SwiftVLC/Core/Broadcaster.swift) consolidates this pattern for player events, log entries, dialog callbacks, renderer discovery events, and playback intent. Per-subscriber state (continuation, optional filter, lifecycle phase) lives under a single Mutex<State>, so registration is one lock acquisition. broadcast snapshots the matching subscribers under the lock and yields outside it. Yielding resumes a consumer task and acquires its status-record lock; a concurrent task cancellation holds that same lock and calls onTermination → unsubscribe → acquire Mutex, so yielding while holding the Mutex would produce an AB-BA deadlock.
final class Broadcaster<Element: Sendable>: Sendable {
func subscribe(bufferSize: Int? = nil, filter: Filter? = nil) -> AsyncStream<Element>
func broadcast(_ element: Element)
func finishAll() // closes current subscribers; allows resubscribe
func terminate() // closes current + auto-finishes future subscribes
}The terminate() / finishAll() distinction matters because some broadcasters expose subscribe indirectly via a computed property — e.g. DialogHandler.dialogs returns broadcaster.subscribe() per access. After a producer is permanently gone (handler whose registration was rejected, discoverer whose pointer is being released), terminate() ensures every future subscribe call returns an immediately-finished stream so consumers don't hang. finishAll() is reserved for lazy producers with onFirstSubscriber reconciliation that may re-attach later.
onFirstSubscriber and onLastUnsubscribed callbacks let lazy producers attach to and detach from their upstream source only when there is actual demand. Both run on a per-broadcaster serial reconciliation DispatchQueue so concurrent subscribe/unsubscribe storms can't double-fire either side. The reconciliation is state-machine driven (idle → scheduledOn → running → idle and the symmetric off cycle), so a fast unsubscribe after a subscribe still terminates with the right callback.
Player.init()creates theEventBridge(which owns aBroadcaster<PlayerEvent>) and attaches every event type to the libVLC event manager.startEventConsumer()inPlayer+Events.swiftspawns aTaskthat reads the bridge stream and updates the player's@Observableproperties.Player.deinitcancels the consumer task, then callsEventBridge.invalidate()to detach the C callbacks, finish the continuations, and release the store.
Every libVLC object follows the same pattern: init allocates → use passes pointer → deinit releases. Swift object lifetime owns C pointer lifetime.
| Swift Type | C Pointer | Alloc | Free |
|---|---|---|---|
VLCInstance |
libvlc_instance_t* |
libvlc_new |
libvlc_release |
Player |
libvlc_media_player_t* |
libvlc_media_player_new |
libvlc_media_player_release |
Media |
libvlc_media_t* |
libvlc_media_new_* |
libvlc_media_release |
MediaList |
libvlc_media_list_t* |
libvlc_media_list_new |
libvlc_media_list_release |
MediaListPlayer |
libvlc_media_list_player_t* |
libvlc_media_list_player_new |
libvlc_media_list_player_release |
MediaDiscoverer |
libvlc_media_discoverer_t* |
libvlc_media_discoverer_new |
libvlc_media_discoverer_release |
RendererDiscoverer |
libvlc_renderer_discoverer_t* |
libvlc_renderer_discoverer_new |
libvlc_renderer_discoverer_release |
RendererItem |
libvlc_renderer_item_t* |
libvlc_renderer_item_hold |
libvlc_renderer_item_release |
Equalizer |
libvlc_equalizer_t* |
libvlc_audio_equalizer_new |
libvlc_audio_equalizer_release |
For C callback contexts that need to bridge to Swift objects:
| Pattern | Use Case | Lifetime |
|---|---|---|
Unmanaged.passRetained |
Long-lived callback context or broadcaster box (EventBridge, logging, dialogs, renderer discovery) | Explicitly released in cleanup/deinit |
Unmanaged.passUnretained |
Short-lived reference (VideoSurface in set_nsobject) |
Object must outlive the call |
Ordering in Player.deinit is load-bearing: detaching the listeners before releasing the player prevents use-after-free when a callback fires during teardown.
- Cancel event consumer task
EventBridge.invalidate()- Detach the C event listeners
- Finish all
AsyncStreamcontinuations - Release retained store
libvlc_media_player_stop_async()libvlc_media_player_release()
sequenceDiagram
participant SwiftUI
participant VideoView
participant VideoSurface as VideoSurface<br/>(UIView / NSView)
participant libVLC
SwiftUI->>VideoView: makeUIView / makeNSView
VideoView->>VideoSurface: create
VideoSurface->>libVLC: set_nsobject(view pointer)
libVLC->>VideoSurface: adds rendering sublayer
loop On resize
VideoSurface->>VideoSurface: layoutSubviews() — sync sublayer frames
end
SwiftUI->>VideoView: dismantleUIView
VideoSurface->>libVLC: set_nsobject(nil)
There is no CALayer setup, no MTKView, and no AVPlayerLayer to configure. libVLC handles all rendering internally.
PiP rendering depends on the platform:
| Path | Pipeline |
|---|---|
| VideoView | set_nsobject → VLC renders directly into the view |
| PiPVideoView on iOS | set_nsobject drawable proxy → libVLC's iOS sample-buffer video output → libVLC-owned AVPictureInPictureController |
| Direct PiPController | vmem callbacks → CVPixelBuffer → CMSampleBuffer → SwiftVLC-owned AVSampleBufferDisplayLayer → AVKit PiP |
| PiPVideoView on macOS | SPI opt-in: set_nsobject drawable → same VLC-owned NSView is moved into the system PiP presenter |
PiPVideoView attaches a drawable proxy to the player. The proxy hosts
libVLC's inline rendering children and implements the Picture in Picture
selectors expected by VLC's bundled iOS video output. That output creates
and owns the AVPictureInPictureController, then passes its native window
controller back to SwiftVLC for start/stop, possibility, activity, playback
invalidation, and linear-playback policy. The view never hosts
PiPController.layer; that layer belongs exclusively to the direct path.
flowchart TB
subgraph PBR["PixelBufferRenderer — Mutex<State>"]
direction TB
F["1. formatCallback<br/>Set BGRA format, create CVPixelBufferPool"]
L["2. lockCallback<br/>Get buffer from pool, lock base address"]
U["3. unlockCallback<br/>Unlock base address"]
D["4. displayCallback<br/>Wrap as CMSampleBuffer, enqueue to layer"]
C["5. cleanupCallback<br/>Release pool"]
F --> L --> U --> D --> C
end
subgraph PiPStack["Display Stack"]
Layer["AVSampleBufferDisplayLayer"]
Controller["AVPictureInPictureController<br/>(managed by PiPController)"]
Layer --> Controller
end
D -->|"enqueues on dedicated serial queue"| Layer
The direct renderer requests 8-bit BGRA frames. Its optional resize pass uses a device-RGB Core Image target, so this path is SDR and does not preserve HDR or wide-color metadata.
libVLC copies vmem function pointers and their opaque value when a video
output opens. Clearing the media-player callback variables therefore does
not revoke a copy already held by that output. SwiftVLC ties each retained
opaque to one exact libvlc_media_player_t through
NativePlayerHandleLifetime:
- Sequential direct controllers on the same native handle reuse one stable handle slot. During format negotiation, each vout replaces its copied handle opaque with a separately retained vout context and decode pool. An overlapping output therefore cannot change another output's dimensions, pitch, pool, pending picture, or cleanup state. Each vout still resolves its display target through the handle slot, so a successor controller can atomically take over an already-open output.
- Replacing the native media-player handle creates a fresh slot for the new handle and permanently retires the old slot.
- Every successful lock pins exactly one buffer until matching display, a superseding successful lock, or that vout's cleanup. This balances the retain even when pinned vmem suppresses display after native picture construction fails. Cleanup drains only its own vout and releases that vout context exactly once.
- Each
MediaListPlayernative binding is a counted owner because pinned libVLC retains the samelibvlc_media_player_t. A rebuild synchronously rebinds the retiring list player to an independent neutral player before offloading its release, so it cannot advance or stop a successor's shared handle. Final shutdown waits for the initial owner and every counted list owner to release. - Retirement suppresses new display work, but releases the handle opaque
only after the final counted native release returns for that exact handle
and every handle callback already in flight has drained. No timeout or
transient
voutobservation is treated as proof of safety.
This per-handle rule prevents both use-after-free during teardown and stale controller teardown from clearing a successor's callbacks.
The direct AVKit playback delegate synchronously snapshots the current native handle and maps media state as follows:
| Native state | AVKit range |
|---|---|
| No media | Invalid |
Loaded media with length <= 0 |
Start zero, positive-infinite duration |
| Loaded media with positive length | Start zero, finite native duration |
An unknown duration is therefore valid live/indefinite content; rendering
does not wait for a finite length. Media, length, and seekability events
drive invalidation from their payloads. That is load-bearing because
Player and PiPController subscribe independently and either consumer
may observe the event first. Playback-state changes conservatively
invalidate as a payload-free fallback. Seekability payloads also synchronize
requiresLinearPlayback on the direct controller and iOS native backend.
- Timebase sync. Creates a
CMTimebaseand keeps it aligned with the player's state (playing, paused, or rate-shifted). - Playback delegation. Owns a
PiPPlaybackDelegateProxythat implementsAVPictureInPictureSampleBufferPlaybackDelegate, breaking AVKit's strong-retain cycle while routing PiP play, pause, and seek commands into thePlayer. - Range policy. Reports no-content, live/indefinite, and finite media distinctly, and invalidates from event payloads rather than stale observable mirrors.
- State observation. Distinguishes VLC-initiated state changes from pending PiP-initiated play/pause intent and keeps AVKit's linear-playback policy aligned with seekability.
- Deferred pause. Debounces transient AVKit pause signals so skips and PiP transitions do not issue an unsafe short-lived native pause.
- Audio policy. Configures the iOS playback category at construction but defers
setActive(true)until PiP start or active playback intent. Direct and inactive native construction do not take audio focus; native adoption of an already-active Player activates before publishing backend ownership so automatic PiP cannot outrun session setup. A failed attempt remains retryable at observed native did-start.
On macOS, the SPI native backend intentionally bypasses SwiftVLC's vmem
renderer. It gives libVLC a normal NSView drawable and deliberately does
not conform that view to VLC's sample-buffer PiP protocols. When the
PrivateMacOSPiP SPI opt-in is enabled, entering PiP loads macOS's PiP
presenter at runtime and reparents the exact VLC drawable view into the
floating PiP controller. That keeps video, audio, play/pause intent, and
time on one VLC timeline while avoiding the macOS AVKit sample-buffer
CALayerHost mirror that can crop at 1:1 layer size instead of scaling
into the PiP panel.
Only one rendering path may own a player at a time. On iOS,
PiPVideoView and VideoView both use set_nsobject, while a direct
PiPController replaces drawable rendering with vmem callbacks. A stable
same-handle callback slot makes sequential direct-controller handoff safe;
it does not make simultaneous drawable and vmem outputs supported. On
macOS, both SwiftUI views use set_nsobject, and the most recently attached
drawable owns VLC's native video output.
The iOS native backend deliberately reports PiP unavailable in Simulator.
Simulator AVKit can report an active sample-buffer PiP controller while the
system window remains black, so state-only simulator tests cannot validate
video delivery. End-to-end PiP rendering must be exercised on a physical
iOS device with the audio background mode enabled.
Operations that introduce a SwiftVLC failure use throws(VLCError):
func play() throws(VLCError) {
guard libvlc_media_player_play(pointer) == 0 else {
throw .playbackFailed(reason: "libVLC refused to start playback")
}
}
func parse(timeout: Duration) async throws(VLCError) -> Metadata {
// ...
throw .parseTimeout
}Scoped closure APIs such as withAdjustments, withMarquee, withLogo,
and MediaList.withLocked use rethrows. They introduce no library
failure and propagate an error thrown by the caller's closure unchanged.
| Error | When |
|---|---|
instanceCreationFailed |
libvlc_new returns nil |
mediaCreationFailed |
libvlc_media_new_* returns nil |
playbackFailed |
libvlc_media_player_play returns non-zero |
parseFailed |
Media parsing reports failure status |
parseTimeout |
Parsing exceeds specified timeout |
trackNotFound |
Track selection fails (invalid track ID) |
invalidState |
Operation attempted in wrong state |
invalidInput |
Public API argument is outside its documented range |
operationFailed |
Generic libVLC operation failure |
All errors conform to LocalizedError and CustomStringConvertible for logging and user-facing messages.
A comprehensive Swift Testing suite targets the public API surface. Package tests are integration tests against the real libVLC binary. Showcase UI tests use XCTest separately.
Tests/SwiftVLCTests/
├── Support/
│ ├── TestMedia.swift # Fixture URLs (bundled resources)
│ └── Tag.swift # Test tag definitions
├── Fixtures/
│ ├── test.mp4 # 1s, 64x64, with metadata
│ ├── twosec.mp4 # 2s, for seeking tests
│ ├── silence.wav # Audio-only
│ └── test.srt # Subtitle file
└── … # One test suite per domain area
| Tag | Purpose | Speed |
|---|---|---|
logic |
Pure Swift logic, no libVLC | Fast |
integration |
Requires VLCInstance |
Medium |
media |
Uses bundled fixture files | Medium |
mainActor |
Runs on @MainActor |
Medium |
async |
Async tests with timeout guards | Slow |
Integration tests with real libVLC. No mocking; every test creates actual Player and Media instances:
@Test(.tags(.integration, .media, .async))
func playAndWaitForState() async throws {
let player = Player()
try player.play(url: TestMedia.videoURL)
// Wait for state change...
}CI execution. GitHub Actions runs package tests on macos-latest
with Xcode latest-stable plus the Swift 6.3.1 open-source toolchain
from swift.org, invoked via xcrun --toolchain. Showcase builds run on
macos-26 with Xcode 26.4 because Xcode's built-in SwiftPM must parse
the Swift 6.3 manifest. The package test step is wrapped by
scripts/ci-run-with-timeouts.py, which enforces a 10-minute wall
clock and a 3-minute idle watchdog and sends SIGKILL to the process
group when either fires. Caches cover the libvlc xcframework, compiled
build products, and SPM dependency checkouts.
| Script | Purpose |
|---|---|
scripts/setup-dev.sh |
First step for local repo work. Downloads the last-released xcframework into Vendor/, flips Package.swift to the local-path form, and points the Showcase app at the repo-local Swift package. Flags: --force (re-download), --skip-download (only flip local references). |
scripts/build-libvlc.sh |
Compiles libVLC from VideoLAN source (pinned via VLC_HASH) into Vendor/libvlc.xcframework. Applies the local VLC source patches described in README. |
scripts/fix-duplicate-symbols.sh |
Localizes _json_parse_error and _json_read in the chromecast plugin, which two VLC plugins each emit. Called automatically by build-libvlc.sh and setup-dev.sh. |
scripts/release.sh |
Cuts a versioned release, uploads the xcframework asset, pins the Showcase app to that exact Swift package version, and advances main. |
scripts/ci-use-released-xcframework.sh |
CI-only. Rewrites the current Package.swift binaryTarget to the url+checksum of the latest release tag. Run at CI job start so tests resolve against the same binary a downstream consumer would. |
scripts/ci-run-with-timeouts.py |
CI-only. Wraps swift test with wall-clock and idle timeouts; SIGKILLs the process group if either fires. |
Published states (main and release tags) carry the remote form of the libvlc binaryTarget:
.binaryTarget(
name: "libvlc",
url: "https://github.com/harflabs/SwiftVLC/releases/download/vX.Y.Z/libvlc.xcframework.zip",
checksum: "<sha256>"
)That keeps the repository's default package state aligned with what downstream SPM consumers resolve. Local repo development flips the manifest back to the on-disk xcframework with ./scripts/setup-dev.sh, which rewrites only the libvlc binaryTarget to:
.binaryTarget(name: "libvlc", path: "Vendor/libvlc.xcframework")The Showcase apps follow the same split: published states pin SwiftVLC by exact release version, while setup-dev.sh rewrites the Xcode project to use the repo-local package checkout.
| Context | What binaryTarget looks like |
Where the xcframework comes from |
|---|---|---|
Published main |
url: + checksum: of the latest release |
GitHub Release asset |
Local dev after setup-dev.sh |
path: "Vendor/libvlc.xcframework" |
setup-dev.sh (download) or build-libvlc.sh (build) |
| CI | rewritten in-memory to url: + checksum: of the latest release |
SPM resolves + caches (keyed on checksum) |
Release tag vX.Y.Z |
url: + checksum: |
release.sh uploads the zip as a release asset at that URL |
SPM consumer pinning X.Y.Z |
Reads the tag's Package.swift |
SPM resolves + verifies checksum + caches |
release.sh creates a real release commit that stays on main.
flowchart LR
BUILD["build-libvlc.sh --all"] --> XCF["Vendor/libvlc.xcframework<br/>(unstripped)"]
XCF --> RELEASE["release.sh X.Y.Z"]
RELEASE --> VERIFY["Verify all required slices present"]
VERIFY --> STRIP["strip -S"]
STRIP --> ZIP["ditto -c -k"]
ZIP --> SUM["swift package compute-checksum"]
SUM --> COMMIT["Commit on main:<br/>Package.swift → url+checksum<br/>Showcase → exactVersion X.Y.Z"]
COMMIT --> TAG["git tag vX.Y.Z"]
TAG --> PUSH_TAG["git push origin vX.Y.Z"]
PUSH_TAG --> GH["gh release create<br/>+ attached .zip"]
GH --> PUSH_MAIN["git push origin HEAD:main"]
PUSH_MAIN --> SPM["main and consumers resolve the same release"]
Preflight refuses releases from non-main branches, uncommitted changes in Package.swift or the Showcase project, pre-existing local or remote tags, and unauthenticated gh. If a pre-commit rewrite or post-write sanity check fails, the script restores Package.swift and the Showcase project before exiting. The tag is pushed before main, so if GitHub Release creation fails, origin/main still points at the previous good release; finish the release or delete the tag before retrying. A post-write regex guard verifies that the rewritten Package.swift still contains the CLibVLC target, catching a malformed replacement before the tag is cut.
After publishing a tag, verify that Swift Package Index has completed its asynchronous documentation build and that the unversioned documentation URL resolves to the new release rather than the preceding tag.
| Workflow | Trigger | Purpose |
|---|---|---|
test.yml |
Push to main / PR |
Lints sources, builds all Showcase schemes, runs package tests with coverage, and checks public API doc coverage. |
sanitize.yml |
Push to main, selected PR paths, weekly schedule |
Runs race, stress, memory, and lifecycle tests under Thread Sanitizer and Address Sanitizer. |
fixtures.yml |
Push to main / selected PR paths |
Builds the layered dynamic-host fixtures and verifies that applications load exactly one copy of libVLC. |
vendor-manifest.yml |
Push to main / binary-manifest PR paths |
Verifies every xcframework slice against its checked-in archive-member manifest. |
claude.yml |
Issue comment / PR mention | Claude Code bot integration. |
SwiftVLC/
├── Sources/
│ ├── CLibVLC/ # C bridging layer
│ │ ├── include/vlc/ # libVLC 4.0 C headers
│ │ └── shim.c # va_list formatting shim
│ │
│ └── SwiftVLC/ # Main library
│ ├── Core/ # VLCInstance, VLCError, Logging, Duration
│ ├── Player/ # Player, EventBridge, PlayerState, Events, ABLoop, etc.
│ ├── Media/ # Media, Metadata, Track, Thumbnails, Statistics
│ ├── Audio/ # AudioOutput, Equalizer, ChannelModes
│ ├── Video/ # VideoView, AspectRatio, Adjustments, Marquee, Logo, Viewpoint
│ ├── Playlist/ # MediaList, MediaListPlayer, PlaybackMode
│ ├── Discovery/ # MediaDiscoverer, RendererDiscoverer
│ └── PiP/ # PiPController, PiPVideoView, PixelBufferRenderer
│
├── Tests/SwiftVLCTests/ # Swift Testing suite, one file per domain
│ ├── Support/ # TestMedia fixtures, Tag definitions
│ └── Fixtures/ # Bundled media files (~50 KB)
│
├── Showcase/ # Platform showcase apps
│ ├── Shared/ # Launch-arg/accessibility contracts, app icon, and shared showcase resources
│ ├── iOS/ # Full-featured iOS target/scheme, also enabled for Mac Catalyst
│ ├── macOS/ # Native macOS target/scheme with Mac-tailored showcases
│ ├── tvOS/ # Native tvOS target/scheme with TV-tailored showcases
│ ├── visionOS/ # Native visionOS target/scheme with focused playback coverage
│ └── UITests/
│ ├── iOS/ # UI tests for the iOS/Catalyst showcase
│ ├── macOS/ # Native macOS UI tests
│ └── tvOS/ # Empty tvOS UI-test target shell
│
├── Vendor/ # libvlc.xcframework (multi-GB unstripped; release zip a few hundred MB)
│
├── scripts/
│ ├── build-libvlc.sh # Compile libvlc from source
│ ├── setup-dev.sh # Download xcframework for local dev
│ ├── release.sh # Cut a versioned release and advance main
│ ├── fix-duplicate-symbols.sh # Localize duplicate json symbols
│ ├── ci-use-released-xcframework.sh # CI: point Package.swift at latest release
│ ├── ci-run-with-timeouts.py # CI: wall-clock + idle test timeouts
│ └── patches/ # Ordered VLC source patches applied by the build
│
├── .github/workflows/
│ ├── test.yml # CI test runner
│ ├── sanitize.yml # Sanitizer test runner
│ ├── fixtures.yml # Layered-consumer single-copy gate
│ ├── vendor-manifest.yml # XCFramework archive-member gate
│ └── claude.yml # Claude Code bot integration
│
├── Package.swift # SPM manifest (Swift 6.3+)
├── .swiftlint.yml # Lint configuration
├── .swiftformat # Format: 2-space indent
└── README.md # User guide