SwiftUI Meets Video: Building a Native macOS Player
Most video players on macOS use AppKit or even cross-platform frameworks. HorangPlayer is built entirely with SwiftUI for the interface layer. Here's how we made it work — and why it matters.
Why SwiftUI?
IINA uses AppKit with Cocoa bindings and XIB files. VLC uses a custom cross-platform toolkit. We chose SwiftUI because:
- Declarative UI — Describe what the interface looks like, not how to build it
- Reactive state — UI updates automatically when data changes
- Modern macOS design — Built-in support for vibrancy, Dark Mode, and system conventions
- Less code — SwiftUI eliminates boilerplate that AppKit requires
The result is a clean, responsive interface that feels native to macOS.
The @Observable Architecture
HorangPlayer uses Swift's @Observable macro (not the older ObservableObject protocol) for state management. This means fine-grained observation — SwiftUI only re-renders views that depend on properties that actually changed.
The central PlaybackEngine class is the single source of truth:
@MainActor @Observable
final class PlaybackEngine {
var state: PlayerState = .idle
var currentTime: Double = 0
var duration: Double = 0
var mediaTitle: String = ""
var volume: Float = 1.0
// ... more observable state
}Every SwiftUI view reads directly from PlaybackEngine. No bindings, no publishers, no Combine pipelines. When currentTime changes, only views displaying the time re-render. When volume changes, only the volume slider updates.
Composition Over Inheritance
PlaybackEngine doesn't subclass anything. Instead, it composes 16 independent managers:
PlaybackEngine
├── decoder (MediaDecoder)
├── osd (OSDManager)
├── windowState (WindowState)
├── subtitles (SubtitleManager)
├── playlist (PlaylistManager)
├── bookmarks (BookmarkManager)
├── history (PlaybackHistoryManager)
├── filters (FilterManager)
├── hdr (HDRManager)
├── nowPlaying (NowPlayingManager)
├── sleepPreventer (SleepPreventer)
├── abLoop (ABLoopManager)
├── pip (PiPManager)
├── thumbnails (ThumbnailGenerator)
├── keyBindings (KeyBindingManager)
└── perFileStore (PerFileSettingsStore)Each manager is independently testable and replaceable. The engine orchestrates them without any manager needing to know about the others.
Strict Concurrency
HorangPlayer uses Swift Strict Concurrency (SWIFT_STRICT_CONCURRENCY: complete). Every class is annotated with its isolation domain:
- `@MainActor` — PlaybackEngine, all managers, SwiftUI views
- `nonisolated` — Render callbacks, mpv event handlers
- `@unchecked Sendable` — MetalRenderer (GPU work on render thread)
- `nonisolated(unsafe)` — C callback bridges, display link
This catches concurrency bugs at compile time. The mpv integration required careful annotation since mpv callbacks fire from arbitrary threads:
// mpv event handler — fires on eventQueue, not main thread
nonisolated private func handlePropertyChange(_ name: String, _ prop: mpv_event_property) {
// Read the property on eventQueue
let time = data.assumingMemoryBound(to: Double.self).pointee
// Dispatch to main actor for state update
DispatchQueue.main.async { [weak self] in
self?.currentTime = time
}
}SwiftUI + CAOpenGLLayer
One challenge: mpv renders via OpenGL, but SwiftUI doesn't directly support OpenGL views. Our solution:
- MPVVideoNSView — An
NSViewsubclass that hosts aCAOpenGLLayer - MPVVideoView — An
NSViewRepresentablethat bridges to SwiftUI - PlayerView — SwiftUI view that conditionally shows mpv or Metal surface
@ViewBuilder
private var videoSurface: some View {
if engine.decoder.backendType == .mpv {
MPVVideoView(engine: engine)
} else {
MetalVideoView(engine: engine)
}
}The video surface is always present in the view hierarchy (just with opacity: 0 when idle) so the OpenGL context exists before mpv starts loading a file.
The UI Components
Every control is a SwiftUI view:
- ControlBar — Play/pause, seek, volume, playlist toggle
- SeekBar — Precision seeking with thumbnail preview on hover
- OSD — Animated on-screen display for volume, seek, speed changes
- SubtitleOverlay — Positioned text with background (AVFoundation path)
- SidePanel — Playlist and filter adjustments
- SettingsView — All preferences in a native sheet
All of these react to PlaybackEngine state changes automatically. No manual UI refresh, no delegate callbacks, no notification observers.
~6,200 Lines of Code
The entire application — engine, renderers, UI, features, infrastructure — is about 6,200 lines of Swift code (plus one Metal shader file). For comparison, IINA is over 50,000 lines. SwiftUI's declarative approach dramatically reduces the code needed to build a full-featured video player.