Introduction
Detecting when an AVPlayer video finishes playing requires observing the .AVPlayerItemDidPlayToEndTime notification on the AVPlayerItem. This notification fires when the player reaches the end of the item's duration. Alternative approaches include using Key-Value Observing (KVO) on the player item's status property and adding a boundary time observer. For looping video, seek back to the beginning in the notification handler. This article covers all approaches in both UIKit and SwiftUI.
Using NotificationCenter
1import AVFoundation
2import UIKit
3
4class VideoPlayerVC: UIViewController {
5 var player: AVPlayer!
6
7 override func viewDidLoad() {
8 super.viewDidLoad()
9
10 let url = URL(string: "https://example.com/video.mp4")!
11 let playerItem = AVPlayerItem(url: url)
12 player = AVPlayer(playerItem: playerItem)
13
14 // Observe end-of-playback notification
15 NotificationCenter.default.addObserver(
16 self,
17 selector: #selector(videoDidEnd),
18 name: .AVPlayerItemDidPlayToEndTime,
19 object: playerItem // Observe this specific item
20 )
21
22 player.play()
23 }
24
25 @objc func videoDidEnd(_ notification: Notification) {
26 print("Video finished playing")
27 // Navigate to next screen, show replay button, etc.
28 }
29
30 deinit {
31 NotificationCenter.default.removeObserver(self)
32 }
33}
Pass the playerItem as the object parameter to receive notifications only for that specific item. Passing nil receives notifications from all player items.
Using Closure-Based Observer
1class VideoPlayerVC: UIViewController {
2 var player: AVPlayer!
3 var endObserver: NSObjectProtocol?
4
5 override func viewDidLoad() {
6 super.viewDidLoad()
7
8 let playerItem = AVPlayerItem(url: videoURL)
9 player = AVPlayer(playerItem: playerItem)
10
11 // Closure-based observation — no @objc required
12 endObserver = NotificationCenter.default.addObserver(
13 forName: .AVPlayerItemDidPlayToEndTime,
14 object: playerItem,
15 queue: .main
16 ) { [weak self] _ in
17 guard let self = self else { return }
18 self.handleVideoEnd()
19 }
20
21 player.play()
22 }
23
24 func handleVideoEnd() {
25 print("Video ended")
26 }
27
28 deinit {
29 if let observer = endObserver {
30 NotificationCenter.default.removeObserver(observer)
31 }
32 }
33}
The closure-based API returns a token that must be retained and removed in deinit. Use [weak self] to avoid retain cycles.
Looping Video (Replay on End)
1class LoopingVideoVC: UIViewController {
2 var player: AVPlayer!
3
4 override func viewDidLoad() {
5 super.viewDidLoad()
6
7 let playerItem = AVPlayerItem(url: videoURL)
8 player = AVPlayer(playerItem: playerItem)
9
10 NotificationCenter.default.addObserver(
11 self,
12 selector: #selector(loopVideo),
13 name: .AVPlayerItemDidPlayToEndTime,
14 object: playerItem
15 )
16
17 player.play()
18 }
19
20 @objc func loopVideo(_ notification: Notification) {
21 player.seek(to: .zero)
22 player.play()
23 }
24}
25
26// Alternative: AVPlayerLooper (iOS 10+) for seamless looping
27class SeamlessLoopVC: UIViewController {
28 var player: AVQueuePlayer!
29 var looper: AVPlayerLooper!
30
31 override func viewDidLoad() {
32 super.viewDidLoad()
33
34 let item = AVPlayerItem(url: videoURL)
35 player = AVQueuePlayer(items: [item])
36 looper = AVPlayerLooper(player: player, templateItem: item)
37
38 player.play()
39 // Loops automatically — no notification needed
40 }
41}
AVPlayerLooper provides gapless looping without manual seek. For simple loop-on-end, seeking to .zero in the notification handler works but may have a brief gap.
Detecting Playback Failure
1// Also observe when playback fails
2NotificationCenter.default.addObserver(
3 self,
4 selector: #selector(videoDidFail),
5 name: .AVPlayerItemFailedToPlayToEndTime,
6 object: playerItem
7)
8
9@objc func videoDidFail(_ notification: Notification) {
10 if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
11 print("Playback failed: \(error.localizedDescription)")
12 }
13}
.AVPlayerItemFailedToPlayToEndTime fires when playback is interrupted by an error (network failure, corrupted file). Always observe both notifications for robust error handling.
Boundary Time Observer
1// Fire a callback at a specific time (e.g., 5 seconds before end)
2let duration = playerItem.asset.duration
3let fiveSecondsBeforeEnd = CMTimeSubtract(duration, CMTime(seconds: 5, preferredTimescale: 600))
4
5let boundaryObserver = player.addBoundaryTimeObserver(
6 forTimes: [NSValue(time: fiveSecondsBeforeEnd)],
7 queue: .main
8) {
9 print("5 seconds until video ends — show skip button")
10}
11
12// Remove when done
13player.removeTimeObserver(boundaryObserver)
Boundary time observers fire when playback passes specific timestamps. Use this for pre-end actions like showing UI elements before the video finishes.
SwiftUI Integration
1import SwiftUI
2import AVKit
3
4struct VideoPlayerView: View {
5 @State private var player = AVPlayer(url: URL(string: "https://example.com/video.mp4")!)
6 @State private var videoEnded = false
7
8 var body: some View {
9 VStack {
10 VideoPlayer(player: player)
11 .onAppear {
12 setupEndObserver()
13 player.play()
14 }
15 .frame(height: 300)
16
17 if videoEnded {
18 Button("Replay") {
19 player.seek(to: .zero)
20 player.play()
21 videoEnded = false
22 }
23 }
24 }
25 }
26
27 func setupEndObserver() {
28 NotificationCenter.default.addObserver(
29 forName: .AVPlayerItemDidPlayToEndTime,
30 object: player.currentItem,
31 queue: .main
32 ) { _ in
33 videoEnded = true
34 }
35 }
36}
Periodic Time Observer (Progress Tracking)
1// Track playback progress and detect near-end
2let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
3
4let timeObserver = player.addPeriodicTimeObserver(
5 forInterval: interval,
6 queue: .main
7) { time in
8 let currentSeconds = CMTimeGetSeconds(time)
9 let duration = CMTimeGetSeconds(player.currentItem?.duration ?? .zero)
10
11 let progress = currentSeconds / duration
12 print("Progress: \(Int(progress * 100))%")
13
14 if duration - currentSeconds < 1.0 {
15 print("Almost done!")
16 }
17}
Periodic time observers provide continuous progress updates. Use them for progress bars, time labels, and detecting when playback is near the end.
Common Pitfalls
Not removing observers in deinit: Failing to remove notification observers causes crashes if the notification fires after the view controller is deallocated. Always remove in deinit or use [weak self] with closure observers.
Passing nil for the notification object: addObserver(... object: nil) receives notifications from all AVPlayerItem instances. If your app has multiple players, you receive duplicate callbacks. Always pass the specific playerItem.
Notification not firing for streaming content: If a stream has no defined end (live stream), .AVPlayerItemDidPlayToEndTime never fires. Check playerItem.duration — live streams have .indefinite duration.
Seek to zero not working for loop: player.seek(to: .zero) is asynchronous. If you call player.play() before the seek completes, playback may stutter. Use the completion handler: player.seek(to: .zero) { _ in player.play() }.
Ignoring AVPlayerItemFailedToPlayToEndTime: Only observing the success notification misses playback errors. Always observe both .AVPlayerItemDidPlayToEndTime and .AVPlayerItemFailedToPlayToEndTime.
Summary
Observe .AVPlayerItemDidPlayToEndTime notification to detect when a video finishes
Always pass the specific playerItem as the observation object, not nil
Use AVPlayerLooper for seamless gapless looping on iOS 10+
Use boundary time observers to trigger actions at specific timestamps before the end
Remove all observers in deinit to prevent crashes
Observe .AVPlayerItemFailedToPlayToEndTime for error handling