All posts

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:

  1. Declarative UI — Describe what the interface looks like, not how to build it
  2. Reactive state — UI updates automatically when data changes
  3. Modern macOS design — Built-in support for vibrancy, Dark Mode, and system conventions
  4. 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:

  1. MPVVideoNSView — An NSView subclass that hosts a CAOpenGLLayer
  2. MPVVideoView — An NSViewRepresentable that bridges to SwiftUI
  3. 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.