<!--
{
  "documentType" : "article",
  "framework" : "NowPlaying",
  "identifier" : "/documentation/NowPlaying/publishing-remote-media-sessions",
  "metadataVersion" : "0.1.0",
  "role" : "article",
  "title" : "Publishing remote media sessions"
}
-->

# 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`](/documentation/NowPlaying/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`](/documentation/NowPlaying/RemoteMediaSession) to start, fetch, and update sessions.

## Define session attributes

Create a <doc://com.apple.documentation/documentation/Swift/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`](/documentation/NowPlaying/RemoteMediaSessionAttributes) and include only the fields your app needs to represent the external device’s playback.

```swift
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:

```xml
<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`](/documentation/NowPlaying/RemoteMediaSessionExtension) and annotate it with `@main`. The system calls [`session(_:)`](/documentation/NowPlaying/RemoteMediaSessionExtension/session(_:)) when it needs your extension to produce a session for a given set of attributes. From the <doc://com.apple.documentation/documentation/ExtensionFoundation/AppExtension/configuration-swift.property> property, return a [`RemoteMediaSessionExtensionConfiguration`](/documentation/NowPlaying/RemoteMediaSessionExtensionConfiguration) initialized with your extension instance.

```swift
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`](/documentation/NowPlaying/RemoteMediaSessionRepresentable) to provide content, playback state, commands, and device information. Mark it with <doc://com.apple.documentation/documentation/Observation/Observable> to detect changes automatically.

```swift
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(_:)`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/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`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/content) and [`commands`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/commands) on your type to describe what’s playing and which commands the remote session supports. Forward each command to the external device.

```swift
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`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/devices) property. Each [`MediaDevice`](/documentation/NowPlaying/MediaDevice) declares the operations the system can perform on it through an array of [`MediaDevice.Capability`](/documentation/NowPlaying/MediaDevice/Capability) values.

```swift
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:)`](/documentation/NowPlaying/MediaDevice/Capability/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:)`](/documentation/NowPlaying/MediaDevice/Capability/relativeVolume(onIncrement:onDecrement:)) instead.

## Start and update sessions from your app

In your main app, use [`RemoteMediaSession`](/documentation/NowPlaying/RemoteMediaSession) to start a session and send updates when the external device’s state changes:

```swift
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:

```swift
try await remoteSession.update(updatedAttributes)
```

To have the system show this session on the Lock Screen and Control Center, call [`requestToBecomeSystemPrimary()`](/documentation/NowPlaying/RemoteMediaSession/requestToBecomeSystemPrimary()):

```swift
try await remoteSession.requestToBecomeSystemPrimary()
```

> Note: Your app must be in the foreground when you call ``doc://com.apple.nowplaying/documentation/NowPlaying/RemoteMediaSession/requestToBecomeSystemPrimary()``. If your app is in the background, the request has no effect.

When playback ends, call [`end()`](/documentation/NowPlaying/RemoteMediaSession/end()) to remove the session:

```swift
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`](/documentation/NowPlaying/RemoteMediaSession/pushToStartTokenUpdates) to receive the token whenever it changes, and read [`pushToStartToken`](/documentation/NowPlaying/RemoteMediaSession/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.

```swift
// 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`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/pushTokenUpdates) from inside your extension’s session, and query the latest token using [`pushToken`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/pushToken):

```swift
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`](/documentation/NowPlaying/RemoteMediaSessionAttributes):

```json
{
    "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(_:)`](/documentation/NowPlaying/RemoteMediaSessionExtension/session(_:)) with the decoded attributes. Return a [`RemoteMediaSessionRepresentable`](/documentation/NowPlaying/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"`:

```json
{
    "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(_:)`](/documentation/NowPlaying/RemoteMediaSessionRepresentable/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:

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

---

Copyright &copy; 2026 Apple Inc. All rights reserved. | [Terms of Use](https://www.apple.com/legal/internet-services/terms/site.html) | [Privacy Policy](https://www.apple.com/privacy/privacy-policy)