Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

Now Playing on macOS: Metadata, Remote Commands, and Player Ownership

Integrate Mac playback with system Now Playing surfaces using accurate metadata, supported remote commands, serialized state, and cleanup.

Media playback in a Mac app has two related but separate responsibilities: the player controls the media stream, while the system-facing Now Playing APIs describe the current item and receive commands from controls outside the app. MPNowPlayingInfoCenter publishes metadata and playback state. MPRemoteCommandCenter exposes actions such as play, pause, skip, or seek. Neither API is a replacement for the app’s playback engine or queue model.

The central operational rule is to keep one authoritative player owner. If several windows or players write to the shared default Now Playing center, metadata can jump between items and a remote command can target the wrong player. Coordinate active playback, metadata publication, and command routing through a single session or playback coordinator.

Publish metadata from committed player state

Build the Now Playing dictionary from the current item and actual engine state. Useful values can include title, artist, duration, elapsed position, playback rate, artwork, and queue position when they are known. Do not publish invented or stale values to make a control look complete. Clear the dictionary when the app no longer owns the active item.

import MediaPlayer

@MainActor
final class NowPlayingPublisher {
    private let center = MPNowPlayingInfoCenter.default()

    func publish(title: String, artist: String, duration: TimeInterval,
                 elapsed: TimeInterval, isPlaying: Bool) {
        center.nowPlayingInfo = [
            MPMediaItemPropertyTitle: title,
            MPMediaItemPropertyArtist: artist,
            MPMediaItemPropertyPlaybackDuration: duration,
            MPNowPlayingInfoPropertyElapsedPlaybackTime: elapsed,
            MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
        ]
        center.playbackState = isPlaying ? .playing : .paused
    }

    func clear() {
        center.nowPlayingInfo = nil
        center.playbackState = .stopped
    }
}

The dictionary should be updated at meaningful transitions: when the item changes, playback starts or pauses, a seek completes, or the duration becomes known. For long tracks, keep elapsed time synchronized with the player instead of incrementing an app-side counter that drifts after buffering, seeking, or rate changes.

Artwork can be memory-intensive. Decode and resize it for the intended presentation size, cache a bounded derivative, and avoid holding an entire unbounded media library in the metadata layer. If artwork is unavailable, omit it rather than publishing a placeholder that misrepresents the item.

Handle only commands the player supports

The shared command center exposes individual command objects. Register handlers only for operations the current player can perform, and disable unsupported actions so system surfaces do not present controls that always fail. A handler should return a status that accurately reflects whether the command was accepted by the app’s playback state machine.

import MediaPlayer

@MainActor
final class PlaybackCommandBindings {
    private let commands = MPRemoteCommandCenter.shared()
    private var playToken: Any?
    private var pauseToken: Any?

    func install() {
        guard playToken == nil, pauseToken == nil else { return }
        playToken = commands.playCommand.addTarget { [weak self] _ in
            guard let self, self.requestPlay() else { return .commandFailed }
            return .success
        }
        pauseToken = commands.pauseCommand.addTarget { [weak self] _ in
            guard let self, self.requestPause() else { return .commandFailed }
            return .success
        }
        commands.nextTrackCommand.isEnabled = false
    }

    func remove() {
        if let playToken { commands.playCommand.removeTarget(playToken) }
        if let pauseToken { commands.pauseCommand.removeTarget(pauseToken) }
        playToken = nil
        pauseToken = nil
    }

    private func requestPlay() -> Bool { true }
    private func requestPause() -> Bool { true }
}

The sample uses placeholders for the playback engine. In a real app, marshal the request to the actor or queue that owns the player, and update Now Playing metadata after the state transition is accepted. Do not report success for a command that the app has ignored. If the command begins an asynchronous operation, define whether the status means “queued” or “completed” and keep UI state consistent with that contract.

The command center is shared. Remove only the target tokens owned by your app component, not all handlers attached by unrelated playback code. Avoid installing a new target each time a view appears. Centralize registration with a stable owner and tear it down when the app relinquishes the player session.

Resolve multiple players and active sessions

An app with a preview player and a primary player needs an arbitration rule. A preview can be temporary and should not replace the main Now Playing item after it stops. A queue can move to a new item while a previous remote command is still in flight. Use a session or generation identifier, and have each command resolve against the currently active player at the moment it executes.

