napplet.
← All NAPs

NAP-MEDIA / PR #10

Media Session Control

Open PR

Full specification from the fetched upstream revision ↗. The document below is rendered from its original Markdown. Follow the latest source on GitHub ↗.

NAP-MEDIA

Media Session Control

draft

NAP ID: NAP-MEDIA Domain: media Depends:

  • resource — capability · optional — artwork.url bytes are fetched via resource.bytes Web binding (NIP-5D): window.napplet.media; domain presence signals availability.

Description

NAP-MEDIA provides media session management between napplets and the shell. Napplets that play audio or video need a way to expose playback state and metadata to the shell so the shell can display media controls (play/pause, skip, volume), aggregate multiple media sources across napplets, and enforce audio policy (e.g., mute on focus loss, duck overlapping sources). Napplets that cannot or should not fetch/play media directly need a way to request shell-owned playback without gaining direct network or audio authority.

The shell provides a media session registry and control surface. Napplets create sessions, report state and metadata, declare dynamic capabilities, and receive commands from the shell when they own playback. For shell-owned playback, the napplet passes a source reference and the shell validates policy, fetches, plays, owns lifecycle, and reports state back to the napplet. Each napplet may have multiple concurrent sessions (e.g., a music player with background playback and a notification sound effect).

Ownership Model

Every session has a playback owner. The owner is the side that fetches or decodes the media, emits authoritative playback state, and receives playback commands.

  • owner: "shell" -- The napplet asks the shell to play a source. The shell validates policy, fetches through shell-controlled resource handling, owns the media element/backend, emits state, and may keep playback alive according to its own persistence policy. This is the strict sandbox path: the napplet does not gain direct media or network access.
  • owner: "napplet" -- The napplet plays media inside its own frame and registers the session so the shell can provide metadata display, media keys, audio focus, OS integration, and commands.

Stronger napplets can still choose shell-owned playback when they want shell policy enforcement, persistent playback, shared player UI, or OS-level integration.

API Surface

Operation Parameters Result Wire
createSession options (MediaSessionCreate) MediaSessionResult media.session.create / media.session.create.result
updateSession sessionId (text), partial metadata (MediaMetadata) none media.session.update
destroySession sessionId (text) none media.session.destroy
reportState sessionId (text), state (MediaState) none media.state
reportCapabilities sessionId (text), actions (list of MediaAction) none media.capabilities
command sessionId (text), command (MediaCommand) none media.command
onState sessionId (text), handler for MediaState Subscription handle media.state
onCapabilities sessionId (text), handler for list of MediaAction Subscription handle media.capabilities
onCommand sessionId (text), handler for MediaCommand Subscription handle media.command
onControls sessionId (text), handler for list of MediaAction Subscription handle media.controls
onEnded sessionId (text), handler for MediaSessionEnded Subscription handle media.session.ended

Schemas

MediaPlaybackOwner = "shell" / "napplet"
MediaAction = "play" / "pause" / "stop" / "next" / "prev" / "seek" / "volume"

MediaSessionCreate = {
  owner: MediaPlaybackOwner,
  ? sessionId: tstr,
  ? source: MediaSourceRef,
  ? metadata: MediaMetadata,
  ? context: MediaSessionContext,
  ? capabilities: [* MediaAction],
  ? autoplay: bool,
  ? live: bool,
}

MediaSourceRef = {
  ? url: tstr,
  ? blossomHash: tstr,
  ? nostr: MediaNostrRef,
  ? mimeType: tstr,
}

MediaNostrRef = {
  ? eventId: tstr,
  ? address: tstr,
  ? relays: [* tstr],
}

MediaArtwork = {
  ? url: tstr,
  ? hash: tstr,
}

MediaMetadata = {
  ? title: tstr,
  ? artist: tstr,
  ? album: tstr,
  ? artwork: MediaArtwork,
  ? duration: number,
  ? mediaType: "audio" / "video",
}

MediaSessionContext = {
  ? label: tstr,
  ? detail: tstr,
  ? index: uint,
  ? total: uint,
  ? links: [* MediaContextLink],
}

MediaContextLink = {
  rel: tstr,
  ? title: tstr,
  ? nostr: MediaNostrRef,
}

