---
title: "What wrapping libVLC actually cost"
description: "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."
author: "Omar Albeik"
date: 2026-08-03
type: essay
topics: [software, engineering]
language: en
reading_time_minutes: 11
canonical_url: https://albeik.dev/en/blog/what-wrapping-libvlc-actually-cost
translation_url: https://albeik.dev/ar/blog/what-wrapping-libvlc-actually-cost
source_url: https://albeik.dev/en/blog/what-wrapping-libvlc-actually-cost.md
---

# What wrapping libVLC actually cost

[Tilfaz](https://tilfaz.app) 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](https://www.videolan.org/vlc/libvlc.html) —
VideoLAN's playback core, exposed as a C API, which is what VLC itself runs on.
The established Apple wrapper is
[VLCKit](https://code.videolan.org/videolan/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`](https://github.com/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](https://github.com/harflabs/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.

<Figure
  caption="Every published tag, placed on the real calendar: twenty of them in 156 days, with 1.0 arriving on day 136 and six of the twenty inside a single week in April. Read from the GitHub releases API on 3 August 2026."
>
  <ReleaseCadence locale="en" />
</Figure>

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:

```swift
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:

```swift
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:

```swift
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](https://github.com/harflabs/SwiftVLC/blob/main/ARCHITECTURE.md)
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>`](https://github.com/harflabs/SwiftVLC/blob/main/Sources/SwiftVLC/Core/Broadcaster.swift),
shared by player events, log entries, dialog callbacks, renderer discovery, and
playback intent.

<Figure
  lockDirection
  caption="An event crosses two isolation boundaries before SwiftUI sees it. The second one is where the design lives: the broadcaster runs on whatever thread libVLC happened to be on, and each subscriber gets its own stream rather than a shared one."
>
  <EventPath locale="en" />
</Figure>

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.

<Figure
  lockDirection
  caption="Two locks, two threads, opposite acquisition order. Neither side is doing anything unusual — one is delivering an event, the other is cancelling a task — which is exactly why the cycle is easy to write and hard to see."
>
  <LockCycle locale="en" />
</Figure>

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:

1. cancel the event-consumer task
2. `EventBridge.invalidate()` — detach the C listeners, finish the
   continuations, release the retained store
3. `libvlc_media_player_stop_async()`
4. `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.

<Figure
  caption="Four rendering paths. Read them for ownership rather than for boxes: on iOS the PiP controller belongs to libVLC and is handed back, in the direct path the layer belongs to SwiftVLC, and the only macOS path that renders correctly belongs to a private framework."
>
  <PiPPaths locale="en" />
</Figure>

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/`](https://github.com/harflabs/SwiftVLC/tree/main/scripts/patches)
holds twenty-five ordered patches applied against a pinned VLC revision at build
time, with a checksum manifest.

<Figure
  caption="Where the twenty-five patches land, counted by parsing each diff rather than by reading its title — a patch is counted once per area it modifies, so the bars sum to more than twenty-five. Provenance comes from each patch's own header: nine reproduce an upstream commit unchanged, and the remaining sixteen were written here. Together they change 4,606 lines across 56 files of VLC."
>
  <PatchMap locale="en" />
</Figure>

Some are backports of upstream fixes that landed after the pin. [Patch 12](https://github.com/harflabs/SwiftVLC/blob/main/scripts/patches/0012-player-timer-pause-clock.patch)
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](https://github.com/harflabs/SwiftVLC/blob/main/scripts/patches/0008-adaptive-segment-double-recycle.patch)
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](https://code.videolan.org/videolan/vlc/-/issues/29845)
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.