For multiple coordinated AVPlayer instances, Apple’s MPNowPlayingSession API provides a session-oriented model. Check its current availability and the player integration constraints for the deployment target before adopting it. Do not combine a session API and the default center as if they were unrelated writers to the same metadata state. Select one documented ownership model for a playback experience.

Do not let a stale asynchronous metadata fetch overwrite the current item. Attach an item generation to artwork or metadata loads, and discard a completion whose generation no longer matches. This avoids a common defect where a slow artwork request for track A replaces the metadata for track B after the queue advances.

Playback state, seeking, and rate changes

Elapsed time, duration, and playback rate are related. For variable speed playback, derive displayed position from the player timeline rather than adding wall-clock seconds. After a seek, publish the new position once the engine has applied it. If the player is buffering, distinguish waiting from paused when the API surface supports that state; do not leave a stale positive playback rate if playback has stopped.

Remote seek and skip commands can request a position or interval. Validate the request against the current item duration and supported seek policy. Clamp only when the product defines that behavior; otherwise return a failure status. Make repeated commands safe, since a control surface may send repeated events while a button is held.

When an item is removed from the queue or its URL becomes unavailable, clear or replace metadata and disable commands whose target no longer exists. A Now Playing card can outlive a particular window, so window closure does not necessarily imply playback ended. Follow the playback session’s lifecycle, not the UI surface that started it.

Queue identity and cross-window control

Keep queue index and queue count synchronized with the queue the player actually owns. After shuffle, repeat, insertion, or deletion, update both the metadata and the engine’s navigation operations. A remote “next track” command should run through the same queue service as the app’s Next button so the two paths cannot diverge.

If several windows can start playback, decide whether they share one queue or each own a separate session. A shared session needs a single active item and one command owner; independent sessions need a policy for which one is published externally. Avoid last-writer-wins behavior on the default Now Playing center. It can make a background preview take over metadata while the user is controlling the foreground player.

When a playback source is a stream, include only metadata that is known and stable. Do not invent a finite duration for live content, and do not publish an elapsed value that resets unexpectedly on a reconnect. Clear optional fields when the new item does not have them instead of allowing the prior track’s artwork or artist to persist.

Command status and asynchronous engine work

A command handler should be small enough to return promptly. If the underlying player actor is busy, enqueue a command with a bounded policy and update the public playback state once the operation begins or completes according to the documented product behavior. Coalesce repeated play requests when the player is already starting, and do not accumulate an unbounded backlog of remote seeks.

Use command-specific validation. A pause command can be unavailable while stopped, and a seek command can be unavailable for a live stream without a seekable window. Set each command’s enabled state from the same model rule that guards its handler. The handler must still re-check the state because it may change between rendering a control and delivering the event.

Artwork, privacy, and metadata quality

Publish only metadata the app is allowed and expected to expose. The system may display Now Playing information on system surfaces and connected accessories. Do not put private local paths, account identifiers, or unredacted internal IDs into title or artwork metadata. If the user disables a feature that donates media information to system surfaces, respect that choice and clear published state as appropriate.

Use stable artwork identity for caching, but ensure the artwork actually belongs to the current media item. Keep author/title strings localized or sourced as appropriate, and avoid copying arbitrary remote markup into system-visible metadata. Treat external metadata as untrusted input: bound string lengths, normalize control characters, and validate image dimensions before decoding.

Test external control behavior

Test start, pause, resume, item transition, queue end, seek, skip, buffering, engine failure, window close during playback, app termination, duplicate command binding, and a second app becoming the Now Playing app. Verify the command reaches the active engine exactly once and that the published state follows actual playback rather than desired UI state.

Use supported system controls such as Control Center or media keys to test the external path. Confirm the app is eligible to become the Now Playing app by publishing accurate info and registering relevant commands. An installed command handler alone does not prove the app will receive events before it becomes the active media app.

Record the active player ID, item generation, command name, command outcome, metadata update time, and playback state transition. Avoid logging full media URLs or private library data. If system metadata becomes stale, compare the engine’s current item, the active session, and the last published dictionary before changing command routing.

Now Playing APIs connect player state to system metadata and controls. The application remains responsible for arbitration, truthful metadata, command semantics, and handler ownership. Publish from the active player, bind commands once, and clear or replace state when ownership changes.

Related:

Sources:

Comments