CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

av-playback.mdskills/photokit/references/

AV Playback

Patterns for media playback with AVPlayer, streaming HLS content, audio session configuration, background audio, Now Playing integration, remote command handling, and Picture-in-Picture.

Contents

AVPlayer and AVPlayerViewController Setup

AVPlayer manages playback of a single media asset. Use AVPlayerViewController (AVKit) for the system-standard playback UI with transport controls, or AVPlayerLayer for a custom player interface.

Docs: AVPlayer, AVPlayerViewController

AVPlayerViewController in SwiftUI

import SwiftUI
import AVKit

struct VideoPlayerView: View {
    let url: URL
    @State private var player: AVPlayer?

    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                player = AVPlayer(url: url)
                player?.play()
            }
            .onDisappear {
                player?.pause()
                player = nil
            }
    }
}

AVPlayerViewController in UIKit

import UIKit
import AVKit

final class VideoViewController: UIViewController {
    private var player: AVPlayer?

    func presentVideo(url: URL) {
        player = AVPlayer(url: url)
        let controller = AVPlayerViewController()
        controller.player = player
        present(controller, animated: true) {
            self.player?.play()
        }
    }
}

Custom Player with AVPlayerLayer

For full control over the player UI, embed an AVPlayerLayer:

import AVFoundation
import UIKit

final class PlayerView: UIView {
    override class var layerClass: AnyClass { AVPlayerLayer.self }

    var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }

    var player: AVPlayer? {
        get { playerLayer.player }
        set { playerLayer.player = newValue }
    }

    func configure() {
        playerLayer.videoGravity = .resizeAspect
    }
}

AVPlayerItem and AVAsset Loading

AVAsset represents the static media (duration, tracks, metadata). AVPlayerItem adds the dynamic state (current time, buffering status) needed for playback.

Docs: AVPlayerItem, AVAsset

import AVFoundation

// Local file
let localURL = Bundle.main.url(forResource: "intro", withExtension: "mp4")!
let localItem = AVPlayerItem(url: localURL)

// Remote file
let remoteURL = URL(string: "https://example.com/video.mp4")!
let remoteItem = AVPlayerItem(url: remoteURL)

// From an existing AVAsset (for more control)
let asset = AVURLAsset(url: remoteURL, options: [
    AVURLAssetPreferPreciseDurationAndTimingKey: true
])

// Load properties asynchronously before playback (iOS 15+)
let duration = try await asset.load(.duration)
let tracks = try await asset.load(.tracks)
let isPlayable = try await asset.load(.isPlayable)

let item = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: item)

Replacing the Current Item

Reuse a single AVPlayer and swap items:

let nextItem = AVPlayerItem(url: nextVideoURL)
player.replaceCurrentItem(with: nextItem)
player.play()

Queue Playback with AVQueuePlayer

let items = videoURLs.map { AVPlayerItem(url: $0) }
let queuePlayer = AVQueuePlayer(items: items)
queuePlayer.play()
// Automatically advances to the next item

Playback Controls

// Play
player.play()

// Pause
player.pause()

// Set playback rate (1.0 = normal, 2.0 = 2x, 0.5 = half speed)
player.rate = 1.5

// Seek to a specific time
let targetTime = CMTime(seconds: 30, preferredTimescale: 600)
await player.seek(to: targetTime)

// Seek with tolerance (for precise seeking, e.g., scrubbing)
await player.seek(
    to: targetTime,
    toleranceBefore: .zero,
    toleranceAfter: .zero
)

// Seek to a percentage of duration
func seekToPercentage(_ percentage: Double) async {
    guard let duration = player.currentItem?.duration,
          duration.isNumeric else { return }
    let targetSeconds = duration.seconds * percentage
    let target = CMTime(seconds: targetSeconds, preferredTimescale: 600)
    await player.seek(to: target)
}

Observing Player State and Time

Periodic Time Observer

Use addPeriodicTimeObserver to update UI elements like a progress bar:

import AVFoundation

@Observable
@MainActor
final class PlayerManager {
    let player = AVPlayer()
    var currentTime: Double = 0
    var duration: Double = 0
    var isPlaying = false

    private var timeObserver: Any?

