Kajsa Lindqvist
Kajsa LindqvistNov 2, 2025

Building a Custom Video Player in SwiftUI with AVKit

Learn how to build a custom video player in SwiftUI using AVKit. This comprehensive guide covers video playback, custom controls, picture-in-picture, and performance optimization for macOS and iOS.
Building a video player in SwiftUI

Video playback is a fundamental feature in many modern apps. Whether you're building a media player, educational app, or video editor, understanding how to implement custom video playback in SwiftUI is essential. In this comprehensive tutorial, you'll learn how to build a feature-rich video player using AVKit that works seamlessly on both macOS and iOS.

By the end of this guide, you'll have a production-ready video player with custom controls, picture-in-picture support, and optimized performance.

What We'll Build

Our custom video player will include:

  • Basic playback controls (play, pause, seek)
  • Custom UI that matches your app's design
  • Picture-in-Picture support for multitasking
  • Playback speed controls for user convenience
  • Looping capabilities for background videos
  • Performance optimization for smooth playback

Let's get started!


Understanding AVKit and AVFoundation

Before diving into code, let's understand the frameworks we'll use:

AVFoundation

AVFoundation is Apple's low-level framework for working with time-based audiovisual media. It provides:

  • AVPlayer: The core player object
  • AVPlayerItem: Represents media to be played
  • AVAsset: Represents media resources

AVKit

AVKit provides high-level UI components built on AVFoundation:

  • VideoPlayer (SwiftUI): Simple, built-in video player
  • AVPlayerViewController (UIKit/AppKit): Full-featured player with standard controls

We'll use AVFoundation for control and AVKit for display.


Part 1: Basic Video Player Setup

Let's start with a minimal video player implementation.

Creating the Basic Player View

VideoPlayerView.swift
import SwiftUI
import AVKit
 
struct VideoPlayerView: View {
    let videoURL: URL
    @State private var player: AVPlayer?
 
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                setupPlayer()
            }
            .onDisappear {
                cleanup()
            }
    }
 
    private func setupPlayer() {
        let playerItem = AVPlayerItem(url: videoURL)
        player = AVPlayer(playerItem: playerItem)
    }
 
    private func cleanup() {
        player?.pause()
        player = nil
    }
}

Using the Player

ContentView.swift
struct ContentView: View {
    var body: some View {
        VideoPlayerView(
            videoURL: URL(string: "https://example.com/video.mp4")!
        )
        .frame(height: 400)
    }
}

This basic implementation gives you a working video player with built-in controls. But let's go deeper and create custom controls.


Part 2: Creating Custom Video Controls

The built-in VideoPlayer is convenient but limited. Let's build custom controls for complete design freedom.

Custom Player with AVPlayerLayer

First, we need to bridge AVPlayer to SwiftUI using UIViewRepresentable/NSViewRepresentable:

CustomVideoPlayer.swift
import SwiftUI
import AVKit
 
#if os(macOS)
struct CustomVideoPlayer: NSViewRepresentable {
    let player: AVPlayer
 
    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.videoGravity = .resizeAspect
        view.layer = playerLayer
        view.wantsLayer = true
        return view
    }
 
    func updateNSView(_ nsView: NSView, context: Context) {
        guard let playerLayer = nsView.layer as? AVPlayerLayer else { return }
        playerLayer.player = player
    }
}
#else
struct CustomVideoPlayer: UIViewRepresentable {
    let player: AVPlayer
 
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.videoGravity = .resizeAspect
        view.layer.addSublayer(playerLayer)
        return view
    }
 
    func updateUIView(_ uiView: UIView, context: Context) {
        if let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer {
            playerLayer.player = player
            playerLayer.frame = uiView.bounds
        }
    }
}
#endif

Creating a Player View Model

Managing player state is cleaner with a view model:

VideoPlayerViewModel.swift
import AVFoundation
import Combine
 
@Observable
class VideoPlayerViewModel {
    var player: AVPlayer?
    var isPlaying = false
    var currentTime: Double = 0
    var duration: Double = 0
    var volume: Float = 1.0
    var playbackRate: Float = 1.0
 
