Building a Custom Video Player in SwiftUI with AVKit
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 objectAVPlayerItem: Represents media to be playedAVAsset: Represents media resources
AVKit
AVKit provides high-level UI components built on AVFoundation:
VideoPlayer(SwiftUI): Simple, built-in video playerAVPlayerViewController(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
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
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:
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
}
}
}
#endifCreating a Player View Model
Managing player state is cleaner with a view model:
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:
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
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:
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:
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:
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.
Related Reading
Happy coding! 🎬
Stay in the loop.
Get exclusive deals and invitations to try out our new app releases.