Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Patterns for media playback with AVPlayer, streaming HLS content, audio session configuration, background audio, Now Playing integration, remote command handling, and Picture-in-Picture.
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
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
}
}
}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()
}
}
}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
}
}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)Reuse a single AVPlayer and swap items:
let nextItem = AVPlayerItem(url: nextVideoURL)
player.replaceCurrentItem(with: nextItem)
player.play()let items = videoURLs.map { AVPlayerItem(url: $0) }
let queuePlayer = AVQueuePlayer(items: items)
queuePlayer.play()
// Automatically advances to the next item// 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)
}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)
}
}
}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)NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: .main
) { _ in
// Playback finished -- loop, show replay button, or advance
player.seek(to: .zero) // Loop
}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 resolutionlet 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 defaultConfigure 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
| Category | Behavior | Common Use |
|---|---|---|
.playback | Audio plays even with silent switch on; can play in background | Music, podcasts, video |
.playAndRecord | Simultaneous input and output | Voice/video calls, recording with monitoring |
.ambient | Mixes with other audio; silenced by switch | Game sound effects, casual audio |
.soloAmbient | Default; silences other audio; silenced by switch | Default 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]
)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
}
}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()
}
}To play audio when the app is in the background, two things are required:
audio background mode in your app's capabilities.AVAudioSession with the .playback category.<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>Or enable "Audio, AirPlay, and Picture in Picture" in Xcode's Signing & Capabilities tab.
import AVFAudio
func enableBackgroundAudio() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
}Rules:
setCategory before setActive..playback category is required; .ambient and .soloAmbient do not
support background audio.func deactivateAudioSession() {
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
}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
}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
}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)
}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
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 defaultimport 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)
}
}
}UIBackgroundModes includes audio in Info.plistAVAudioSession category set to .playbackAVPictureInPictureController.isPictureInPictureSupported() before setuprestoreUserInterfaceForPictureInPictureStop delegate methodskills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references