- All posts
- Binding libVLC directly from Swift 6
Binding libVLC directly from Swift 6
Four and a half months, twenty-five patches carried against VLC's own source, and one feature I still cannot ship. What it takes to own a C engine instead of consuming one.
Tilfaz is an IPTV client. It ships on the App Store, on iPhone, iPad, Mac and Apple TV. 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. “AVFoundation can’t do IPTV” is the sort of claim that gets you corrected within the hour by somebody who has shipped a flawless HLS app and never had a reason to feed it anything else.
So take the smallest case: one MP4 carrying H.264 video and AAC audio, remuxed
to Matroska with -c copy. The elementary streams come out bit-identical — I
checked the hashes. The MP4 decodes. The MKV returns -11828, this media
format is not supported. There is no Matroska demuxer, so the question of
what is inside is never asked.
- decoded
- opens, tracks missing
- refused — −11828
| fixture | macOS 26 | iOS 26 |
|---|---|---|
| base.mp4H.264 · AAC | ||
| h264_aac.mkvsame streams, remuxed | ||
| vp9_opus.webmVP9 · Opus | ||
| h264_aac.flvH.264 · AAC | ||
| h264_ac3.mp4Dolby Digital | ||
| h264_aac.tsH.264 · AAC | ||
| h264_mp2.tsMPEG-1 Layer II | ||
| hevc_aac.tsHEVC · AAC | no video | |
| h264_dts.tsH.264 · DTS | no audio | |
| h264_opus.tsH.264 · Opus | no audio |
Every partial success reports isPlayable == true and raises nothing. HEVC in a transport stream is ordinary in IPTV.
The refusals are the cheap half: a loud failure is one you can handle. The
expensive rows are the last three. HEVC inside a transport stream opens on
macOS, reports isPlayable == true, raises nothing, and hands you an asset
with no video track — and HEVC in a transport stream is ordinary in IPTV. A
viewer does not see an unsupported-format error. They see a channel that plays
sound over a black rectangle, and they tell you your app is broken, and they
are right.
Much later, building demo apps for the library that came out of all this, I needed one file that exercised everything a player ought to cope with. It ended up as sixty seconds of Matroska carrying three video variants, three Opus audio tracks, three subtitle tracks — one of them Arabic, right to left — six named chapters and an attached cover image. Every one of those properties is unreachable through AVFoundation. I built it as a demo. It is really the gap, written down as a file.
I was already using VLCKit
So the engine had to be libVLC — VideoLAN’s playback core, the same C API that VLC itself runs on. The established Apple wrapper for it is VLCKit, also VideoLAN’s own, written in Objective-C. I was already using it.
VLCKit is a faithful record of the decade it was written in. I mean that
straight: the delegate protocol, the notification names, the id drawable you
assign a view to — that was how you built a media framework in 2013, and a
great deal of software you rely on was built the same way and works.
I did not leave over the idiom. I left over Picture-in-Picture, and the reason
is duller than a design argument. VLCKit 3 does not have it. There is no PiP
type, no PiP protocol, no VLCDrawable.h in that line at all — the feature
arrives in VLCKit 4, and VLCKit 4 has been in alpha since September 2022 and
ships from a download path with the word unstable in it. So the actual
choice was not modern Swift against good-enough Objective-C. It was: take a
production dependency on a four-year-old alpha to get one feature, or bind the
C API underneath it myself. Everything downstream of that — the direct C
binding, the lifetime rules, most of the patches — follows from it.
Where the wrapper is ahead
- Call path
- Swift 6, C bound directly
- Objective-C — no Swift in the sources
- Concurrency
- Main-actor player, Sendable media
- None. Callbacks land on libVLC's thread
- Events
- An AsyncStream each, buffered per lane
- One delegate, plus NotificationCenter
- Errors
- Typed throws, nine cases
- No error type — nil, BOOL, a thread-local string
- SwiftUI
- A view ships
- Write your own representable
Where VLCKit is ahead
- Linkage
- A static archive in every slice
- A dynamic framework — static dropped in 4.0
- LGPL relink route
- None that an App Store app can take
- The conventional one, and VideoLAN's own
- Crash reports
- Stripped — libVLC symbolicates to nothing
- Debug symbols ship inside the framework
- Reach
- iOS 18 up. Catalyst, which VLCKit lacks
- iOS 12, macOS 10.13, watchOS
- Engine
- libVLC 4 — never released stable
- Also a line on released libVLC 3
- Track record
- One app
- VLC for iOS, on this alpha, today
- Hands on it
- One
- VideoLAN, on sources dating to 2007
Both carry their own patch set against VLC — VLCKit 27, SwiftVLC 25. Patching the engine is the ordinary cost of shipping libVLC on Apple platforms, not a distinguishing feature of either.
VLCKit 4 is not standing still. It added visionOS and watchOS, rewrote its event handling, gained subsecond seeking, and picked up Swift Package Manager support two weeks before I wrote this. Comparing against the 3.x README, which is what its own repository still shows you, would be a straw man.
And the unreleased engine is mine, not theirs. I am on libVLC 4, which has never had a stable release; VLCKit maintains a line on released libVLC 3 and is still fixing that line. If you need an engine that has shipped, there is exactly one option in this comparison and it is not mine.
There is an open Blocker on VideoLAN’s tracker, filed in July by someone who is not me: a second input on a reused player never re-attaches its video output. Audio playing behind a permanently black picture, on tvOS, on live MPEG-TS from an IPTV panel. That is channel switching. That is the workload.
The attempt before the attempt
In December 2025 I published
harflabs/VLC. Four commits in one
evening, and then never again.
It wrapped VLCKit, not libVLC. It took the three Objective-C frameworks that
VLCKit 3.7.0 ships, one per platform, and republished them as SPM binary
targets behind a platform-conditional umbrella — VLCKit 3.x has no
Package.swift at all, and I wanted to type a URL into a manifest like a
normal person.
It is archived now. The reason is visible in its own manifest, which declares
swift-tools-version: 6.0 and then wraps a framework whose public headers
contain not one concurrency annotation. Packaging turned out to be the easy
half. I had made VLCKit resolvable and changed nothing about the fact that a
Swift 6 SwiftUI app now owned an unannotated, single-delegate object model.
SwiftVLC started in February and hit 1.0 in July. Four and a half months, and here is where they went.
Bind C directly, and accept the cost
The first decision was to go from C to Swift with no Objective-C in between.
The table above already made that case. What it cannot show is that 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 layer you construct, no MTKView, no
AVPlayerLayer.
The cost is that you now own every C lifetime rule yourself, with no ARC in
between. 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.
Two ways to move a pointer
OpaquePointer and UnsafeMutableRawPointer do not conform to Sendable.
Not as an oversight and not as a consequence of region-based isolation: the
standard library declares the conformance unavailable, on purpose, because
the compiler cannot reason about what a pointer points at. Regions are what
occasionally rescue you by letting a disconnected value transfer between
isolation domains. They do not help here, because capturing into an escaping
@Sendable closure is not a transfer, and releasing a C object off the main
thread is exactly that capture.
SwiftVLC allows two ways out. 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 read and written from several
threads over time, a Mutex around a state struct, with the struct marked
@unchecked Sendable and the mutex, not the compiler, doing the actual work.
There is a third way, and the architecture document bans it: 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 codebase breaks that rule in exactly one place. A qualification path
stores a player address as a UInt and converts it straight back, and the
field’s own doc comment offers the banned justification — stored as bits
because OpaquePointer is not Sendable. I have left it there, and named it
here. A rule with one exception you can point at and a rule nobody enforces
any more look identical from outside.
Lifetimes nothing checks
Events cross three isolation domains: C callbacks firing on libVLC’s own
threads, a multi-consumer broadcaster, then @Observable properties on the
main actor.
Broadcaster.broadcast snapshots the subscribers under the lock, then runs
their filters and yields outside it. One line.
Two separate things go wrong if you yield inside. A subscriber’s filter is
user code. If that code touches the broadcaster again — subscribes, asks
whether it is empty, broadcasts — it deadlocks against a mutex that is not
recursive. And while it waits, it is still holding a lock libVLC’s own event
thread is blocked on. Separately, onTermination runs synchronously on
whatever thread canceled the consuming task, and it calls unsubscribe, which
wants that same mutex. A cancellation landing mid-broadcast becomes one thread
taking a lock it is already holding.
There is a regression test for this now, and it exists because the fix is one line and somebody could move it back. There was not one when the bug was written. Deadlocks of this shape do not turn up in a suite that finishes while you are still watching it; they turn up in the hands of someone scrubbing a live stream on a train.
The same type carries two ways to close. finishAll() closes the current
subscribers and allows resubscribe; terminate() closes them and makes every
future subscribe return an immediately-finished stream. The second exists
because some broadcasters are reached through a computed property that builds
a fresh subscription on each access, so a subscriber arriving after the
producer is gone has to get a finished stream rather than a live one nobody
will ever feed. Without it, the failure is not a crash. It is an await that
never returns, which is much worse to find.
One callback also has to become two streams, because libVLC mixes two kinds of event on it and only one of them can be dropped safely.
one C callback, on libVLC's thread
control24 cases
Media identity, capability, lifecycle, terminal outcomes.
Each reports something that happened once. Nothing later re-states it, so a dropped one is lost information.
unboundedMemory grows with consumer lag times the control rate, not the clock rate.
timing4 cases
The playback clock, buffer fill, render counters.
Each supersedes the one before it, so dropping a backlog costs resolution and nothing else.
newest 4A lagging consumer skips stale samples and still receives the newest.
timeChanged alone fires around 30 Hz. Through one bounded buffer, a consumer stalled for two seconds loses whatever was queued behind it — a mediaChanged, an endReached.
The limit it does not solve
Newest-4 is across the timing lane, not one slot per kind, so a long enough burst of the fastest kind can still evict the newest of a slower one. What the split does guarantee is the half that matters: nothing dropped can ever be a one-shot control event, because control events are not in that buffer.
Teardown looks like four steps in a fixed order. It is not. The order falls out of one question asked at each node: what is still reading this?
All three constraints live in one comment above one function, which is the only thing enforcing them.
A pointer is not an identity
This is the idea I would most want to have been told before starting, and it is the one I arrived at last.
A Player can replace the libVLC player underneath it while the Swift object
and its event streams stay alive. When that happens, callbacks already in
flight are still going to arrive, and they are going to arrive describing
something that no longer exists. The instinct is to compare pointers. The
instinct is wrong, and the source says why in one line: native pointer
addresses are not identities — an allocator may reuse a retired address for a
later handle.
So every layer that can be replaced under a live consumer gets a monotonic counter, and every event carries the values it was born with.
I did not design this up front. It accreted, one recurring bug class at a time, and when I audited the library after 1.0 the single largest group of findings was some version of this callback is about the previous thing. If I were starting again I would reach for it on the first day, and I suspect that is true of anyone binding a C library whose objects outlive the calls that make them.
Where the wrapper stops being a wrapper
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 that output is
already holding. The obvious teardown — unset the callbacks, then free the
context — is 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. Retiring an opaque suppresses new display work immediately,
but it 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. With C callbacks,
“it has probably finished by now” is not a lifetime.
Then there are four rendering paths, and they differ mainly in who owns the object at the end.
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
That last row is the decision I am least comfortable with and most confident
about. On macOS, the public sample-buffer mirror crops at 1:1 layer size
instead of scaling into the PiP panel, on the macOS releases I support — the
video is simply wrong on screen. The path that works is a private framework:
load PIPViewController out of PIP.framework at runtime and reparent VLC’s
real drawable view into it.
None of which means VideoLAN left PiP unsolved. libVLC ships a Picture-in-Picture controller and both wrappers use it. But it is gated to iOS and tvOS in VLC’s own build files, the source file says as much in its header, and the class is simply absent from the macOS slice of the binary I ship. There is no native macOS path in the engine to use.
So the private-framework path ships behind an explicit opt-in, and every call into a private Apple symbol 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 this touches should be able to read one file and be done.
The iOS backend does something similar in miniature: it reports PiP unavailable in the Simulator, deliberately. 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 tick.
And then the abstraction leaks the other way. Getting the geometry I needed out of the vmem path meant adding entry points to libVLC that libVLC does not have — which means shipping an engine whose public C surface I have extended, and then negotiating with archives built before I invented it.
the handshake
swiftvlc_libvlc_pip_extensions_version()
returns 0
a released archive that predates the symbols
- weak definitions in the Swift shim — they exist only so the link succeeds
- fall back to the public format callbacks
- crop and pixel aspect cannot be proven
returns 3
an archive built from the patched tree
- the engine's own strong definitions win
- the extended format callback, with geometry captured atomically
- coded size, visible size, crop and aspect arrive together or not at all
What each revision of the extension added
- 1geometry-aware vmem setup, and a media-and-length snapshot taken under the player lock
- 2a playback snapshot as a distinct type, so a newer archive cannot write past storage a older client allocated
- 3overlay composition — version-gated only, with no weak stub to fall back to
When you stop reading the headers
At some point you stop reading a C engine’s headers and start reading its source, and shortly after that you start changing it.
When they arrived
- shipped with 1.0
- written in the nine days after it
What they touch
- 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
Where they came from
- 9reproduce upstream commits
- 40 distinct ones, named in the patch headers — two of the nine disclose adaptations to the pin
- 16written here
- original fixes, new C API surface, and build repairs upstream has no reason to carry
What they land on
- 56
- VLC files modified
- 6
- files createda public header, three sample-buffer geometry headers, and two regression tests added to VLC's own suites
Version 1.0 shipped with six patches. The other nineteen landed twelve days later, in a single eight-day run. 1.0 was the point at which I stopped working around the engine and started fixing it.
Some are backports of upstream fixes that landed after my pin. The
player-timer series is the clearest: 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. That patch carries three upstream
refactors it depends on, verbatim, rather than hand-adapting the two real
fixes onto the older shape — one of the three is what turns the interpolation
flag into the enum the real fix adds a case to. A rebase you can re-derive
beats a rewrite you have to re-verify.
Others are mine. One is a double free in VLC’s adaptive demuxer.
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.
A one-line commit in 2022 copied a recycle call up from the branch immediately
below, where recycling is exactly right: createChunk() returned nothing
there, so no chunk exists to own the source. One branch up a chunk does exist,
and the copy became a second release. Somebody moved a correct line one branch
too far. It is still in VLC master today, and there is an open
report on
VideoLAN’s tracker describing an intermittent EXC_BAD_ACCESS on iOS arm64
after roughly ten minutes of HLS playback, with a stack that lands on the
destructor.
The ten minutes follow from the mechanism. prepareChunk() only does work for
encrypted segments, and only fails when the key cannot be resolved. Keys are
cached. So it bites at key rotation or after cache eviction rather than at
startup — which is exactly the fine for ten minutes, then crash profile in
the report.
Encrypted segments only, only when an AES-128 key fails to resolve, flaky network, real device, ten minutes in. That bug is not going to fail your test suite. It did not fail mine, either. Somebody else hit it first, on the same engine commit I pin, and filed it upstream. I found the defect by reading the ownership graph, then reproduced it deterministically. My patch adds a regression test to VLC’s own adaptive suite; it counts releases instead of freeing, so the double release trips an assertion instead of undefined behavior.
I wrote the demos before I wrote the library
I built the demo apps first — one per shipping platform, driving every feature I meant to ship, against that sixty-second Matroska — and the test suite alongside them. Both were written as though the library underneath already worked. It did not exist yet. What existed was an executable statement of what working meant: a screen that plays a stream carrying three audio tracks and lets you move between them, a Picture-in-Picture window that survives a seek, a playlist that advances without losing an event on the way.
Then the agents closed the gap. They built and ran the apps and the suite, hundreds of times over, on simulators where volume was the point and on real hardware for the things that only fail there. They read the crash logs and opened pull requests. Most of the code in this library came out of that loop, written against a definition of done that already existed and could be checked mechanically.
What changed is where my hours went. They stopped going into implementation and started going into specification — into saying what correct meant precisely enough that something other than me could decide whether it had been reached. The issues in that repository are where the work went, and never in the title. It is the acceptance criteria underneath that took the time: ten thousand active media replacements under a sanitizer, with late metadata and teardown callbacks. A seek soak on a physical device, with allocation provenance retained. One combined row covering both PiP backends, so that a passing backend cannot hide a failing one. Writing that well is harder than writing the fix, and it is the part that does not delegate.
A loop that runs hundreds of times is worth exactly as much as its ability to say no, and I learned that the expensive way. A batch of tests I had already accepted turned out to contain sixteen that could never reach the condition they were waiting on. They had been passing for months without testing anything. A model will produce a plausible fix and a plausible test for it in the same pass, and neither is worth anything until you break the fix and watch the test fail.
So everything else I built exists to make that check cheap. Anything load-bearing carries a red-green note in its commit body, recording that the fix was reverted and the test watched failing. A coverage check reports which patches the binary under test does not contain, because CI links a released engine, and a patch in the tree is not a patch in the artifact. And the release gate says what it is: this does not run the tests — a person does, on hardware. What it enforces is that the results exist, that every required row was executed and passed, and that they describe this artifact rather than an earlier one.
The boundary sat in the same place every time. Volume and patience delegate completely; ownership does not. Lock order, the moment a C object actually dies, what AVKit expects of you that nobody ever wrote down — on those I would get back something reasonable and wrong. The favorite wrong answer was a timeout where the real answer was a rule about who owns what. A timeout passes. It fails a month later, on someone else’s network. Reading VLC’s source until the mechanism was understood is what I could not hand over. It is also what produced the patches.
Both halves were necessary, and the double free is the proof — in the direction I did not expect. The loop did not find it. Somebody else’s crash report did, against the commit I pin, and closing it meant sitting with VLC’s source until the vtable dispatch made sense. What the loop bought was everything downstream: the deterministic reproduction, the sanitizer run, the four hundred passes that showed the fix held. Diagnosis was mine. Proof was not.
What it cost
Four and a half months to 1.0, and it is still being paid.
The patch set costs the most. An ordered series against a pinned revision
means a rebase every time the pin moves — and pinning does not even buy a
stable world. One of those patches exists solely because a newer Autoconf
handed the C compiler a different standard flag from the one it handed the
Objective-C compiler. libtool could not infer a tag, and every Apple slice
died in test/, after libvlccore and libvlc had both linked. The same sources
built a fortnight earlier.
The engine ships as a static archive, which adds licensing obligations as well as engineering work. libVLC uses LGPL 2.1. With static linking, providing object files that let users relink against a modified library is one route to compliance. GNU’s licensing FAQ explains that requirement. Choosing a package format alone does not settle an application’s distribution obligations.
An open issue asks for a dynamic xcframework to make those requirements easier to address. At the time of writing I had not resolved it: I expect a larger binary, and am still weighing that against the distribution I want to support.
Static linking also creates an engineering problem: the process can contain more than one copy of the engine.
why it mattersThe engine ships as a static archive, and a static archive has no identity at load time. Two images that each link it produce two complete libVLC runtimes in one process — two plugin registries, and fifteen unnamespaced Objective-C classes defined twice over, one of which is called simply AoutWrapper. Nothing fails to link. The runtime picks one of each and does not say which.
the supported shape
- App
- FeatureAstatic
- FeatureBstatic
- MediaCoredynamic
- SwiftVLC
- libvlc.a
what CI counts
_libvlc_new
| loaded image | defines it |
|---|---|
| App executable | 0 |
| FeatureA | 0 |
| FeatureB | 0 |
| MediaCore.framework | 1 |
Exactly one, and it has to be that one.
The rest, briefly. The macOS PiP path that renders correctly cannot go in the App Store, which means the best version of a feature I built is one I cannot ship. The engine ships stripped of debug symbols and without a dSYM, so a crash inside libVLC gives everyone shipping it a function name and no line number. And physical-device qualification turned into a fifty-three-row matrix and a harness whose own test file is longer than anything I wrote in the library — because the things that break here break on hardware, and nothing else is admissible as proof.
If you are considering wrapping a large C library, budget for its source rather than its headers. The API surface looks like the work. It is bounded by the header, and it is the same shape every time. 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 sits in a repository you can edit.
I stopped consuming the engine and started owning it, and an engine you own hands you its licensing, its packaging and its bugs together, whether or not you wrote any of them. That is the trade. I would make it again on Monday. But it is a standing obligation, not a milestone you pass.
There is no total to give you. Picture-in-Picture on iOS still shuts itself off on a plain seek — open on the tracker as I write this, with a fix merged and not yet proven on hardware, reported by somebody I have never met. It is the feature I left VLCKit to get. Two dozen patches into someone else’s engine, and the thing that started all of this is the thing still open.
If you’ve worked across a similar Swift and C boundary, tell me which assumption broke first. A small reproducer or a different approach would be welcome. Share this with someone considering a direct binding; I’d like to hear what their experience adds to mine.