Contents

Publishing remote media sessions

Show media from an external device on the Lock Screen and Control Center.

Overview

A remote session represents media playback happening on an external device such as a speaker, streaming stick, or smart TV. When you publish a remote session, the framework makes the external device’s playback available for display on the Lock Screen and Control Center. Your app extension handles any commands the system routes back to the device.

Remote sessions use an app extension architecture. An app extension conforming to RemoteMediaSessionExtension provides the session content and handles commands.

Send push notifications from your server to start and update remote sessions, so the framework makes remote playback available for display even when your app isn’t running. In your main app, use RemoteMediaSession to start, fetch, and update sessions.

Define session attributes

Create a Codable type that captures the playback state of the external device. Your app and extension share this type to communicate session state. Conform to RemoteMediaSessionAttributes and include only the fields your app needs to represent the external device’s playback.

struct MySessionAttributes: RemoteMediaSessionAttributes {
    var id: String
    var isPlaying: Bool
    var elapsedTime: TimeInterval
    var timestamp: Date
    var currentTrack: TrackInfo?
    var devices: [DeviceInfo]
    var queueCount: Int
    var currentTrackIndex: Int
}

Declare the extension

Add an app extension target to your project. In the extension’s Info pane in Xcode, add an EXAppExtensionAttributes dictionary with an EXExtensionPointIdentifier of com.apple.nowplaying.remote-media. Xcode writes the following entries to the extension’s Info.plist file:

<key>EXAppExtensionAttributes</key>
<dict>
    <key>EXExtensionPointIdentifier</key>
    <string>com.apple.nowplaying.remote-media</string>
</dict>

Create the app extension

Define a type that conforms to RemoteMediaSessionExtension and annotate it with @main. The system calls session(_:) when it needs your extension to produce a session for a given set of attributes. From the configuration property, return a RemoteMediaSessionExtensionConfiguration initialized with your extension instance.

import ExtensionFoundation
import NowPlaying

@main
struct MyRemotePlaybackExtension: RemoteMediaSessionExtension {
    var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
        RemoteMediaSessionExtensionConfiguration(extension: self)
    }

    func session(_ attributes: MySessionAttributes) async throws -> MyRemoteSession {
        MyRemoteSession(attributes: attributes)
    }
}

The framework caches the sessions your extension returns and routes subsequent updates and commands to the same instance. If your extension connects to a backend or device, establish that connection once and reuse it across session(_:) calls.

Implement the remote session representable

Create a type that conforms to RemoteMediaSessionRepresentable to provide content, playback state, commands, and device information. Mark it with Observable to detect changes automatically.

import NowPlaying

@Observable
final class MyRemoteSession: @MainActor RemoteMediaSessionRepresentable {
    let id: String
    var attributes: MySessionAttributes
    let apiClient: MyAPIClient

    init(attributes: MySessionAttributes) {
        self.id = attributes.id
        self.attributes = attributes
        self.apiClient = MyAPIClient()
    }

    func update(_ attributes: MySessionAttributes) {
        self.attributes = attributes
    }

    var playbackSnapshot: MediaPlaybackSnapshot? {
        MediaPlaybackSnapshot(
            state: attributes.isPlaying ? .playing() : .paused,
            elapsedTime: attributes.elapsedTime,
            timestamp: attributes.timestamp
        )
    }
}

The system delivers incoming attribute changes to update(_:). Save them to your stored attributes so the Now Playing interface reflects the external device’s current state.

Provide content and commands

Implement content and commands on your type to describe what’s playing and which commands the remote session supports. Forward each command to the external device.

extension MyRemoteSession {
    var content: (any MediaContentRepresentable)? {
        guard let track = attributes.currentTrack else { return nil }
        var content = MusicContent(
            id: track.id,
            songTitle: track.title,
            artistName: track.artist,
            albumName: track.album,
            type: .audio,
            duration: .finite(track.duration),
            artwork: Artwork(id: track.artworkID) { size in
                let (data, _) = try await URLSession.shared.data(from: track.artworkURL)
                return try ArtworkRepresentation(data: data)
            }
        )
        content.isExplicit = track.isExplicit
        return content
    }

    var commands: [MediaCommand] {
        [
            .play { try await self.apiClient.sendCommand(.play) },
            .pause { try await self.apiClient.sendCommand(.pause) },
            .next { try await self.apiClient.sendCommand(.next) }
                .enabled(attributes.currentTrackIndex < attributes.queueCount - 1),
            .previous { try await self.apiClient.sendCommand(.previous) }
                .enabled(attributes.currentTrackIndex > 0),
            .seekToPosition { time in
                try await self.apiClient.sendCommand(.seek(to: time))
            },
        ]
    }
}

Provide devices and control capabilities

Return the devices playing as part of the session from the devices property. Each MediaDevice declares the operations the system can perform on it through an array of MediaDevice.Capability values.

extension MyRemoteSession {
    var devices: [MediaDevice] {
        attributes.devices.map { device in
            MediaDevice(
                id: device.id,
                name: device.name,
                type: .speaker,
                capabilities: [
                    .absoluteVolume(device.volume) { newLevel in
                        try await self.apiClient.setVolume(newLevel, forDevice: device.id)
                    }
                ]
            )
        }
    }
}