Resource resolution. The artwork.url field is a URL string. Napplets and shells that need the artwork bytes (for example, to render album art on a media controls surface) MUST fetch them through NAP-RESOURCE: window.napplet.resource.bytes(url). The optional artwork.hash field, when present, MAY be used by shells as a content-addressed cache key but is not a substitute for the URL fetch — napplets address artwork by URL through the resource NAP. Artwork URLs do not grant direct network access. The web projection's runtime network policy governs external loads; an opaque sandbox origin alone does not block network requests. If the optional resource domain is unavailable, napplets SHOULD omit remote artwork or use local placeholders. Standard NAP-RESOURCE policy applies (private-IP block list at DNS-resolution time, MIME byte-sniffing, SVG rasterization, etc.).

MediaState fields:

Field Required Type Notes
status yes "playing", "paused", "stopped", or "buffering" Playback state within an active session.
position no number Playback position in seconds.
duration no number Duration in seconds.
volume no number Volume from 0.0 to 1.0.
reason no text Cause of this transition; see Transition reasons.

MediaCommand fields:

Field Required Type Notes
action yes MediaAction Requested playback action.
value no number Seek position or volume, as required by the action.
reason no text Cause of the command; does not grant authority.

MediaSessionEnded fields:

Field Required Type Notes
sessionId yes text Canonical identifier of the retired session.
reason yes text Why the shell retired the session.

MediaSessionResult fields:

Field Required Type Notes
sessionId conditional text Required on successful creation.
owner conditional MediaPlaybackOwner Required on successful creation.
error no text Creation failure; no session was created.

createSession(options) -- Creates a new media session. The napplet MUST set owner to "shell" or "napplet". For shell-owned sessions, source is required and the shell fetches/plays it. For napplet-owned sessions, source is optional and advisory; the napplet owns playback inside its frame. The napplet MAY provide a preferred sessionId, metadata, context, initial capabilities, autoplay, and live. Returns the shell-canonical sessionId and owner. The shell MAY reject session creation (e.g., invalid source, unsupported owner mode, or session limit exceeded).

updateSession(sessionId, metadata) -- Updates metadata for an existing session. Partial updates are supported -- only the fields provided are changed. Fire-and-forget.

destroySession(sessionId) -- Requests session retirement. The shell removes it from the media control surface and sends media.session.ended with reason destroyed. The request is fire-and-forget; the terminal notification has no request correlation id.

reportState(sessionId, state) -- Reports the current playback state for a napplet-owned session. The playback owner sends state whenever it changes, including transition reasons when known (see Transition reasons). Fire-and-forget, high frequency during active playback.

reportCapabilities(sessionId, actions) -- Declares which media actions a napplet-owned session currently supports. Capabilities are dynamic -- a streaming source may not support seek initially but add it once buffered. The shell uses this to enable/disable media control buttons. Fire-and-forget.

onCommand(sessionId, callback) -- Listens for media commands from the shell for a napplet-owned session. The shell sends commands based on user interaction or audio policy; reason distinguishes their causes. The value parameter is used for seek (position in seconds) and volume (0.0 to 1.0). Returns a Subscription with close().

onControls(sessionId, callback) -- Listens for the shell's control list. The shell tells the napplet which controls the shell supports, so the napplet can adapt its own UI (e.g., hide a next/prev button if the shell does not support it). Returns a Subscription with close().

command(sessionId, command) — Requests an allowed action on shell-owned playback. It cannot revive a retired session or change playback ownership.

onState(sessionId, callback) and onCapabilities(sessionId, callback) — Receive authoritative state and supported actions for shell-owned playback.

onEnded(sessionId, callback) — Receives terminal retirement for either owner mode. The binding MUST retain the terminal record until the napplet endpoint is destroyed. A handler registered after retirement MUST receive it once. Retirement MUST stop further state, capability, control, and command callbacks for that session. A napplet-owned player MUST stop its output and release session resources when it receives retirement.

Wire Protocol

media.* messages use the NIP-5D wire format ({ "type": "domain.action", ...payload }).