    private var timeObserver: Any?
    private var cancellables = Set<AnyCancellable>()
 
    func setupPlayer(url: URL) {
        let playerItem = AVPlayerItem(url: url)
        player = AVPlayer(playerItem: playerItem)
 
        // Observe playback status
        setupObservers()
    }
 
    private func setupObservers() {
        guard let player = player else { return }
 
        // Observe current time
        let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
        timeObserver = player.addPeriodicTimeObserver(
            forInterval: interval,
            queue: .main
        ) { [weak self] time in
            self?.currentTime = time.seconds
        }
 
        // Observe duration
        player.currentItem?.publisher(for: \.duration)
            .sink { [weak self] duration in
                self?.duration = duration.seconds
            }
            .store(in: &cancellables)
 
        // Observe playback state
        player.publisher(for: \.timeControlStatus)
            .sink { [weak self] status in
                self?.isPlaying = (status == .playing)
            }
            .store(in: &cancellables)
    }
 
    // MARK: - Playback Controls
 
    func play() {
        player?.play()
        player?.rate = playbackRate
    }
 
    func pause() {
        player?.pause()
    }
 
    func togglePlayPause() {
        isPlaying ? pause() : play()
    }
 
    func seek(to time: Double) {
        let cmTime = CMTime(seconds: time, preferredTimescale: 600)
        player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero)
    }
 
    func setVolume(_ volume: Float) {
        self.volume = volume
        player?.volume = volume
    }
 
    func setPlaybackRate(_ rate: Float) {
        self.playbackRate = rate
        if isPlaying {
            player?.rate = rate
        }
    }
 
    func restart() {
        seek(to: 0)
        play()
    }
 
    // MARK: - Cleanup
 
    func cleanup() {
        if let timeObserver = timeObserver {
            player?.removeTimeObserver(timeObserver)
        }
        player?.pause()
        player = nil
        cancellables.removeAll()
    }
 
    deinit {
        cleanup()
    }
}

Building Custom Controls UI

Now let's create a beautiful control interface:

VideoPlayerControlsView.swift
import SwiftUI
 
struct VideoPlayerControlsView: View {
    @Bindable var viewModel: VideoPlayerViewModel
    @State private var isShowingControls = true
    @State private var hideControlsTask: Task<Void, Never>?
 
    var body: some View {
        ZStack {
            // Video Player
            if let player = viewModel.player {
                CustomVideoPlayer(player: player)
                    .onTapGesture {
                        toggleControls()
                    }
            }
 
            // Controls Overlay
            if isShowingControls {
                controlsOverlay
                    .transition(.opacity)
            }
        }
        .onAppear {
            scheduleHideControls()
        }
    }
 
    private var controlsOverlay: some View {
        VStack {
            Spacer()
 
            // Timeline
            timelineView
 
            // Control Buttons
            HStack(spacing: 30) {
                // Play/Pause Button
                Button(action: viewModel.togglePlayPause) {
                    Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill")
                        .font(.title)
                        .foregroundColor(.white)
                }
 
                // Restart Button
                Button(action: viewModel.restart) {
                    Image(systemName: "gobackward")
                        .font(.title2)
                        .foregroundColor(.white)
                }
 
                Spacer()
 
                // Playback Speed
                playbackSpeedMenu
 
                // Volume Control
                volumeControl
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [.clear, .black.opacity(0.7)],
                startPoint: .top,
                endPoint: .bottom
            )
        )
    }
 
    private var timelineView: some View {
        VStack(spacing: 8) {
            // Progress Slider
            Slider(
                value: Binding(
                    get: { viewModel.currentTime },
                    set: { viewModel.seek(to: $0) }
                ),
                in: 0...max(viewModel.duration, 0.1)
            )
            .tint(.white)
 
            // Time Labels
            HStack {
                Text(formatTime(viewModel.currentTime))
                    .font(.caption)
                    .foregroundColor(.white)
 
                Spacer()
 
                Text(formatTime(viewModel.duration))
                    .font(.caption)
                    .foregroundColor(.white)
            }
        }
        .padding(.horizontal)
    }
 
