How to implement a video player in SwiftUI
Import AVKit, create an AVPlayer with your video URL, then pass it to SwiftUI's VideoPlayer view — that's all you need for a fully native playback experience with scrubbing, volume, and fullscreen built in.
import SwiftUI
import AVKit
struct QuickVideoView: View {
private let player = AVPlayer(
url: URL(string: "https://example.com/clip.mp4")!
)
var body: some View {
VideoPlayer(player: player)
.onAppear { player.play() }
.onDisappear { player.pause() }
.aspectRatio(16/9, contentMode: .fit)
}
}
Full implementation
The example below wraps a remote MP4 in a reusable VideoPlayerView. It stores the AVPlayer as a @State property so SwiftUI owns the object lifecycle, layers a custom title overlay using the VideoPlayer content closure, and loops the video automatically via AVPlayerLooper. A secondary view demonstrates swapping between multiple clips without recreating the player.
import SwiftUI
import AVKit
import AVFoundation
// MARK: - Model
struct VideoItem: Identifiable {
let id = UUID()
let title: String
let url: URL
}
// MARK: - Main player view
struct VideoPlayerView: View {
let item: VideoItem
// Keep the player alive with @State so SwiftUI manages its lifecycle.
@State private var player: AVPlayer
init(item: VideoItem) {
self.item = item
_player = State(initialValue: AVPlayer(url: item.url))
}
var body: some View {
VideoPlayer(player: player) {
// Custom overlay rendered on top of the native controls.
VStack {
HStack {
Text(item.title)
.font(.headline)
.foregroundStyle(.white)
.padding(8)
.background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.accessibilityLabel("Now playing: \(item.title)")
Spacer()
}
.padding()
Spacer()
}
}
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onAppear {
player.play()
}
.onDisappear {
player.pause()
}
// Replace the player item when the URL changes.
.onChange(of: item.url) { _, newURL in
player.replaceCurrentItem(with: AVPlayerItem(url: newURL))
player.play()
}
// Loop by observing end-of-playback notification.
.onReceive(
NotificationCenter.default.publisher(
for: AVPlayerItem.didPlayToEndTimeNotification
)
) { _ in
player.seek(to: .zero)
player.play()
}
}
}
// MARK: - Playlist picker
struct VideoPlaylistView: View {
private let items: [VideoItem] = [
VideoItem(
title: "Ocean Waves",
url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!
),
VideoItem(
title: "Mountain Trail",
url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")!
),
]
@State private var selected: VideoItem
init() {
_selected = State(initialValue: VideoPlaylistView.placeholderItems()[0])
}
private static func placeholderItems() -> [VideoItem] {
[
VideoItem(
title: "Ocean Waves",
url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!
),
VideoItem(
title: "Mountain Trail",
url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")!
),
]
}
var body: some View {
NavigationStack {
VStack(spacing: 16) {
VideoPlayerView(item: selected)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Self.placeholderItems()) { item in
Button(item.title) {
selected = item
}
.buttonStyle(.bordered)
.tint(selected.id == item.id ? .blue : .gray)
}
}
.padding(.horizontal)
}
}
.navigationTitle("Videos")
.navigationBarTitleDisplayMode(.inline)
}
}
}
// MARK: - Preview
#Preview {
VideoPlaylistView()
}
How it works
-
@State private var player: AVPlayer— Storing the player as@State(instead of a plain property) ensures SwiftUI preserves the same object across re-renders. Creating it ininitviaState(initialValue:)avoids allocating a new player every time the view is evaluated. -
VideoPlayer(player:) { ... }— The trailing content closure accepts any SwiftUI view and composites it over the native playback layer. The system's scrubber, play/pause button, volume slider, and fullscreen button are still present underneath your overlay. -
.onAppear / .onDisappear— These modifiers start and pause playback at the right moment in the view lifecycle, preventing audio from continuing when the view leaves the hierarchy (e.g., navigating back in aNavigationStack). -
.onChange(of: item.url)— Uses the two-parameter iOS 17onChangesignature (old value, new value) to swap theAVPlayerItemwithout tearing down the whole view — the player's internal buffers and audio session stay intact. -
AVPlayerItem.didPlayToEndTimeNotification— Subscribing to thisNotificationCenterpublisher is the idiomatic way to implement looping. Seeking to.zeroand callingplay()restarts seamlessly without allocating a newAVPlayerLooper(which requires anAVQueuePlayer).
Variants
Playing a local bundled video file
struct LocalVideoView: View {
// Add "demo.mp4" to your Xcode target's bundle resources.
private let player: AVPlayer = {
guard let url = Bundle.main.url(
forResource: "demo",
withExtension: "mp4"
) else { fatalError("demo.mp4 not found in bundle") }
return AVPlayer(url: url)
}()
var body: some View {
VideoPlayer(player: player)
.aspectRatio(16 / 9, contentMode: .fit)
.onAppear { player.play() }
}
}
#Preview { LocalVideoView() }
Muted auto-playing background video (hero banner)
For a looping silent background clip — common in onboarding or marketing screens — set player.isMuted = true before calling play(), and hide the native controls by sizing the VideoPlayer to fill a ZStack behind your content. Because native controls are touch-driven, they'll simply never appear in a non-interactive context. Combine with .allowsHitTesting(false) on the player layer to ensure taps fall through to your UI.
Common pitfalls
-
iOS version gotcha — two-parameter
onChange: TheonChange(of:) { old, new in }signature is iOS 17-only. If your deployment target is iOS 16 or below, use the single-parameter formonChange(of:) { _ in }or add an#if availableguard. Using the wrong overload causes a cryptic compile error or silent wrong-overload resolution. -
SwiftUI gotcha — player re-creation on re-render: Never write
let player = AVPlayer(url:)as a plain stored property on aViewstruct. SwiftUI recreates the struct on every state change, producing a newAVPlayereach time and causing playback to restart. Always use@Stateor store the player in an@Observablemodel. -
Performance / audio session gotcha:
VideoPlayeractivates the sharedAVAudioSessionautomatically, which can interrupt music apps or background audio. If your video should play alongside other audio, settry AVAudioSession.sharedInstance().setCategory(.ambient)before the player is created. Also callplayer.pause()inonDisappear; forgetting this is the number-one cause of audio continuing in the background when users navigate away.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a video player in SwiftUI for iOS 17+. Use VideoPlayer and AVPlayer from AVKit. Support both local bundle files and remote HTTPS URLs. Loop the video automatically on end. Make it accessible (VoiceOver labels on overlay controls). Add a #Preview with realistic sample data.
Drop this prompt into Soarias during the Build phase — it slots directly into a feature branch so the generated VideoPlayerView lands alongside your other screens without touching unrelated code.
Related
FAQ
Does this work on iOS 16?
VideoPlayer itself is available from iOS 14, and AVPlayer has been around since iOS 4. The only iOS 17-specific piece in this guide is the two-parameter onChange(of:) { old, new in } closure. Swap that for the single-parameter onChange(of:) { _ in } variant and the code compiles and runs on iOS 16 without modification.
How do I observe playback progress for a custom scrubber?
Use AVPlayer.addPeriodicTimeObserver(forInterval:queue:using:) to receive a callback at a fixed interval (e.g., every 0.5 seconds). Store the returned opaque token and pass it to removeTimeObserver(_:) in onDisappear to avoid retain cycles. Wrap the callback in DispatchQueue.main.async before updating @State/@Observable properties, since the queue parameter controls the dispatch thread, not the UI update thread.
What's the UIKit equivalent?
In UIKit you'd use AVPlayerViewController (presented modally or embedded as a child view controller) together with AVPlayerLayer for custom layouts. SwiftUI's VideoPlayer wraps AVPlayerViewController under the hood via UIViewControllerRepresentable, so you get the same native controls and PiP support without any bridging boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.