Type Direction Payload fields
media.session.create napplet -> shell id, owner, sessionId?, source?, metadata?, context?, capabilities?, autoplay?, live?
media.session.create.result shell -> napplet id, sessionId?, owner?, error?
media.session.update napplet -> shell sessionId, metadata
media.session.destroy napplet -> shell sessionId
media.session.ended shell -> napplet sessionId, reason
media.state owner -> peer sessionId, status, position?, duration?, volume?, reason?
media.capabilities owner -> peer sessionId, actions
media.command controller -> owner sessionId, action, value?, reason?
media.controls shell -> napplet sessionId, controls

Key design notes:

  • media.session.create / media.session.create.result use id for correlation. The napplet MAY provide sessionId as a preferred client identifier, but the shell canonicalizes the session id in media.session.create.result.
  • owner is required. Without it, the shell cannot know who fetches media, emits state, receives commands, or owns audio output.
  • source is required for shell-owned sessions and optional for napplet-owned sessions. A napplet-owned source is advisory metadata; it does not ask the shell to fetch or play media.
  • context is optional session association metadata. It can describe where this media item sits in a list (label, detail, index, total) and can link related resources such as a Nostr event, address, or live-chat target. Context does not grant fetch authority and MUST NOT change session ownership.
  • media.session.update, media.session.destroy, media.state, and media.capabilities are fire-and-forget (no id correlation).
  • For napplet-owned sessions, media.state and media.capabilities are napplet -> shell, and media.command is shell -> napplet.
  • For shell-owned sessions, media.state and media.capabilities are shell -> napplet, and media.command is napplet -> shell when the napplet requests an allowed playback action.
  • media.session.ended is a shell-initiated terminal notification with no id correlation.
  • media.controls is shell-initiated. The shell pushes its supported control list so the napplet can adapt its UI.
  • Multiple sessions per napplet are supported. Each session is identified by sessionId.
  • Volume handling is owner-specific. For napplet-owned sessions, the napplet reports its own volume via media.state, and the shell can request volume changes via media.command with action: 'volume'. The effective volume may be the product of napplet volume and shell policy volume. For shell-owned sessions, the shell owns output volume and reports it in media.state.

Session ID Canonicalization

The napplet MAY include sessionId in media.session.create as a stable client-generated hint. The shell MUST return the canonical sessionId in media.session.create.result. The canonical id MAY equal the napplet-supplied value, or it MAY be rewritten to avoid collisions, enforce namespace policy, or bind the id to the napplet identity.

All later messages MUST use the canonical sessionId. Messages that use the pre-canonical hint after creation SHOULD be treated as unknown-session messages.

Transition reasons

reason explains a transition or command; it does not determine session existence. Only media.session.ended retires a session. Missing or unrecognized reasons MUST be treated as unspecified causes, while still processing the message's status, action, or terminal meaning. Receivers MUST NOT infer natural completion or permission to resume from stopped or paused alone.

Reason Meaning
user The user acted in the shell, player, or napplet UI.
ended The current source completed naturally.
error Playback failed.
preempted Another session permanently took this session's output.
focus-lost The shell or platform temporarily withdrew audio focus.
focus-gained The shell permits playback to resume after focus returns.
destroyed The napplet requested session destruction.
backend-exited The playback backend exited without a more specific known cause.
policy The shell revoked the session under its policy.

Playback owners SHOULD include the known reason on state transitions. A natural completion that leaves the session usable MUST be reported as status: "stopped" with reason: "ended"; user stop and playback failure use user and error respectively when known. An ended track does not by itself retire the session. If the shell retires the session for that completion instead, it MUST send media.session.ended with reason ended as the sole notification of that completion. Consumers MUST NOT interpret other reasons as natural completion.

For an active session, a later play remains subject to advertised capabilities and shell policy. An empty capability list disables actions; it MUST NOT be used as a substitute for session retirement.

Session retirement and preemption

The shell MAY retire either owner mode under policy. It MUST retire a session when it permanently reallocates that session's output to another session, or when its playback backend exits and it will not preserve a usable session. Permanent output replacement uses reason preempted. Backend exit uses the known cause (user, ended, or error), or backend-exited when unknown.

