What wrapping libVLC actually cost
SwiftVLC began as a video player for an IPTV app and ended with twenty-five patches against VLC's own source. Notes on C interop, Swift 6 isolation, and the places where a wrapper stops being a wrapper.
Tilfaz is an IPTV client. IPTV means whatever the provider decided to send: MPEG-TS over UDP, HLS manifests that go stale between segments, MKV containers, SSA subtitles carrying their own styling, audio in codecs Apple has never shipped a decoder for. AVFoundation is very good at the formats Apple ships. That set and this set overlap in maybe half the cases, and the half that misses is not the half you can apologise for.
So the engine had to be libVLC —
VideoLAN’s playback core, exposed as a C API, which is what VLC itself runs on.
The established Apple wrapper is
VLCKit:
Objective-C, delegates, KVO, NSNotificationCenter, manual thread management.
None of that is bad work. It is a faithful artifact of the decade it was
written in. But dropping it into a SwiftUI app under strict concurrency means
writing an adapter on top of an adapter, and the adapter layer is where the
bugs live.
My first attempt was not a wrapper at all. In December 2025 I published
harflabs/VLC, a Swift package whose entire
job was making a prebuilt libVLC binary resolvable by SPM. Ship the xcframework,
pin a version, move on. It is archived now, because packaging turned out to be
the easy half. SwiftVLC started in
February, hit 1.0 in July, and is currently 101 source files, 170
test files, and twenty-five patches against VLC’s own source — which I will get
to, because they are the most interesting thing in the repository.
one mark per published tag
156 days
v0.1.0 — first tag, day the repo opened. The short marks are 1.1 betas.
What follows is the set of decisions I would have wanted to read about before starting.
Bind C directly, and accept the bill
The first decision was to go from C to Swift with no Objective-C in between.
That buys real things. Failures become throws(VLCError), typed and
exhaustive, instead of NSError codes you match on by hand. Player state
becomes @Observable, so SwiftUI updates without a KVO bridge. Events become
AsyncStream<PlayerEvent> with multiple independent consumers instead of
notification names. Rendering becomes one view:
struct PlayerView: View {
@State private var player = Player()
var body: some View {
VideoView(player)
.onAppear { try? player.play(url: streamURL) }
}
}
VideoView hands libVLC an NSView/UIView through set_nsobject and VLC
renders into it directly. No CALayer setup, no MTKView, no AVPlayerLayer.
The bill is that you now own every C lifetime rule yourself, with no
ARC-shaped layer to hide behind. Every libVLC object follows the same
pattern — init allocates, deinit releases, Swift object lifetime owns C
pointer lifetime — and that part is mechanical. The interesting failures all
happen at the seams.
The pointer problem Swift 6 hands you
OpaquePointer and UnsafeMutableRawPointer are not Sendable under
region-based isolation. That is correct of the compiler and inconvenient
constantly, because releasing a C object off the main thread means capturing a
pointer in a @Sendable closure. SwiftVLC allows exactly two ways to do it.
For a capture into a single closure, a local binding that opts out:
nonisolated(unsafe) let p = pointer
DispatchQueue.global(qos: .utility).async {
libvlc_media_player_release(p)
}
The pointer is trivially transferable and stays valid for the enclosing scope,
which is the whole argument. For pointers that must be read and written from
several threads over time, a Mutex whose state is explicitly unchecked:
private struct State: @unchecked Sendable {
var selfBox: UnsafeMutableRawPointer?
}
private let state = Mutex(State())
Mutex’s sending semantics want the state sendable to the callee;
@unchecked honours that while the mutex does the actual excluding.
There is a third way, and it is banned in this codebase: laundering a pointer
through Int(bitPattern:) and back. It compiles, it silences the diagnostic,
and it destroys the two things that made the diagnostic worth having — the type
and the intent. Six months later nobody reading that function can tell a live
pointer from an integer, and the compiler has been talked out of helping. The
rule is worth stating in the
architecture document
precisely because the shortcut is so easy to reach for at 1 a.m.
Events, and the deadlock at the bottom of them
Events cross three layers: C callbacks firing on libVLC’s own threads, a
multi-consumer broadcaster, then @Observable properties on the main actor.
The middle layer is a small generic type,
Broadcaster<Element: Sendable>,
shared by player events, log entries, dialog callbacks, renderer discovery, and
playback intent.
The part worth writing down is one line of its implementation: broadcast
snapshots the matching subscribers under the lock and yields outside it.
That is not a performance choice. Yielding into an AsyncStream resumes a
consumer task, which acquires that task’s status-record lock. A concurrent
cancellation of the same task holds that lock already and calls
onTermination, which calls unsubscribe, which wants the broadcaster’s
mutex. Yield while holding the mutex and you have AB-BA: two locks, two
threads, opposite order. It is the kind of deadlock that never appears under
test and appears immediately in the hands of someone scrubbing a live stream on
a train.
The same type carries a distinction that looks like over-engineering until it
doesn’t. finishAll() closes the current subscribers and allows resubscribe.
terminate() closes them and makes every future subscribe call return an
immediately-finished stream. Both exist because some broadcasters are reached
through a computed property — handler.dialogs builds a fresh subscription on
each access. When the producer behind it is permanently gone, a subscriber that
arrives late must get a finished stream rather than a live one nobody will ever
feed. Without terminate(), the failure mode is not a crash. It is an await
that never returns, which is much worse to diagnose.
Deinit order is part of the API
Player.deinit does four things in one order that is not negotiable:
- cancel the event-consumer task
EventBridge.invalidate()— detach the C listeners, finish the continuations, release the retained storelibvlc_media_player_stop_async()libvlc_media_player_release()
Detaching the listeners before releasing the player is what prevents a callback firing into freed memory during teardown. Swap steps 2 and 4 and everything still compiles, the tests still pass, and the crash reports arrive weeks later from users whose network dropped at the wrong moment. This is the least glamorous kind of design decision and among the most load-bearing, which is why it is written down rather than left for a reader to infer.
Where the wrapper stops being a wrapper
Picture-in-Picture is where the abstraction stopped being mine.
The governing fact is that libVLC copies your video-memory callback pointers
and their opaque context when a video output opens. Clearing the callback
variables on the media player afterwards does not revoke a copy already held by
that output. The obvious teardown — unset the callbacks, then free the
context — is therefore a use-after-free waiting for a vout that has not
finished with you yet.
The fix ties every retained opaque to one exact libvlc_media_player_t, so
sequential controllers on the same native handle can hand over atomically while
an overlapping output can never touch another output’s dimensions, pool, or
cleanup state. Retirement suppresses new display work immediately, but the
opaque is released only after the final counted native release for that handle
returns and every callback already in flight has drained. No timeout, and no
transient vout observation, is treated as proof of safety. That sentence is
the whole policy: with C callbacks, “it has probably finished by now” is not a
lifetime.
PiP also forced the one decision I am least comfortable with and most confident
about. On iOS, PiPVideoView uses libVLC’s own native drawable path and the
system PiP controller it owns; a directly constructed PiPController instead
installs public vmem callbacks and drives an AVSampleBufferDisplayLayer. On
macOS, the public sample-buffer mirror crops at 1:1 layer size instead of
scaling into the PiP panel — the video is simply wrong on screen. The working
path is a private framework: load PIPViewController out of PIP.framework at
runtime and reparent VLC’s real drawable view into it.
VideoViewall platforms
set_nsobjectNSView / UIView
VLC draws into your view
PiPVideoViewiOS
drawable proxyVLC's iOS sample-buffer voutAVPictureInPictureController
controller owned by libVLC
PiPControllerdirect, public API
vmem callbacksCVPixelBufferCMSampleBufferAVSampleBufferDisplayLayer
layer owned by SwiftVLC · 8-bit BGRA, SDR
PiPVideoViewmacOS, SPI opt-in
set_nsobjectVLC's own NSViewPIPViewController
private PIP.framework · off by default
So it ships, disabled by default, behind an explicit allowsPrivateMacOSAPI
opt-in, and every private-symbol reference in the library lives in exactly one
file. Not because that makes it App Store safe — it does not, and that is the
point of the opt-in — but because an auditor asking “what private API does this
library touch?” should be able to read one file and be done, rather than trust a
grep across a hundred.
The same honesty principle shows up in a smaller place: the iOS native PiP backend deliberately reports PiP unavailable in the Simulator. Simulator AVKit will happily report an active sample-buffer PiP controller while the system window stays black. A test that passes against that is not a passing test, it is a lie with a green checkmark, so end-to-end PiP has to be exercised on a physical device.
Twenty-five patches
The thing nobody tells you about building on a large C engine: at some point you stop reading its headers and start reading its source, and shortly after that you start changing it.
scripts/patches/
holds twenty-five ordered patches applied against a pinned VLC revision at build
time, with a checksum manifest.
- 25
- patches
- 4,606
- lines changed
- 56
- VLC files touched
- Apple video output & PiP
- libVLC C API
- Input & player core
- Demuxers — MP4, TS, HLS
- Build & test scaffolding
- Chromecast & stream out
- UPnP discovery
- avcodec
patches touching this area
Provenance, from each patch's own header
- 9reproduces an upstream commit unchanged
- 16written for SwiftVLC
Some are backports of upstream fixes that landed after the pin. Patch 12
is the player-timer series:
on the pinned revision the timer interpolates past the pause point, so a paused
player keeps reporting a time that advances. That surfaces directly as
Player.currentTime, and PiP’s control timebase is placed from the same
reading, so the visible symptom is a paused video with a drifting scrubber. The
patch carries two upstream refactors it depends on, verbatim, rather than
hand-adapting the two real fixes onto the older shape — a rebase you can
re-derive beats a rewrite you have to re-verify.
Others are original.
Patch 8
fixes a use-after-free when an encrypted segment’s chunk cannot be prepared: ISegment::toChunk() releases the chunk source twice
on the prepareChunk() failure path, once explicitly through
recycleSource(), and again when it deletes the chunk, because
~AbstractChunk() releases the source the chunk owns. For segment chunks the
recycle is not cacheable and goes straight to delete, so the second release
dispatches through a vtable on freed memory. It reproduces under
AddressSanitizer, and the crash site matches
an existing VLC report
of intermittent EXC_BAD_ACCESS on iOS arm64 after roughly ten minutes of HLS
playback.
Read the conditions on that one: encrypted segments only, and only when the AES-128 key fails to resolve. Flaky network, real device, ten minutes in. That bug does not exist in your test suite. It exists in your reviews.
Ship the thing your users actually resolve
Two infrastructure decisions earned their keep.
The published state of the repository — main and every release tag — carries
the remote form of the binary dependency, url: plus checksum:, so the
default state of the repo is the state a downstream consumer resolves. Local
development flips that to an on-disk path with a script, and CI rewrites it
back to the latest released xcframework before running tests. The tests
therefore run against the same binary a stranger gets from SPM, not the one
sitting in my Vendor/ directory.
And the tests use no mocks at all. Every test creates a real Player and real
Media against the real libVLC binary and about 50 KB of fixture media.
Mocking a media engine tests your understanding of the media engine, which is
precisely the thing under suspicion. The cost is that a media test does not
fail when it goes wrong — it hangs. So CI wraps the run in a watchdog with a
ten-minute wall clock and a three-minute idle timer that SIGKILLs the process
group when either fires. A separate workflow builds layered dynamic-host
fixtures and asserts that an app ends up loading exactly one copy of libVLC,
which is the sort of thing you only think to check after it has gone wrong
once.
Was it worth it
Five months from first commit to 1.0, for a library whose interesting part is maybe fifteen of its hundred-odd files. All fifteen sit on a boundary between ownership models that disagree: C’s manual lifetimes, Swift 6’s compile-time isolation, and AVKit’s undocumented expectations about who retains whom. The rest is typing.
If you are considering wrapping a large C library, the honest advice is to budget for its source rather than its headers. The API surface is a weekend of work per module and mostly writes itself. What costs months is the part where the engine’s assumptions and your language’s guarantees are both correct and incompatible, and only one of them can be changed by editing your own repository.