The NowPlaying framework provides a first-class Swift API for integrating app media content into system now-playing surfaces including the Lock Screen, Control Center, Dynamic Island, StandBy, CarPlay, Apple Watch, and Apple TV. It replaces the older MPNowPlayingInfoCenter and MPRemoteCommandCenter approach with a modern, protocol-driven, observable model.
โข Replaces the fragmented MPNowPlayingInfoCenter/MPRemoteCommandCenter pattern with a single, type-safe MediaSessionRepresentable protocol โ drastically reducing boilerplate
โข Automatically syncs playback state to every system surface (Lock Screen, CarPlay, AirPlay) whenever your @Observable model changes โ no manual dictionary updates
โข Remote media sessions and Media Sharing Extensions let apps control third-party speakers/TVs through the unified system device picker without embedding third-party SDKs
Shows how to conform an @Observable player model to MediaSessionRepresentable so ambient audio appears on the Lock Screen with play/pause and next-track controls, then wires it up via MediaSession.
import NowPlaying
import AVFoundation
import SwiftUI
// MARK: - Sound model
struct AmbientSound: Identifiable {
let id: String
let name: String
let description: String
let imageName: String
}
// MARK: - Player model conforming to MediaSessionRepresentable
@Observable
final class AmbientPlayerModel: MediaSessionRepresentable {
// Required unique identifier for this session
let id = "com.example.ambientplayer.session"
private(set) var currentSound: AmbientSound = sounds[0]
private(set) var isPlaying: Bool = false
private let audioEngine = AVAudioEngine()
// MARK: MediaSessionRepresentable โ content
var content: some MediaContentRepresentable {
GenericContent(id: currentSound.id) {
$0.title = currentSound.name
$0.subtitle = currentSound.description
$0.mediaType = .audio
$0.duration = .continuous
$0.artwork = Artwork { size in
// Return UIImage scaled to requested size
UIImage(named: self.currentSound.imageName)
}
}
}
// MARK: MediaSessionRepresentable โ playback snapshot
var playbackSnapshot: PlaybackSnapshot {
PlaybackSnapshot(isPlaying: isPlaying)
}
// MARK: MediaSessionRepresentable โ commands
var commands: some MediaCommandRepresentable {
PlayCommand {
self.resume()
}
PauseCommand {
self.pause()
}
NextTrackCommand {
self.skipToNext()
}
}
// MARK: Playback control
func resume() { isPlaying = true /* start audioEngine */ }
func pause() { isPlaying = false /* pause audioEngine */ }
func skipToNext() {
let idx = (sounds.firstIndex(where: { $0.id == currentSound.id }) ?? 0)
currentSound = sounds[(idx + 1) % sounds.count]
}
}
// MARK: - Sample data
let sounds: [AmbientSound] = [
AmbientSound(id: "rain", name: "Rain", description: "Gentle rainfall", imageName: "rain"),
AmbientSound(id: "forest", name: "Forest", description: "Birds and wind", imageName: "forest"),
AmbientSound(id: "ocean", name: "Ocean", description: "Rolling waves", imageName: "ocean"),
]
// MARK: - App entry point wiring MediaSession
@main
struct AmbientApp: App {
@State private var player = AmbientPlayerModel()
// MediaSession must be stored; it observes the model and drives system UI
@State private var session: MediaSession<AmbientPlayerModel>?
var body: some Scene {
WindowGroup {
ContentView(player: player)
.onAppear {
session = MediaSession(representation: player)
}
}
}
}
// MARK: - Simple UI
struct ContentView: View {
let player: AmbientPlayerModel
var body: some View {
VStack(spacing: 20) {
Text(player.currentSound.name).font(.title)
Text(player.currentSound.description).foregroundStyle(.secondary)
HStack(spacing: 30) {
Button(player.isPlaying ? "Pause" : "Play") {
player.isPlaying ? player.pause() : player.resume()
}
Button("Next") { player.skipToNext() }
}.buttonStyle(.bordered)
}
.padding()
}
}MediaSession must be kept alive (stored as a property) for the duration of playback โ it is not a singleton. GenericContent suits ambient/custom media; use Music, Podcast, or MovieContent where applicable for richer system metadata. The Artwork closure is called on-demand by the system at varying sizes, so avoid heavy synchronous work inside it.
Remote media sessions require a push notification server (APNs) and an app extension target; Media Sharing Extensions require a separate extension target
More iOS 27 APIs land every week.
Get notified when new capabilities are published โ no noise, just signal.