    func startObserving() {
        // Fire every 0.5 seconds on the main queue
        let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
        timeObserver = player.addPeriodicTimeObserver(
            forInterval: interval,
            queue: .main
        ) { [weak self] time in
            guard let self else { return }
            self.currentTime = time.seconds
            self.duration = self.player.currentItem?.duration.seconds ?? 0
            self.isPlaying = self.player.timeControlStatus == .playing
        }
    }

    func stopObserving() {
        if let observer = timeObserver {
            player.removeTimeObserver(observer)
            timeObserver = nil
        }
    }

    deinit {
        if let observer = timeObserver {
            player.removeTimeObserver(observer)
        }
    }
}

Observing Player Status with KVO

Check player and item readiness before playing:

import AVFoundation
import Combine

// Using Combine
var cancellables = Set<AnyCancellable>()

player.publisher(for: \.status)
    .sink { status in
        switch status {
        case .readyToPlay:
            print("Ready to play")
        case .failed:
            print("Failed: \(player.error?.localizedDescription ?? "")")
        case .unknown:
            print("Status unknown")
        @unknown default:
            break
        }
    }
    .store(in: &cancellables)

// Observe buffering state
player.publisher(for: \.timeControlStatus)
    .sink { status in
        switch status {
        case .playing: print("Playing")
        case .paused: print("Paused")
        case .waitingToPlayAtSpecifiedRate:
            print("Buffering: \(player.reasonForWaitingToPlay?.rawValue ?? "")")
        @unknown default: break
        }
    }
    .store(in: &cancellables)

Detecting Playback End

NotificationCenter.default.addObserver(
    forName: .AVPlayerItemDidPlayToEndTime,
    object: player.currentItem,
    queue: .main
) { _ in
    // Playback finished -- loop, show replay button, or advance
    player.seek(to: .zero)  // Loop
}

Streaming HLS Content

HTTP Live Streaming (HLS) works directly with AVPlayer. Pass the .m3u8 URL and AVFoundation handles adaptive bitrate selection, buffering, and failover.

let hlsURL = URL(string: "https://example.com/stream/master.m3u8")!
let player = AVPlayer(url: hlsURL)
player.play()

// AVPlayer automatically selects the best variant based on:
// - Network bandwidth
// - Device capabilities
// - Display resolution

Preferred Bitrate and Resolution

let item = AVPlayerItem(url: hlsURL)

// Limit maximum resolution (e.g., for cellular)
item.preferredMaximumResolution = CGSize(width: 1280, height: 720)

// Limit peak bitrate (bits per second)
item.preferredPeakBitRate = 2_000_000  // 2 Mbps

// For forward buffering duration
item.preferredForwardBufferDuration = 5  // seconds; 0 = system default

AVAudioSession Configuration

Configure AVAudioSession to tell the system how your app intends to use audio. This affects audio routing, mixing behavior, and background playback.

Docs: AVAudioSession, AVAudioSession.Category

Categories and Modes

CategoryBehaviorCommon Use
.playbackAudio plays even with silent switch on; can play in backgroundMusic, podcasts, video
.playAndRecordSimultaneous input and outputVoice/video calls, recording with monitoring
.ambientMixes with other audio; silenced by switchGame sound effects, casual audio
.soloAmbientDefault; silences other audio; silenced by switchDefault app behavior
import AVFAudio

func configureAudioSession(forPlayback: Bool = true) throws {
    let session = AVAudioSession.sharedInstance()

    if forPlayback {
        // Media playback: audio continues with silent switch, supports background
        try session.setCategory(
            .playback,
            mode: .default,
            options: []
        )
    } else {
        // Mix with other apps (e.g., game sounds over user's music)
        try session.setCategory(
            .ambient,
            mode: .default,
            options: [.mixWithOthers]
        )
    }

    try session.setActive(true)
}

// For video calls
try AVAudioSession.sharedInstance().setCategory(
    .playAndRecord,
    mode: .videoChat,
    options: [.defaultToSpeaker, .allowBluetooth]
)

Handling Audio Interruptions