    private var playbackSpeedMenu: some View {
        Menu {
            ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { speed in
                Button("\(speed, specifier: "%.2f")x") {
                    viewModel.setPlaybackRate(Float(speed))
                }
            }
        } label: {
            HStack(spacing: 4) {
                Text("\(viewModel.playbackRate, specifier: "%.2f")x")
                Image(systemName: "chevron.up.chevron.down")
            }
            .font(.caption)
            .foregroundColor(.white)
        }
    }
 
    private var volumeControl: some View {
        HStack {
            Image(systemName: viewModel.volume > 0 ? "speaker.wave.2.fill" : "speaker.slash.fill")
                .foregroundColor(.white)
 
            Slider(
                value: Binding(
                    get: { Double(viewModel.volume) },
                    set: { viewModel.setVolume(Float($0)) }
                ),
                in: 0...1
            )
            .frame(width: 100)
            .tint(.white)
        }
    }
 
    // MARK: - Helper Methods
 
    private func toggleControls() {
        withAnimation(.easeInOut(duration: 0.3)) {
            isShowingControls.toggle()
        }
        if isShowingControls {
            scheduleHideControls()
        }
    }
 
    private func scheduleHideControls() {
        hideControlsTask?.cancel()
        hideControlsTask = Task {
            try? await Task.sleep(for: .seconds(3))
            if !Task.isCancelled && viewModel.isPlaying {
                withAnimation {
                    isShowingControls = false
                }
            }
        }
    }
 
    private func formatTime(_ timeInSeconds: Double) -> String {
        guard !timeInSeconds.isNaN && !timeInSeconds.isInfinite else {
            return "0:00"
        }
 
        let minutes = Int(timeInSeconds) / 60
        let seconds = Int(timeInSeconds) % 60
        return String(format: "%d:%02d", minutes, seconds)
    }
}

Using the Custom Player

ContentView.swift
struct ContentView: View {
    @State private var viewModel = VideoPlayerViewModel()
 
    var body: some View {
        VideoPlayerControlsView(viewModel: viewModel)
            .frame(height: 500)
            .onAppear {
                viewModel.setupPlayer(
                    url: URL(string: "https://example.com/video.mp4")!
                )
            }
    }
}

Part 3: Advanced Features

Looping Video Playback

Perfect for background videos or animations:

extension VideoPlayerViewModel {
    func enableLooping() {
        NotificationCenter.default.addObserver(
            forName: .AVPlayerItemDidPlayToEndTime,
            object: player?.currentItem,
            queue: .main
        ) { [weak self] _ in
            self?.restart()
        }
    }
}

Picture-in-Picture Support

Enable PiP for multitasking:

PictureInPictureManager.swift
import AVKit
 
class PictureInPictureManager: NSObject, AVPictureInPictureControllerDelegate {
    private var pipController: AVPictureInPictureController?
 
    func setupPiP(with playerLayer: AVPlayerLayer) {
        if AVPictureInPictureController.isPictureInPictureSupported() {
            pipController = AVPictureInPictureController(playerLayer: playerLayer)
            pipController?.delegate = self
        }
    }
 
    func togglePiP() {
        guard let pipController = pipController else { return }
 
        if pipController.isPictureInPictureActive {
            pipController.stopPictureInPicture()
        } else {
            pipController.startPictureInPicture()
        }
    }
 
    // Delegate methods
    func pictureInPictureControllerWillStartPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        print("PiP will start")
    }
 
    func pictureInPictureControllerDidStopPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        print("PiP did stop")
    }
}

Background Audio Support

Allow audio to continue when app is in background:

AudioSessionManager.swift
import AVFoundation
 
class AudioSessionManager {
    static func configureForVideoPlayback() {
        do {
            #if os(iOS)
            try AVAudioSession.sharedInstance().setCategory(
                .playback,
                mode: .moviePlayback,
                options: []
            )
            try AVAudioSession.sharedInstance().setActive(true)
            #endif
        } catch {
            print("Failed to setup audio session: \(error)")
        }
    }
}

Part 4: Performance Optimization

Preloading Videos