Before notifying retirement, the shell MUST remove the session from dispatch and stop or revoke its output authority. It MUST send exactly one media.session.ended for that session to its still-live napplet endpoint. Any already-enqueued state, capability, control, or command messages MUST precede the terminal notification. The shell MUST NOT send further state, capabilities, controls, or commands for the retired session. The binding MUST discard late messages for it and retain the terminal record for onEnded.

For a newly created session, a successful creation result MUST precede any terminal notification. Failed creation produces no retirement notification. Retired identifiers MUST NOT be reused during the napplet endpoint's lifetime, even when a later creation requests the same preferred identifier.

Messages targeting retired sessions, including play, MUST be silently ignored as unknown-session messages. They MUST NOT reacquire output or preempt another session. Playing again requires successful creation of a new session. The shell MUST continue enforcing consent, autoplay, and audio-focus policy on creation.

Endpoint destruction requires cleanup but no notification to the dead endpoint. Shell-promoted persistent playback MUST first detach from the destroyed endpoint; its former session identifier MUST NOT become usable by a replacement endpoint.

Temporary audio-focus loss

Temporary focus loss keeps the session alive. For shell-owned playback, the shell MUST report a focus-induced pause as media.state with status: "paused" and reason: "focus-lost". For napplet-owned playback, the shell MUST identify a focus-induced pause command with reason: "focus-lost". The napplet MUST pause and report its actual state. The reason does not transfer playback ownership. A napplet-owned session MUST support pause if it produces pausable output; otherwise the shell MUST revoke output and retire the session under policy.

A napplet MUST NOT resume automatically merely because its window regains focus. For napplet-owned playback, automatic resumption requires a shell play command with reason: "focus-gained". The shell MAY issue that command only when the session was playing before the focus-induced pause and no later user pause, stop, retirement, or playback failure superseded it. The napplet MUST ignore that automatic-resume command if its own subsequent user action or playback failure superseded the focus pause. For shell-owned playback, the shell applies the same conditions and reports resumed playback with reason focus-gained. User-requested play remains subject to ordinary shell policy. A focus transition MUST NOT revive a retired session.

Source References

source identifies media the shell may play for a shell-owned session. It supports direct URLs, Blossom content hashes, and Nostr event/address references. Shells MAY support a subset of source forms and reject unsupported forms during session creation.

For owner: "shell", source is required unless the shell defines a separate out-of-band source binding for the napplet. The shell MUST fetch and validate source bytes through shell-controlled policy. The napplet does not gain direct network access.

For owner: "napplet", source is optional metadata. The shell MUST NOT treat a napplet-owned source as permission to fetch or play media on the napplet's behalf.

Session Context

context describes the media session around the source. It is for UI, queue position, and related-resource affordances. A shell MAY ignore any context field it does not understand.

context.links[] entries identify related resources by relation name. Relation names are lower-case strings. Implementations MAY define relation names such as live-chat or source-event, but unknown relation names MUST be ignored.

Nostr links use MediaNostrRef. nostr.address MAY be an address coordinate such as 30311:<pubkey>:<d-tag>. nostr.eventId MAY identify an event. nostr.relays are optional routing hints. Absence of nostr.relays MUST NOT make the link invalid; the runtime resolves the Nostr reference through its normal routing policy.

Metadata

All metadata fields are optional. The artwork field supports two forms:

  • url -- A direct URL to the artwork image.
  • hash -- A Blossom hash (SHA-256) that the shell can resolve via its configured Blossom servers. If both are provided, the shell MAY prefer either.
{
  "title": "Song Title",
  "artist": "Artist Name",
  "album": "Album Name",
  "artwork": { "url": "https://example.com/cover.jpg", "hash": "abc123..." },
  "duration": 240,
  "mediaType": "audio"
}

Examples

Create a session:

-> { "type": "media.session.create", "id": "m1", "owner": "napplet", "sessionId": "s1", "metadata": { "title": "My Song", "artist": "The Artist" } }
<- { "type": "media.session.create.result", "id": "m1", "sessionId": "s1", "owner": "napplet" }

Create a shell-owned playback session:

-> { "type": "media.session.create", "id": "m2", "owner": "shell", "source": { "url": "https://example.com/live.mp3", "mimeType": "audio/mpeg" }, "metadata": { "title": "Live Stream" }, "autoplay": true, "live": true }
<- { "type": "media.session.create.result", "id": "m2", "sessionId": "shell-7", "owner": "shell" }

