Build A Now Playing Integration For Audio And Video Apps
WWDC26 introduced the Now Playing framework, a Swift-native way to publish media sessions from your app into the system Now Playing experience.
That means your audio or video can appear where people already expect media controls to work: the Lock Screen, Control Center, Dynamic Island, StandBy, CarPlay, and remote-device surfaces.
Apple’s Meet the Now Playing framework session shows the core idea nicely. Your app still owns playback. The framework gives the system a live representation of what is playing, what artwork belongs with it, what state it is in, and which commands the user can perform.
This post builds the shape of a practical integration for an ambient-audio app. The same model works for podcast, audiobook, radio, and video apps once you add duration, elapsed time, seeking, and route-specific behavior.
One beta-era caveat before the code: the machine I am writing on does not have the NowPlaying SDK installed yet, so the Now Playing snippets below follow Apple’s WWDC26 sample code and should be checked against the current Xcode beta before you paste them into a production target. The App Attest post from the same day has typechecked code; this one is intentionally explicit about its beta-shaped parts.
The Migration Mindset
Before Now Playing, most iOS media apps used two familiar MediaPlayer APIs:
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: episode.title,
MPMediaItemPropertyArtist: episode.showTitle,
MPMediaItemPropertyPlaybackDuration: episode.duration
]
MPRemoteCommandCenter.shared().playCommand.addTarget { event in
player.play()
return .success
}
That worked, but it pushed media state through dictionaries and global command centers. The new framework moves the same responsibility into a model-driven Swift API.
Instead of separately publishing metadata and registering command handlers, you make your playback model conform to MediaSessionRepresentable. That representation can provide:
GenericContentor a more specific media content typeArtworkMediaPlaybackSnapshot[MediaCommand]- a stable session identifier
If you already have a working MPNowPlayingInfoCenter integration, treat Now Playing as the owner for each logical active session you migrate. Apps can coordinate more than one local media session, but do not double-publish the same session through both systems unless Apple documents a specific interop path. Double ownership is how you get stale metadata, duplicate command handling, and system UI that says one thing while your player does another.
The Mental Model
The shape is small:
Playback engine
-> observable playback model
-> MediaSessionRepresentable
-> MediaSession
-> system Now Playing surfaces
Now Playing does not decode your audio, host your video, manage your queue, or replace AVPlayer, AVAudioEngine, or a streaming SDK. It observes your app’s state and calls your commands.
That distinction matters. Keep one source of truth for playback. If your actual player pauses because of an interruption, the model that feeds Now Playing should pause too. If your queue advances, the system representation should come from that same queue state.
Start With A Playback Model
Here is a deliberately small ambient sound model. The app plays one sound at a time and supports play, pause, next, and previous.
import Foundation
import Observation
struct AmbientSound: Identifiable, Hashable {
let id: String
let title: String
let subtitle: String
let artworkURL: URL
let audioURL: URL
}
@MainActor
@Observable
final class AmbientPlayerModel {
private let engine: AmbientAudioEngine
private let sounds: [AmbientSound]
private(set) var currentSound: AmbientSound
private(set) var isPlaying = false
init(engine: AmbientAudioEngine, sounds: [AmbientSound]) {
precondition(!sounds.isEmpty)
self.engine = engine
self.sounds = sounds
self.currentSound = sounds[0]
}
func play() {
engine.play(url: currentSound.audioURL)
isPlaying = true
}
func pause() {
engine.pause()
isPlaying = false
}
func next() {
currentSound = sound(after: currentSound)
if isPlaying {
engine.play(url: currentSound.audioURL)
}
}
func previous() {
currentSound = sound(before: currentSound)
if isPlaying {
engine.play(url: currentSound.audioURL)
}
}
private func sound(after sound: AmbientSound) -> AmbientSound {
guard let index = sounds.firstIndex(of: sound) else {
return sounds[0]
}
return sounds[(index + 1) % sounds.count]
}
private func sound(before sound: AmbientSound) -> AmbientSound {
guard let index = sounds.firstIndex(of: sound) else {
return sounds[0]
}
let previousIndex = index == 0 ? sounds.count - 1 : index - 1
return sounds[previousIndex]
}
}
AmbientAudioEngine is your app’s implementation. It might wrap AVAudioPlayer, AVQueuePlayer, AVAudioEngine, AVPlayer, or a streaming vendor SDK. Now Playing should not care.
Keep Background Audio Boring
Now Playing is not a background-mode shortcut. If the user expects audio to continue from the Lock Screen, your app still needs the usual audio-session setup and background mode.
In your app target, enable the Audio background mode:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
Then activate an audio session for playback when your playback engine starts:
import AVFoundation
final class AudioSessionController {
func activateForPlayback() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playback,
mode: .default,
options: []
)
try session.setActive(true)
}
func deactivateAfterPlayback() throws {
try AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
}
}
Also handle interruptions, route changes, headphones, Bluetooth, AirPlay, network loss, and app lifecycle. Now Playing makes the system UI richer; it does not make playback lifecycle bugs disappear.
Adopt MediaSessionRepresentable
Now make the model visible to the Now Playing framework.
This follows Apple’s WWDC26 sample shape:
import NowPlaying
extension AmbientPlayerModel: MediaSessionRepresentable {
var id: String {
"main-ambient-session"
}
var content: (any MediaContentRepresentable)? {
let sound = currentSound
GenericContent(
id: sound.id,
title: sound.title,
subtitle: sound.subtitle,
type: .audio,
duration: .continuous,
artwork: Artwork(id: sound.id) { requestedSize in
let data = try await ArtworkLoader.shared.data(
for: sound.artworkURL,
fitting: requestedSize
)
return try ArtworkRepresentation(data: data)
}
)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: isPlaying ? .playing() : .paused
)
}
var commands: [MediaCommand] {
[
.play {
self.play()
},
.pause {
self.pause()
},
.previous {
self.previous()
},
.next {
self.next()
}
]
}
}
A few details are doing real work here.
The id is the identity of the logical playback session. Keep it stable. Do not make it a new UUID every time the current item changes.
GenericContent is a good fit for simple or custom media experiences. Apple’s docs also list more specific content types such as music, podcasts, movies, TV shows, books, radio, and home media. Use the most specific type that fits your app once the SDK is in front of you.
Artwork loads asynchronously. The system can ask for different sizes on different surfaces, so this is a good place to downsample, cache, and return a fallback if a network image fails.
MediaPlaybackSnapshot tells the system whether the session is playing or paused. For finite media, such as a podcast episode or movie, include elapsed time and duration using the current SDK’s initializer labels.
MediaCommand is the bridge from system UI back into your app. If the user taps pause from Control Center, your pause() method should run. If they tap next from CarPlay, your next() method should run.
Retain The MediaSession
Conformance is not enough. Create a MediaSession from the model, keep a strong reference to it, and ask the system to make it primary.
import NowPlaying
@MainActor
final class PlaybackController {
let player: AmbientPlayerModel
private let audioSessionController = AudioSessionController()
private var mediaSession: MediaSession<AmbientPlayerModel>?
init(engine: AmbientAudioEngine, sounds: [AmbientSound]) {
self.player = AmbientPlayerModel(
engine: engine,
sounds: sounds
)
}
func start(presentOnSystemSurfaces: Bool = true) async throws {
try audioSessionController.activateForPlayback()
let session = MediaSession(player)
mediaSession = session
if presentOnSystemSurfaces {
try await session.requestToBecomeSystemPrimary()
} else {
try await session.requestToBecomeApplicationPrimary()
}
player.play()
}
func stop() {
player.pause()
mediaSession = nil
try? audioSessionController.deactivateAfterPlayback()
}
}
Keep this owner somewhere with playback lifetime: an app model, dependency container, player coordinator, or scene-level service. Do not hide it in a SwiftUI View that can be recreated or discarded.
requestToBecomeApplicationPrimary() begins publishing your app’s primary session. requestToBecomeSystemPrimary() also asks to show that session on the Lock Screen and Control Center; Apple’s docs note that your app needs to be in the foreground for that request to take effect.
When playback ends, nil out the MediaSession reference so the system removes it, then deactivate AVAudioSession with .notifyOthersOnDeactivation so other apps can resume cleanly.
Artwork Deserves Its Own Boundary
Artwork bugs show up in the most visible places in the system. A tiny loader actor keeps that work out of the representable conformance.
import CoreGraphics
import Foundation
actor ArtworkLoader {
static let shared = ArtworkLoader()
private var cache: [String: Data] = [:]
func data(for url: URL, fitting size: CGSize) async throws -> Data {
let key = "\(url.absoluteString)-\(Int(size.width))x\(Int(size.height))"
if let cached = cache[key] {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
// Production code should decode and downsample before caching.
cache[key] = data
return data
}
}
For a real app:
- Cache by content ID and requested size.
- Return a placeholder if the network fails.
- Avoid main-actor image work.
- Downsample large source images.
- Keep artwork IDs stable.
- Be careful with private user-generated artwork on public surfaces.
Podcast apps might use episode artwork and fall back to show artwork. Video apps might choose a poster frame or season artwork. Ambient apps can often use a reusable image per sound.
Finite Media Needs Timeline State
Ambient audio can be continuous. Podcasts, audiobooks, and video need more playback state.
The exact MediaPlaybackSnapshot initializer can move during the beta cycle, but finite media should publish the playhead shape:
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: isPlaying ? .playing() : .paused,
elapsedTime: episodeElapsedTime,
timestamp: episodeTimestamp
)
}
And the content should communicate that it has a duration:
var content: (any MediaContentRepresentable)? {
GenericContent(
id: episode.id,
title: episode.title,
subtitle: episode.showTitle,
type: .audio,
duration: .finite(episode.duration),
artwork: episodeArtwork
)
}
Update the elapsed time and timestamp whenever playback starts, pauses, seeks, or skips. The system can use that timestamp to extrapolate the current position between updates.
Check the exact enum cases and initializer labels in your installed SDK. The product rule is stable even if the spelling changes: finite media should give the system enough information to render a useful timeline and keep it in sync when the user pauses, resumes, seeks, or skips.
Commands Are Product Design
Do not expose every command your engine can technically perform. System controls are compact, especially on the Lock Screen, Dynamic Island, headphones, and CarPlay.
Match user expectations:
- Play should start or resume the current item.
- Pause should pause without losing position.
- Next should move to the next logical item.
- Previous should either restart or go back according to your app’s existing behavior.
- Seeking should appear only when seeking makes sense.
- Like, bookmark, rating, or dislike commands should appear only if they are central to the experience and supported by the system surface.
Derive the command list from state. A livestream may not support seeking. A single ambient sound may not support next or previous. A podcast might support skip forward and skip back but not previous if there is no queue.
For local playback, command handlers can often update immediately. For remote playback or streaming, you may need a buffering state, retry logic, or a conservative update that waits until the command succeeds.
CarPlay Is Not Just Another Surface
Now Playing can make current media visible in CarPlay’s Now Playing UI. That does not mean your app has a full CarPlay browsing experience.
If you want custom browsing, search, library navigation, queue screens, or list-based playback in CarPlay, you still need to follow CarPlay entitlement and template requirements. Apple reviews CarPlay experiences with driver distraction in mind.
For the Now Playing part, keep it simple:
- Metadata should be glanceable.
- Artwork should be recognizable at a distance.
- Commands should be predictable.
- Playback should not require unlocking the phone.
- Network failures should degrade gracefully.
- Next and previous should not surprise the driver.
Test in the CarPlay simulator, but use real hardware if CarPlay is important to your product. Head units, steering wheel controls, Siri, cellular handoffs, and route changes expose very different failures from a desk test.
Test Every System Surface
A useful test matrix looks like this:
| Surface | What to verify |
|---|---|
| Lock Screen | title, subtitle, artwork, play/pause, stale state after backgrounding |
| Control Center | command availability, route picker behavior, volume, artwork refresh |
| Dynamic Island | compact state, live changes, transitions between playing and paused |
| StandBy | large-format artwork, readable metadata, long-running state |
| CarPlay | driver-safe commands, readable labels, steering wheel controls |
| Headphones | play/pause and skip commands from hardware controls |
| Relaunch | session restoration, stale metadata cleanup, current item accuracy |
| Network loss | artwork fallback, buffering state, command retry behavior |
Stale state is the big one. If the user finishes an episode, signs out, stops playback, or switches accounts, update or clear the session representation. Yesterday’s episode hanging around in Control Center makes the integration feel unfinished.
Remote Media Sessions Come Later
After local playback works, look at remote media sessions.
Remote sessions are for media playing somewhere else: a smart speaker, a TV, or another device your app controls. Apple’s Publishing remote media sessions documentation and WWDC26 session describe an extension-based design.
Conceptually:
Remote device
-> your server
-> APNs notification
-> RemoteMediaSessionExtension
-> remote session representation
-> system Now Playing surfaces
Commands flow back:
System UI command
-> RemoteMediaSessionExtension
-> your server
-> remote device
That is a distributed system. You now own APNs payloads, an app extension, server authentication, remote-device state, command latency, retries, and state reconciliation.
Apple’s sample entry point looks like this:
import ExtensionFoundation
import NowPlaying
@main
struct SpeakerNowPlayingExtension: RemoteMediaSessionExtension {
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
func session(_ state: SpeakerSessionState) async throws -> SpeakerSessionModel {
SpeakerSessionModel(state: state)
}
}
The extension target also needs the Now Playing remote-media extension point in its Info.plist:
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.nowplaying.remote-media</string>
</dict>
The model then adopts RemoteMediaSessionRepresentable. It looks similar to the local version, except commands call your server instead of a local player:
import NowPlaying
import Observation
@Observable
@MainActor
final class SpeakerSessionModel: RemoteMediaSessionRepresentable {
private let client: SpeakerServerClient
private(set) var state: SpeakerSessionState
init(state: SpeakerSessionState) {
self.state = state
self.client = SpeakerServerClient(sessionID: state.sessionID)
}
var id: String {
state.sessionID
}
var content: (any MediaContentRepresentable)? {
let item = state.item
GenericContent(
id: item.id,
title: item.title,
subtitle: state.deviceName,
type: .audio,
duration: .continuous,
artwork: Artwork(id: item.artworkID) { size in
let data = try await self.client.artworkData(
id: item.artworkID,
fitting: size
)
return try ArtworkRepresentation(data: data)
}
)
}
var devices: [MediaDevice] {
state.devices.map { device in
MediaDevice(
id: device.id,
name: device.name,
type: .speaker,
capabilities: [
.absoluteVolume(device.volume) { newLevel in
try await self.client.setVolume(
newLevel,
forDevice: device.id
)
}
]
)
}
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: state.isPlaying ? .playing() : .paused,
elapsedTime: state.elapsedTime,
timestamp: state.timestamp
)
}
var commands: [MediaCommand] {
[
.play {
try await self.client.send(.play)
},
.pause {
try await self.client.send(.pause)
},
.next {
try await self.client.send(.next)
}
]
}
func update(_ newState: SpeakerSessionState) {
state = newState
}
}
The state you pass into that extension should be a shared Codable type that conforms to RemoteMediaSessionAttributes. Keep it small and deterministic:
struct SpeakerSessionState: RemoteMediaSessionAttributes {
var sessionID: String
var deviceName: String
var devices: [SpeakerDeviceState]
var item: SpeakerItemState
var isPlaying: Bool
var elapsedTime: TimeInterval
var timestamp: Date
}
From the main app, the minimum viable remote flow is:
let remoteSession = try await RemoteMediaSession.start(attributes: state)
try await remoteSession.requestToBecomeSystemPrimary()
try await remoteSession.update(updatedState)
try await remoteSession.end()
Apple’s docs also cover remote-session push notifications. Your app registers the push-to-start token with your server. Each active remote session has its own update token. Your server then sends APNs pushes with apns-push-type: nowplaying and a topic shaped like <bundle-id>.push-type.nowplaying to start, update, or end sessions when the app is not running.
Remote sessions are powerful, but they are not the first step. Get local playback right first.
Remote Sessions Are Not Media Routing
Remote media sessions describe and control playback happening on another device. Media routing is about sending media from iPhone to another playback destination.
If your app supports third-party speakers, TVs, receivers, or other protocols, also read Apple’s Routing media to third-party devices documentation. WWDC26 discusses Media Sharing Extensions as a way to use the system device picker and support external playback protocols without embedding every protocol SDK directly in your app bundle.
In practice, a media app may need all three layers:
- Local media sessions for playback happening in the app.
- Routing or Media Sharing Extensions when sending playback to another destination.
- Remote media sessions when representing playback that is already happening on another device.
Keep those responsibilities separate. It makes debugging much easier.
A Production Shape
For a real app, I would split the feature like this:
Playback engine
Owns AVPlayer, AVAudioEngine, streaming SDK, buffering, and routes
Playback model
Observable state: current item, play state, queue, elapsed time
Now Playing adapter
MediaSessionRepresentable conformance derived from the model
Session owner
Creates and retains MediaSession for playback lifetime
Artwork service
Loads, downsamples, caches, and invalidates images
Command handlers
Call playback model methods
The key rule: Now Playing should observe your playback truth, not become a second playback truth.
Availability And Fallbacks
If your app supports older operating systems, keep your existing MediaPlayer implementation behind an availability boundary. Use the actual availability from the Xcode SDK you are compiling with.
The architecture can look like this:
protocol NowPlayingPublishing {
func start()
func stop()
}
@MainActor
final class PlaybackController {
private var nowPlayingPublisher: NowPlayingPublishing?
func configureNowPlaying() {
if supportsNowPlayingFramework {
nowPlayingPublisher = NowPlayingSessionPublisher(player: player)
} else {
nowPlayingPublisher = LegacyMediaPlayerPublisher(player: player)
}
nowPlayingPublisher?.start()
}
}
Do not run both publishers for the same active session. The fallback exists so each OS version has one owner for system metadata and commands.
Common Mistakes
The most common mistakes are architectural.
Creating the MediaSession inside a SwiftUI view is one. The view disappears, the session disappears, and the system UI becomes stale.
Publishing state that is not derived from the real player is another. If your player pauses because of an interruption but your model still says it is playing, the Lock Screen will be wrong.
Artwork is another frequent problem. Huge images waste memory, network artwork needs fallbacks, and changing artwork IDs too often causes unnecessary reloads.
Command mismatch is easy to miss too. If your app exposes a next command, make sure next works from every system surface, not only from your in-app queue screen.
Finally, remote sessions often get underestimated. APNs, server state, extension lifecycle, and remote devices need real operational design.
Final Thoughts
The Now Playing framework is a welcome modernization because it lets Swift apps describe media sessions directly instead of juggling metadata dictionaries and global command centers.
Start with a clean playback model. Once that model accurately expresses the current item, artwork, playback state, and available actions, MediaSessionRepresentable becomes a natural bridge into the system.
Start local. Get the Lock Screen, Control Center, Dynamic Island, StandBy, and CarPlay behavior right. Then reach for remote sessions if your app controls playback on external devices.
Keep Apple’s WWDC26 session, Publishing media sessions, Publishing remote media sessions, and Routing media to third-party devices open while you wire it up.