The behavior of AVPlayerItem.didPlayToEndTimeNotification is not as expected in iOS 26.

Hello,

Environment macOS 15.6.1 / Xcode 26 beta 7 / iOS 26 Beta 9

In a simple AVFoundation video-playback sample, I’m seeing different behavior between iOS 18 and iOS 26 regarding AVPlayerItem.didPlayToEndTimeNotification.

I’ve attached a minimal sample below. Please replace videoURL with a valid short video URL.

Repro steps

  1. Tap “Play” to start playback and let the video finish. The AVPlayerItem.didPlayToEndTimeNotification registered with NotificationCenter should fire, and you should see Play finished. in the console.

  2. Without relaunching, tap “Play” again. This is where the issue arises.

Observed behavior

  • On iOS 18 and earlier: The video does not play again (it does not restart from the beginning), but AVPlayerItem.didPlayToEndTimeNotification is posted and Play finished. appears in the console. The same happens every time you press “Play”.

  • On iOS 26: Pressing “Play” does not post AVPlayerItem.didPlayToEndTimeNotification. The code path that prints Play finished. is never called (the callback enclosing that line is not invoked again).

Building the same program with Xcode 16.4 and running it on an iOS 26 beta device shows the same phenomenon, which suggests there has been a behavioral change for AVPlayerItem.didPlayToEndTimeNotification on iOS 26. I couldn’t find any mention of this in the release notes or API Reference.

Because the semantics around AVPlayerItem.didPlayToEndTimeNotification appear to differ, we’re forced to adjust our logic. If there is a way to achieve the iOS 18–style behavior on iOS 26, I would appreciate guidance.

Alternatively, if this change is intentional, could you share the reasoning? Is iOS 26 the correct behavior from Apple’s perspective and iOS 18 (and earlier) behavior considered incorrect? Any official clarification would be extremely helpful.

import UIKit
import AVFoundation

final class ViewController: UIViewController {
    
    private let videoURL = URL(string: "https://......mp4")!
    private var player: AVPlayer?
    private var playerItem: AVPlayerItem?
    private var playerLayer: AVPlayerLayer?

    private var observeForComplete: NSObjectProtocol?

    // UI
    private let playerContainerView = UIView()
    private let playButton = UIButton(type: .system)
    private let stopButton = UIButton(type: .system)
    private let replayButton = UIButton(type: .system)

    deinit {
        if let observeForComplete {
            NotificationCenter.default.removeObserver(observeForComplete)
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupUI()
        setupPlayer()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        playerLayer?.frame = playerContainerView.bounds
    }

    // MARK: - Setup

    private func setupUI() {
        playerContainerView.translatesAutoresizingMaskIntoConstraints = false
        playerContainerView.backgroundColor = .black
        view.addSubview(playerContainerView)

        // Buttons
        playButton.setTitle("Play", for: .normal)
        stopButton.setTitle("Pause", for: .normal)
        replayButton.setTitle("RePlay", for: .normal)

        [playButton, stopButton, replayButton].forEach {
            $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.contentEdgeInsets = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)
        }

        let stack = UIStackView(arrangedSubviews: [playButton, stopButton, replayButton])
        stack.axis = .horizontal
        stack.spacing = 16
        stack.alignment = .center
        stack.distribution = .equalCentering
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)

        NSLayoutConstraint.activate([
            playerContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            playerContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            playerContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            playerContainerView.heightAnchor.constraint(equalToConstant: 200),

            stack.topAnchor.constraint(equalTo: playerContainerView.bottomAnchor, constant: 20),
            stack.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        // Action
        playButton.addTarget(self, action: #selector(didTapPlay), for: .touchUpInside)
        stopButton.addTarget(self, action: #selector(didTapStop), for: .touchUpInside)
        replayButton.addTarget(self, action: #selector(didTapReplayFromStart), for: .touchUpInside)
    }

    private func setupPlayer() {
        // AVURLAsset -> AVPlayerItem → AVPlayer
        let asset = AVURLAsset(url: videoURL)
        let item = AVPlayerItem(asset: asset)
        self.playerItem = item

        let player = AVPlayer(playerItem: item)
        player.automaticallyWaitsToMinimizeStalling = true
        self.player = player

        let layer = AVPlayerLayer(player: player)
        layer.videoGravity = .resizeAspect
        playerContainerView.layer.addSublayer(layer)
        layer.frame = playerContainerView.bounds
        self.playerLayer = layer

        // Notification
        if let observeForComplete {
            NotificationCenter.default.removeObserver(observeForComplete)
        }
        if let playerItem {
            observeForComplete = NotificationCenter.default.addObserver(
                forName: AVPlayerItem.didPlayToEndTimeNotification,
                object: playerItem,
                queue: .main
            ) { [weak self] _ in
                guard self != nil else { return }
                Task { @MainActor in
                    print("Play finished.")
                }
            }
        }
    }

    // MARK: - Actions

    @objc private func didTapPlay() {
        player?.play()
    }

    @objc private func didTapStop() {
        player?.pause()
    }
    
    // RePlay
    @objc private func didTapReplayFromStart() {
        player?.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
            self?.player?.play()
        }
    }
}

I would greatly appreciate an official response from Apple engineering on whether this is an intentional change, a regression, or an API contract clarification, and what the recommended approach is going forward. Thank you.

Hello @hogehoge-kun,

I don't have an answer to share just yet, but I'm looking into this.

Could you share what functionality in your app was relying on the iOS 18 (and earlier) behavior you described?

-- Greg

@DTS Engineer

Sorry for the delay, and thank you for looking into this.

The previously posted code was a minimized extraction to reproduce the issue. In the actual application we provide an SDK component (a video player module). After playback completion we control a “remaining time” label and related UI using AVPlayerItem.didPlayToEndTimeNotification. The problem occurs when the user presses Play again while the item remains at the end position.

Reproduction outline:

  1. During initialization (setupPlayer-equivalent) we create the AVPlayerItem and register for AVPlayerItem.didPlayToEndTimeNotification.

  2. When the view becomes visible (e.g. via scrolling), playback starts and a remaining‑seconds label is shown.

  3. When playback finishes, the notification fires and we hide the label.

  4. While the currentTime stays at the end, the user presses Play again.

  5. Up to iOS 18, this second Play still caused the notification to fire again, allowing us to reuse the same unified end-state hook (the label stayed hidden). On iOS 26 the notification does not fire, so the label may remain visible, producing an unexpected UI inconsistency.

We had treated “play() invoked while the item is already at end causes the notification to re-fire” as a reliable centralized hook for updating the terminal UI state in an idempotent way. Since this assumption no longer holds on iOS 26, we now branch using an internal playback-state flag.

Point of clarification we request:

  • Is the iOS 26 behavior (no re‑post of AVPlayerItem.didPlayToEndTimeNotification when calling play() at end) an intentional specification change, or a regression?

If this is the intended behavior, we will continue with the internal flag approach.

Thank you for the confirmation.

The behavior of AVPlayerItem.didPlayToEndTimeNotification is not as expected in iOS 26.
 
 
Q