Create a shell-owned live stream with a chat association:

-> { "type": "media.session.create", "id": "m3", "owner": "shell", "source": { "url": "https://example.com/live.mp3", "mimeType": "audio/mpeg" }, "metadata": { "title": "Live Stream" }, "context": { "links": [{ "rel": "live-chat", "title": "Live Stream", "nostr": { "address": "30311:pubkey:d-tag" } }] }, "autoplay": true, "live": true }
<- { "type": "media.session.create.result", "id": "m3", "sessionId": "shell-8", "owner": "shell" }

Update metadata:

-> { "type": "media.session.update", "sessionId": "s1", "metadata": { "title": "Updated Title" } }

Report playback state:

-> { "type": "media.state", "sessionId": "s1", "status": "playing", "position": 42.5, "duration": 240, "volume": 0.8 }

Report capabilities:

-> { "type": "media.capabilities", "sessionId": "s1", "actions": ["play", "pause", "seek", "volume"] }

Shell sends a command:

<- { "type": "media.command", "sessionId": "s1", "action": "pause" }
<- { "type": "media.command", "sessionId": "s1", "action": "seek", "value": 120 }
<- { "type": "media.command", "sessionId": "s1", "action": "volume", "value": 0.5 }

Napplet requests a command on shell-owned playback:

-> { "type": "media.command", "sessionId": "shell-7", "action": "pause" }

Shell reports state for shell-owned playback:

<- { "type": "media.state", "sessionId": "shell-7", "status": "playing", "position": 12.0, "volume": 0.7 }

Shell sends control list:

<- { "type": "media.controls", "sessionId": "s1", "controls": ["play", "pause", "stop", "next", "prev", "seek", "volume"] }

Destroy a session:

-> { "type": "media.session.destroy", "sessionId": "s1" }
<- { "type": "media.session.ended", "sessionId": "s1", "reason": "destroyed" }

Track completes; the session remains usable:

<- { "type": "media.state", "sessionId": "shell-7", "status": "stopped", "reason": "ended" }

Another session takes the shared player; the old session is retired:

<- { "type": "media.session.ended", "sessionId": "shell-7", "reason": "preempted" }
-> { "type": "media.command", "sessionId": "shell-7", "action": "play" }

The stale command is silently ignored. A new session is required to play again.

Temporary focus loss and permitted resumption of napplet-owned playback:

<- { "type": "media.command", "sessionId": "s2", "action": "pause", "reason": "focus-lost" }
-> { "type": "media.state", "sessionId": "s2", "status": "paused", "reason": "focus-lost" }
<- { "type": "media.command", "sessionId": "s2", "action": "play", "reason": "focus-gained" }

The resume command is valid only if no intervening user action or failure superseded the focus pause.

Session creation rejected:

<- { "type": "media.session.create.result", "id": "m1", "sessionId": "s1", "error": "session limit exceeded" }

Error Handling

media.session.create.result includes an error field (string) on failure. Common errors: "session limit exceeded", "invalid session ID". When error is present, the session was not created.

For shell-owned sessions, common creation errors also include "missing source", "unsupported source", "source blocked", and "autoplay denied".

Messages referencing an unknown sessionId (e.g., media.state for a destroyed session) MUST be silently ignored by both napplet and shell.

Commands that are not valid for the current owner mode, source, or capabilities SHOULD be silently ignored or rejected using an implementation-defined diagnostic channel. They MUST NOT cause a session to switch owner.