NotificationCenter.default.addObserver(
    forName: AVAudioSession.interruptionNotification,
    object: AVAudioSession.sharedInstance(),
    queue: .main
) { notification in
    guard let info = notification.userInfo,
          let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
          let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
        return
    }

    switch type {
    case .began:
        // Pause playback -- system has interrupted audio
        player.pause()
    case .ended:
        let options = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
        if AVAudioSession.InterruptionOptions(rawValue: options).contains(.shouldResume) {
            player.play()
        }
    @unknown default:
        break
    }
}

Handling Route Changes

NotificationCenter.default.addObserver(
    forName: AVAudioSession.routeChangeNotification,
    object: AVAudioSession.sharedInstance(),
    queue: .main
) { notification in
    guard let info = notification.userInfo,
          let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
          let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
        return
    }

    if reason == .oldDeviceUnavailable {
        // Headphones unplugged -- pause playback (Apple HIG requirement)
        player.pause()
    }
}

Background Audio Setup

To play audio when the app is in the background, two things are required:

  1. Enable the audio background mode in your app's capabilities.
  2. Configure AVAudioSession with the .playback category.

Info.plist Configuration

<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>

Or enable "Audio, AirPlay, and Picture in Picture" in Xcode's Signing & Capabilities tab.

Activating Background Audio

import AVFAudio

func enableBackgroundAudio() throws {
    let session = AVAudioSession.sharedInstance()
    try session.setCategory(.playback, mode: .default)
    try session.setActive(true)
}

Rules:

  • Call setCategory before setActive.
  • The .playback category is required; .ambient and .soloAmbient do not support background audio.
  • Deactivate the session when playback ends to let other apps use audio:
func deactivateAudioSession() {
    try? AVAudioSession.sharedInstance().setActive(
        false,
        options: .notifyOthersOnDeactivation
    )
}

Now Playing Info Center

Update MPNowPlayingInfoCenter so the system displays track information on the lock screen, Control Center, and connected accessories (CarPlay, AirPods).

Docs: MPNowPlayingInfoCenter

import MediaPlayer

func updateNowPlayingInfo(
    title: String,
    artist: String,
    albumTitle: String? = nil,
    duration: TimeInterval,
    currentTime: TimeInterval,
    artwork: UIImage? = nil
) {
    var info: [String: Any] = [
        MPMediaItemPropertyTitle: title,
        MPMediaItemPropertyArtist: artist,
        MPMediaItemPropertyPlaybackDuration: duration,
        MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
        MPNowPlayingInfoPropertyPlaybackRate: 1.0,
        MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue
    ]

    if let albumTitle {
        info[MPMediaItemPropertyAlbumTitle] = albumTitle
    }

    if let artwork {
        info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
            boundsSize: artwork.size
        ) { _ in artwork }
    }

    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}

// Update elapsed time during playback
func updateElapsedTime(_ seconds: TimeInterval, rate: Float = 1.0) {
    var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
    info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seconds
    info[MPNowPlayingInfoPropertyPlaybackRate] = rate
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}

MPRemoteCommandCenter

Register handlers for lock screen, Control Center, and accessory controls (play, pause, skip, seek). Without these, the system controls won't work.

Docs: MPRemoteCommandCenter

import MediaPlayer