Use absoluteVolume(_:onChange:) when the device exposes a volume level in the range 0.0 to 1.0. The system calls the closure with the requested level when someone adjusts the volume slider.

For devices that only support stepwise volume changes, use relativeVolume(onIncrement:onDecrement:) instead.

Start and update sessions from your app

In your main app, use RemoteMediaSession to start a session and send updates when the external device’s state changes:

let remoteSession = try await RemoteMediaSession.start(attributes: attributes)

When the external device’s state changes, update the session with new attributes. The id on the attributes must match the id used to start the session:

try await remoteSession.update(updatedAttributes)

To have the system show this session on the Lock Screen and Control Center, call requestToBecomeSystemPrimary():

try await remoteSession.requestToBecomeSystemPrimary()

When playback ends, call end() to remove the session:

try await remoteSession.end()

Start and update sessions from push notifications

Remote sessions support push notifications. Your server can start, update, or end sessions when your app isn’t running. Use this approach when playback starts from a remote device without someone opening your app.

Observe the push-to-start token

The push-to-start token lets your server start new remote sessions with a push notification. Observe pushToStartTokenUpdates to receive the token whenever it changes, and read pushToStartToken to get the current value at any time. Send the latest token to your server so it can send push notifications to the right device.

// Observe token updates.
Task {
    for await token in RemoteMediaSession<MySessionAttributes>.pushToStartTokenUpdates {
        let tokenString = token.map { String(format: "%02x", $0) }.joined()
        try await apiClient.registerStartToken(tokenString)
    }
}

// Read the current token.
if let token = RemoteMediaSession<MySessionAttributes>.pushToStartToken {
    let tokenString = token.map { String(format: "%02x", $0) }.joined()
    try await apiClient.registerStartToken(tokenString)
}

Observe session update tokens

Each active session has its own push token for receiving updates and end events. Observe pushTokenUpdates from inside your extension’s session, and query the latest token using pushToken:

extension MyRemoteSession {
    func startObservingPushToken() {
        if let token = pushToken {
            let tokenString = token.map { String(format: "%02x", $0) }.joined()
            Task { try? await apiClient.registerUpdateToken(tokenString, forSessionID: id) }
        }

        Task { [weak self] in
            guard let self else { return }
            for await token in pushTokenUpdates {
                let tokenString = token.map { String(format: "%02x", $0) }.joined()
                try? await apiClient.registerUpdateToken(tokenString, forSessionID: id)
            }
        }
    }
}

Send push notifications from your server

Your server sends APNs push notifications to start, update, or end remote sessions. Use the nowplaying push type for each push and target the topic <your-bundle-id>.push-type.nowplaying.

Set the following headers on your APNs request:

apns-topic: com.example.myapp.push-type.nowplaying
apns-push-type: nowplaying

Start a session

To start a new remote session, send a push to the push-to-start token. The event field must be "start", and the attributes field must contain a JSON-encoded representation of your RemoteMediaSessionAttributes:

{
    "aps": {
        "event": "start",
        "timestamp": 1773907200,
        "attributes": {
            "id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
            "devices": [
                {
                    "id": "living-room-speaker",
                    "name": "Living Room Speaker",
                    "type": "speaker",
                    "volume": 0.65
                }
            ],
            "isPlaying": true,
            "currentTrackIndex": 0,
            "queueCount": 5,
            "elapsedTime": 0,
            "timestamp": "2026-03-16T12:00:00Z",
            "currentTrack": {
                "id": "track-1",
                "title": "Song Title",
                "artist": "Name of Artist",
                "album": "Title of the Album",
                "duration": 237.0,
                "artworkURL": "https://example.com/artwork/track-1"
            }
        }
    }
}

When the system receives this push, it wakes your app extension and calls session(_:) with the decoded attributes. Return a RemoteMediaSessionRepresentable object from this method.

Update a session

To update an existing session, like when someone skips to the next track on the remote device, send a push notification to the session’s update token with the event field set to "update":

{
    "aps": {
        "event": "update",
        "timestamp": 1773907444,
        "attributes": {
            "id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
            "devices": [
                {
                    "id": "living-room-speaker",
                    "name": "Living Room Speaker",
                    "type": "speaker",
                    "volume": 0.65
                }
            ],
            "isPlaying": true,
            "currentTrackIndex": 1,
            "queueCount": 5,
            "elapsedTime": 0,
            "timestamp": "2026-06-08T12:00:00Z",
            "currentTrack": {
                "id": "track-2",
                "title": "Song Title",
                "artist": "Name of Artist",
                "album": "Title of the Album",
                "duration": 312.0,
                "artworkURL": "https://example.com/artwork/track-2"
            }
        }
    }
}

The system delivers the updated attributes to your extension’s update(_:) method, and the Now Playing interface updates to reflect the new content.

End a session

To end a session when playback stops on the remote device, send a push to the session’s update token with the event field set to "end". Only the session id is required in the attributes:

{
    "aps": {
        "event": "end",
        "timestamp": 1773907847,
        "attributes": {
            "id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
        }
    }
}

See Also

Remote sessions