Creating a media device extension
Provide a way for people to find, connect to, and control your media device by adding a device extension in your iOS app.
Overview
A media device extension brings TVs, speakers, and streaming devices into systems’s media device picker, the picker that opens when someone taps an AVRoutePickerView in an app. Your extension implements a media sharing protocol that handles discovery, connection, and playback for these devices.
When a person opens the media device picker, the system launches your extension and asks it discover available devices. Your extension scans for nearby devices and reports each one to the system, so they appear in the picker.
When the person selects a device, the system calls activateDevice(_:session:for:). Your extension connects to the device and reports the result through routingManager(for:): call activatedDevice(_:session:) on success, or failedToActivateDevice(_:session:error:) on failure.
When the device is active, your extension is ready to receive media. Media apps that support your protocol use AVSystemRoute to start playback on the device. If your extension conforms to RealtimeSampleHandling, the system also routes real-time audio or video samples to it.
Your extension works with AVSystemRouting, the framework media apps use to observe routes and control playback. Apps observe route changes through AVSystemRouteController and control playback through AVSystemRoute. Your extension handles the protocol-specific communication with the hardware.
Create and configure the extension target
In Xcode, choose File > New > Target, select Generic Extension. Xcode adds the new extension to your project.
Both the extension and its container app require the com.apple.developer.media-device-extension entitlement set to Media Sharing Protocol ID that uniquely names your protocol:
<key>com.apple.developer.media-device-extension</key>
<string>com.example.sharingprotocol</string>In the extension’s Info pane in Xcode, set EXExtensionPointIdentifier to com.apple.media-device-extension inside the EXAppExtensionAttributes dictionary.
Declare your protocol type
Your Media Sharing Protocol ID identifies your protocol throughout the system, and you set it in three places that must all use the same value:
The string value of the
com.apple.developer.media-device-extensionentitlement (set above).The
UTTypeIdentifierof aUTExportedTypeDeclarationsentry in the extension’s Info pane in Xcode.The identifier your extension’s protocolType property returns.
Declare a custom Uniform Type Identifier in the extension’s Info pane in Xcode using the same identifier. The type must conform to public.media-sharing-protocol:
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.example.sharingprotocol</string>
<key>UTTypeDescription</key>
<string>My Sharing Protocol</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.media-sharing-protocol</string>
</array>
</dict>
</array>The system uses the UTTypeDescription value as your protocol’s display name. It appears in the media device picker, in the Settings interface that lets people choose a preferred protocol, and in system-surfaced error screens. Choose a name that is short, recognizable, and appropriate for people to easily identify your device.
Implement the media device extension
In the new target, create a class that conforms to MediaDeviceExtension and mark it with @main:
import MediaDevice
import UniformTypeIdentifiers
@main
@available(iOS 27.0, *)
class MyDeviceExtension: MediaDeviceExtension {
var protocolType: UTType {
UTType(exportedAs: "com.example.sharingprotocol")
}
var supportsSimultaneousSessions: Bool { false }
lazy var routingManager: MediaDeviceRoutingManager = .routingManager(for: self)
required init() {}
func startDeviceDiscovery() {
// Start protocol-specific network discovery here.
}
func stopDeviceDiscovery() {
// Stop protocol-specific network discovery here.
}
func activateDevice(
_ device: MediaOutputDevice,
session: MediaOutputSession,
for deviceFeatures: MediaOutputDevice.Capabilities
) {
// Connect to the device and report the result through the routing manager.
}
func connectUsingPairingCode(
_ pairingCode: String?,
to device: MediaOutputDevice,
session: MediaOutputSession
) {
// Authenticate the device with the person's pairing input.
}
func deactivateDevice(
_ device: MediaOutputDevice,
session: MediaOutputSession
) {
// Disconnect the device and release any session-scoped resources.
}
func setVolume(_ volume: Float, for device: MediaOutputDevice) {
// Set the device's volume to the requested level.
}
func volume(for device: MediaOutputDevice) -> Float {
// Return the device's current volume level.
0
}
func changeVolume(by increments: Int, for device: MediaOutputDevice) {
// Apply a relative volume change to the device.
}
func muteDevice(_ device: MediaOutputDevice) {
// Mute the device.
}
func isDeviceMuted(_ device: MediaOutputDevice) -> Bool {
// Return the device's current mute state.
false
}
func startSession(
_ session: MediaOutputSession,
identifier: String?,
url: URL
) {
// Begin playback of the URL on the remote device.
}
func stopSession(_ session: MediaOutputSession) {
// Stop playback for this session.
}
func sendData(
_ data: Data,
toApplication applicationIdentifier: String,
session: MediaOutputSession
) {
// Forward the data payload to the target app on the remote device.
}
}The routingManager property is how your extension communicates with the system. Obtain an instance by calling routingManager(for:), then use it throughout your extension to report discovered devices, activation results, sessions, and pairing requests.
The supportsSimultaneousSessions property tells the system whether your extension can handle multiple MediaOutputSession instances at the same time.
Discover devices on the network
When a person opens the media device picker, the system calls startDeviceDiscovery(). Use your protocol’s discovery mechanism to find devices, then report them to the system through routingManager(for:).
func startDeviceDiscovery() {
// Start protocol-specific network discovery here.
// As you find devices, create a `MediaOutputDevice` and report it
// to make the device available to the system.
// Use a stable identifier (for example, derived from your protocol's
// device ID) so the same physical device produces the same ID
// across discoveries.
guard let device = MediaOutputDevice(
id: deviceID,
displayName: "Living Room TV",
capabilities: [.urlPlayback],
requiredNetworkEndpoints: endpoints
) else { return }
routingManager.foundDevice(device)
}
func stopDeviceDiscovery() {
// Stop protocol-specific network discovery here.
}Report discovery events through routingManager(for:) as devices appear and disappear on the network:
foundDevice(_:) — Reports a newly discovered device so the system can include it in the device list.
lostDevice(_:) — Removes a previously discovered device from the device list.
updateDevices(_:) — Refreshes the state of one or more devices after their properties change.
discoveryFailed(_:) — Reports an unexpected discovery failure. Don’t call this when no devices are found.
Activate a device
When a person selects a device, the system calls activateDevice(_:session:for:). Connect to the device and report the result through routingManager(for:).
func activateDevice(
_ device: MediaOutputDevice,
session: MediaOutputSession,
for deviceFeatures: MediaOutputDevice.Capabilities
) {
// Handle possible device authorization here.
do {
try myProtocolClient.connect(to: device)
routingManager.activatedDevice(device, session: session)
} catch {
routingManager.failedToActivateDevice(
device,
session: session,
error: MediaDeviceError(.connectionFailed)
)
}
}When the person disconnects from the device, the system calls deactivateDevice(_:session:). Disconnect and release any resources associated with the session.
Handle device authorization
Some devices require pairing before activation. When a device needs authorization, call requestPairingCode(for:session:reason:authorizationMethod:) to present a pairing interface. The MediaOutputDevice.AuthorizationMethod type defines the available pairing interfaces:
numericCode(length:) — A numeric PIN code with a fixed digit count. Pass fourCharacter or sixCharacter.
password — A text password.
none — No authorization required.
The system collects the person’s input and delivers it to connectUsingPairingCode(_:to:session:):
func activateDevice(
_ device: MediaOutputDevice,
session: MediaOutputSession,
for deviceFeatures: MediaOutputDevice.Capabilities
) {
if myProtocolClient.requiresPairing(device) {
routingManager.requestPairingCode(
for: device,
session: session,
reason: "Enter the code shown on your TV.",
authorizationMethod: .numericCode(length: .fourCharacter)
)
} else {
// Handle regular device activation here.
do {
try myProtocolClient.connect(to: device)
routingManager.activatedDevice(device, session: session)
} catch {
routingManager.failedToActivateDevice(
device,
session: session,
error: MediaDeviceError(.connectionFailed)
)
}
}
}
func connectUsingPairingCode(
_ pairingCode: String?,
to device: MediaOutputDevice,
session: MediaOutputSession
) {
guard let code = pairingCode else {
// The person canceled pairing with their iPhone.
routingManager.failedToActivateDevice(
device,
session: session,
error: MediaDeviceError(.authorizationFailed)
)
return
}
do {
try myProtocolClient.authenticate(with: code, device: device)
routingManager.activatedDevice(device, session: session)
} catch {
routingManager.failedToActivateDevice(
device,
session: session,
error: MediaDeviceError(.authorizationFailed)
)
}
}Start media playback
After activation, the system calls startSession(_:identifier:url:) to begin playback. Send the URL to the device and report success through routingManager(for:):
func startSession(
_ session: MediaOutputSession,
identifier: String?,
url: URL
) {
do {
let playbackControl = try myProtocolClient.startPlayback(
url: url,
applicationIdentifier: identifier
)
routingManager.started(
application: identifier,
playbackControl: playbackControl,
session: session
)
} catch {
routingManager.sessionFailed(
session,
error: MediaDeviceError(.sessionFailed)
)
}
}
func stopSession(_ session: MediaOutputSession) {
myProtocolClient.stopPlayback()
}The playbackControl parameter conforms to AVInterfaceControllable, which models the full playback state of the remote session. Update its properties to keep the system in sync as playback progresses: for example, isPlaying, state, currentPlaybackPosition, playbackSpeed, timeRange, and metadata.
The system observes these properties and drives the shared playback UI from them, including Now Playing, the media device picker, and any controls surfaced by media apps. The system also uses the object’s conformance to AVInterfacePlaybackControllable to deliver play, pause, and seek commands back to your extension.
Stream real-time samples
Some devices receive audio or video samples directly instead of fetching a URL. Real-time sample delivery is orthogonal to URL playback, and a device can support any combination of the two:
For devices that receive audio samples directly, set the device’s capabilities to include realtimeAudioStreaming. The system routes audio destined for that device through your extension.
For devices that receive video frames directly to support screen-mirroring, set the device’s capabilities to include realtimeVideoStreaming. The system routes screen frames to your extension only while screen mirroring is active.
Ensure your extension class conforms to RealtimeSampleHandling. The system calls startRealtimeSampleDelivery(session:) when samples start flowing, and stopRealtimeSampleDelivery(session:) to stop them.
To capture the samples themselves, use the appropriate system framework:
For audio, use AudioDriverKit to receive system audio, then Audio Toolbox to encode it.
For video, use ScreenCaptureKit to receive system video, then Video Toolbox to encode it.
@main
@available(iOS 27.0, *)
class MyDeviceExtension: MediaDeviceExtension, RealtimeSampleHandling {
// Implement the `MediaDeviceExtension` requirements above.
func startRealtimeSampleDelivery(session: MediaOutputSession) {
// Start capturing samples and send them to the remote device.
}
func stopRealtimeSampleDelivery(session: MediaOutputSession) {
// Stop capturing samples and remove any encoders.
}
}Provide volume control
Configure volume support when you create a MediaOutputDevice by setting its MediaOutputDevice.VolumeControl mode:
MediaOutputDevice.VolumeControl.absolute — The device supports direct volume levels. The system calls setVolume(_:for:) and volume(for:) to set and read the volume.
MediaOutputDevice.VolumeControl.relative — The device supports only incremental adjustments. The system calls changeVolume(by:for:) to raise or lower the volume.
MediaOutputDevice.VolumeControl.none — The device doesn’t support volume control.
func setVolume(_ volume: Float, for device: MediaOutputDevice) {
myProtocolClient.setVolume(volume, on: device)
}
func volume(for device: MediaOutputDevice) -> Float {
myProtocolClient.currentVolume(for: device)
}
func changeVolume(by increments: Int, for device: MediaOutputDevice) {
myProtocolClient.adjustVolume(by: increments, on: device)
}
func muteDevice(_ device: MediaOutputDevice) {
myProtocolClient.mute(device)
}
func isDeviceMuted(_ device: MediaOutputDevice) -> Bool {
myProtocolClient.isMuted(device)
}