extension VideoPlayerViewModel {
    func preloadVideo(url: URL) {
        let asset = AVURLAsset(url: url)
        let keys = ["playable", "duration"]
 
        Task {
            do {
                let status = try await asset.load(.isPlayable)
                if status {
                    await MainActor.run {
                        self.setupPlayer(url: url)
                    }
                }
            } catch {
                print("Failed to preload: \(error)")
            }
        }
    }
}

Memory Management

extension VideoPlayerViewModel {
    func pauseAndReleaseResources() {
        pause()
        player?.replaceCurrentItem(with: nil)
    }
 
    func handleMemoryWarning() {
        if !isPlaying {
            pauseAndReleaseResources()
        }
    }
}

Optimal Player Configuration

private func configurePlayer() {
    guard let player = player else { return }
 
    // Optimize buffering
    player.automaticallyWaitsToMinimizeStalling = true
 
    // Configure for low latency if needed
    if let currentItem = player.currentItem {
        currentItem.preferredForwardBufferDuration = 2.0
    }
}

Part 5: Complete Implementation Example

Putting it all together in a production-ready player:

ProductionVideoPlayer.swift
import SwiftUI
 
struct ProductionVideoPlayer: View {
    let videoURL: URL
    let autoPlay: Bool
    let loop: Bool
 
    @State private var viewModel = VideoPlayerViewModel()
 
    init(
        videoURL: URL,
        autoPlay: Bool = false,
        loop: Bool = false
    ) {
        self.videoURL = videoURL
        self.autoPlay = autoPlay
        self.loop = loop
    }
 
    var body: some View {
        VideoPlayerControlsView(viewModel: viewModel)
            .onAppear {
                setupPlayer()
            }
            .onDisappear {
                viewModel.cleanup()
            }
    }
 
    private func setupPlayer() {
        viewModel.setupPlayer(url: videoURL)
 
        if loop {
            viewModel.enableLooping()
        }
 
        if autoPlay {
            viewModel.play()
        }
    }
}
 
// Usage
struct ExampleView: View {
    var body: some View {
        ProductionVideoPlayer(
            videoURL: URL(string: "https://example.com/video.mp4")!,
            autoPlay: true,
            loop: true
        )
        .frame(height: 400)
        .cornerRadius(12)
        .shadow(radius: 10)
    }
}

Best Practices and Tips

1. Error Handling

Always handle loading and playback errors:

player.currentItem?.publisher(for: \.status)
    .sink { status in
        switch status {
        case .failed:
            if let error = player.currentItem?.error {
                print("Playback failed: \(error)")
                // Show error UI
            }
        case .readyToPlay:
            print("Ready to play")
        case .unknown:
            print("Unknown status")
        @unknown default:
            break
        }
    }
    .store(in: &cancellables)

2. Network Monitoring

Check connectivity before streaming:

import Network
 
class NetworkMonitor {
    private let monitor = NWPathMonitor()
    var isConnected = false
 
    func startMonitoring() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.isConnected = (path.status == .satisfied)
        }
        monitor.start(queue: .global(qos: .background))
    }
}

3. Accessibility

Make your player accessible:

Button(action: viewModel.togglePlayPause) {
    Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill")
}
.accessibilityLabel(viewModel.isPlaying ? "Pause" : "Play")
.accessibilityHint("Double tap to \(viewModel.isPlaying ? "pause" : "play") video")

Conclusion

You've now built a feature-complete, production-ready video player in SwiftUI! This implementation includes:

✅ Custom playback controls ✅ Timeline scrubbing ✅ Playback speed control ✅ Volume management ✅ Picture-in-Picture support ✅ Looping capabilities ✅ Performance optimization ✅ Memory management ✅ Error handling

Next Steps

  • Add gesture controls (swipe for volume/brightness)
  • Implement subtitle support
  • Add quality selection for adaptive streaming
  • Create thumbnail previews for seeking
  • Implement analytics tracking

For apps that need professional video wallpaper capabilities at scale, check out how we implemented optimized video playback in Backdrop, where we achieve 0.3% CPU usage for 4K video wallpapers.

Happy coding! 🎬