func setupRemoteCommands(player: AVPlayer) {
    let commandCenter = MPRemoteCommandCenter.shared()

    // Play
    commandCenter.playCommand.isEnabled = true
    commandCenter.playCommand.addTarget { _ in
        player.play()
        return .success
    }

    // Pause
    commandCenter.pauseCommand.isEnabled = true
    commandCenter.pauseCommand.addTarget { _ in
        player.pause()
        return .success
    }

    // Toggle play/pause (headphone button, etc.)
    commandCenter.togglePlayPauseCommand.isEnabled = true
    commandCenter.togglePlayPauseCommand.addTarget { _ in
        if player.timeControlStatus == .playing {
            player.pause()
        } else {
            player.play()
        }
        return .success
    }

    // Skip forward (e.g., 15 seconds)
    commandCenter.skipForwardCommand.isEnabled = true
    commandCenter.skipForwardCommand.preferredIntervals = [15]
    commandCenter.skipForwardCommand.addTarget { event in
        guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
            return .commandFailed
        }
        let currentTime = player.currentTime().seconds
        let target = CMTime(
            seconds: currentTime + skipEvent.interval,
            preferredTimescale: 600
        )
        player.seek(to: target)
        return .success
    }

    // Skip backward (e.g., 15 seconds)
    commandCenter.skipBackwardCommand.isEnabled = true
    commandCenter.skipBackwardCommand.preferredIntervals = [15]
    commandCenter.skipBackwardCommand.addTarget { event in
        guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
            return .commandFailed
        }
        let currentTime = player.currentTime().seconds
        let target = CMTime(
            seconds: max(0, currentTime - skipEvent.interval),
            preferredTimescale: 600
        )
        player.seek(to: target)
        return .success
    }

    // Scrubbing (seek bar on lock screen)
    commandCenter.changePlaybackPositionCommand.isEnabled = true
    commandCenter.changePlaybackPositionCommand.addTarget { event in
        guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
            return .commandFailed
        }
        let target = CMTime(
            seconds: positionEvent.positionTime,
            preferredTimescale: 600
        )
        player.seek(to: target)
        return .success
    }

    // Disable unsupported commands to remove them from UI
    commandCenter.nextTrackCommand.isEnabled = false
    commandCenter.previousTrackCommand.isEnabled = false
}

Cleanup

func teardownRemoteCommands() {
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.playCommand.removeTarget(nil)
    commandCenter.pauseCommand.removeTarget(nil)
    commandCenter.togglePlayPauseCommand.removeTarget(nil)
    commandCenter.skipForwardCommand.removeTarget(nil)
    commandCenter.skipBackwardCommand.removeTarget(nil)
    commandCenter.changePlaybackPositionCommand.removeTarget(nil)
}

Picture-in-Picture

AVPictureInPictureController enables floating video playback that continues when the user navigates away. Requires the audio background mode.

Docs: AVPictureInPictureController, Adopting Picture in Picture in a Custom Player

With AVPlayerViewController (Automatic)

AVPlayerViewController supports PiP automatically when the background audio capability is enabled. No extra code needed.

let controller = AVPlayerViewController()
controller.player = player
controller.allowsPictureInPicturePlayback = true  // true by default

With a Custom Player

import AVKit

@Observable
@MainActor
final class PiPManager: NSObject, AVPictureInPictureControllerDelegate {
    private var pipController: AVPictureInPictureController?
    var isPiPActive = false
    var isPiPPossible = false

    func setup(playerLayer: AVPlayerLayer) {
        guard AVPictureInPictureController.isPictureInPictureSupported() else {
            return
        }

        pipController = AVPictureInPictureController(playerLayer: playerLayer)
        pipController?.delegate = self

        // Auto-start PiP when app goes to background
        pipController?.canStartPictureInPictureAutomaticallyFromInline = true
    }

    func togglePiP() {
        guard let pipController else { return }
        if pipController.isPictureInPictureActive {
            pipController.stopPictureInPicture()
        } else {
            pipController.startPictureInPicture()
        }
    }

    // MARK: - AVPictureInPictureControllerDelegate

    nonisolated func pictureInPictureControllerWillStartPictureInPicture(
        _ controller: AVPictureInPictureController
    ) {
        Task { @MainActor in isPiPActive = true }
    }

    nonisolated func pictureInPictureControllerDidStopPictureInPicture(
        _ controller: AVPictureInPictureController
    ) {
        Task { @MainActor in isPiPActive = false }
    }

    nonisolated func pictureInPictureController(
        _ controller: AVPictureInPictureController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
        completionHandler: @escaping (Bool) -> Void
    ) {
        // Restore your player UI here, then call the handler
        Task { @MainActor in
            // Navigate back to the player view
            completionHandler(true)
        }
    }
}

PiP Requirements Checklist

  • UIBackgroundModes includes audio in Info.plist
  • AVAudioSession category set to .playback
  • Check AVPictureInPictureController.isPictureInPictureSupported() before setup
  • Implement restoreUserInterfaceForPictureInPictureStop delegate method
  • Call completion handler in the restore delegate method (failure to call causes hangs)

skills

.mcp.json

README.md

tile.json