Shell Behavior

  • The shell MUST accept media.session.create messages and respond with media.session.create.result carrying the same id.
  • The shell MUST reject media.session.create messages without owner.
  • The shell MUST reject owner: "shell" creation when source is missing or blocked by policy.
  • The shell MUST return the canonical sessionId in media.session.create.result.
  • The shell MUST track active sessions per napplet and apply Session retirement and preemption on destruction, output replacement, and terminal backend exit.
  • The shell SHOULD display media controls (play/pause, skip, volume) for active sessions based on the napplet's reported capabilities.
  • The shell SHOULD update its media control UI in response to media.state messages.
  • The shell MAY send media.command messages to control napplet playback based on user interaction with the shell's media controls.
  • The shell MAY send media.controls to inform the napplet which controls the shell supports. The napplet can adapt its own UI accordingly.
  • The shell MAY enforce audio policy (e.g., mute sessions on focus loss, duck overlapping sources, enforce a single-active-output policy).
  • The shell SHOULD make audio focus decisions across all sessions, regardless of owner.
  • For shell-owned sessions, the shell MUST own fetching, playback lifecycle, state emission, and policy enforcement.
  • For napplet-owned sessions, the shell MUST NOT fetch/play source on the napplet's behalf unless the napplet creates a separate shell-owned session.
  • When the owning napplet/window closes, the shell MUST destroy napplet-owned sessions and SHOULD stop shell-owned sessions unless the shell explicitly promotes the session to persistent playback.
  • If shell-promoted persistent playback is allowed, the shell MUST detach the persistent session from the closed iframe before destroying the napplet's message endpoint, and MUST define how the user can stop or revoke that playback.
  • The shell MAY limit the number of concurrent sessions per napplet.
  • The shell MAY enforce ACL checks on media capabilities (e.g., restricting which napplets can create media sessions).
  • The shell MUST silently ignore messages from unknown session IDs.
  • On endpoint removal, the shell MUST clean up all sessions still bound to it. Explicitly detached persistent playback follows the persistence policy above.

Command Validity

For napplet-owned sessions:

  • The shell SHOULD only send commands listed in the napplet's current media.capabilities. Focus withdrawal follows Temporary audio-focus loss; unsupported pause MUST NOT leave revoked output playing.
  • The napplet SHOULD ignore commands it does not currently support.
  • seek MUST include value as a position in seconds.
  • volume MUST include value from 0.0 to 1.0.

For shell-owned sessions:

  • The napplet MAY send media.command only for actions exposed by the shell through media.controls or media.capabilities.
  • The shell MUST enforce source policy, autoplay policy, audio focus, and output volume.
  • The shell MAY ignore seek when the source is live or not seekable.
  • The shell MAY ignore next and prev unless the source represents a playlist or queue.
  • The shell MUST NOT expose raw source bytes to the napplet as part of command handling.

Security Considerations

  • Media sessions expose metadata and playback state to the shell. Napplets SHOULD NOT include sensitive information in metadata fields.
  • Session context is napplet-supplied metadata. Shells SHOULD validate Nostr references before using them for UI or routing and MUST ignore unknown context links.
  • Artwork URLs are fetched by the shell, not the napplet (the napplet is sandboxed with no network access). The shell SHOULD validate URLs and enforce a content security policy for fetched resources.
  • Shell-owned source URLs are fetched and played by the shell, not the napplet. The shell MUST apply the same resource safety policy it applies to artwork and other external bytes.
  • Blossom artwork hashes allow the shell to resolve artwork through its own Blossom infrastructure without the napplet needing network access. The shell controls which Blossom servers are queried.
  • For napplet-owned sessions, volume control is advisory -- the napplet controls actual audio output. A malicious napplet could ignore volume commands. The shell MAY enforce volume limits at the iframe level using the Web Audio API or iframe attribute policies if available. For shell-owned sessions, the shell controls actual output volume.
  • Session creation is rate-limited by the shell. A napplet that creates excessive sessions can be throttled or denied.
  • Transition reasons are explanatory metadata, not authorization. The shell MUST NOT let a caller-supplied reason bypass ownership, capability, autoplay, or audio-focus checks.
  • The media.command message allows the shell to control napplet behavior. The napplet trusts the shell (NIP-5D security model) but SHOULD validate the action against its declared capabilities.

Implementations

  • (none yet)

Changelog

  • d3adc87 - Introduced NAP-MEDIA for media session control.

  • 7eeac9f - Added media session context associations for linking playback state to external context.

  • df41999 - Adopted injected-domain availability and clarified mediated artwork loading and optional-resource fallback.

  • bd521c6 - Added playback and command reasons, terminal session notifications, stale-command rejection, ordered retirement delivery, and audio-focus resumption rules; aligned the operations table with the wire contract.

Also under this NAP