Media-sjabloon
Een mediakaart in de stijl van Nu afspelen: hoesafbeelding, een live meelopende schuifbalk en bedieningsknoppen die je webhooks activeren — bedien elke speler vanaf het toegangsscherm.
Dit gedeelte beschrijft een functie in een app-update die door de App Store wordt beoordeeld. Zodra de update live is, wordt het hier automatisch ontgrendeld.
PushWard never talks to the player: you send the state, the phone renders it, a tap arrives at your webhook, and your next push confirms what happened.
Builds older than 1.9.0 fall back to the generic layout with a plain progress bar (the server mirrors position_seconds / duration_seconds into progress for exactly that case).
Fields
The activity name is the source ("Living Room", "Kitchen TV") and stays on the card in small type; media_title is the big line.
| Field | Type | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
template | string | Required. Must be "media" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
media_title | string | Track, episode or programme title. Max 128 characters | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
subtitle | string | Artist, show, album or channel -- the second line under the title | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
playback_state | string | One of playing, paused, stopped, buffering. Default: paused. Only playing makes the bar tick; the play/pause glyph follows it too | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
position_seconds | float | Playback position at the moment you sampled it. >= 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
duration_seconds | float | Total length. > 0, at most 604800 (7 days). Omit it for live or indeterminate media (radio, a stream): no bar is drawn and the elapsed clock still counts up | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
position_at | integer | Unix timestamp (seconds) at which position_seconds was sampled. Defaults to the server's receive time, which is what you want when you push straight from the player. May be at most 300 s in the future. Re-send position_seconds on every play/pause transition so the device re-anchors instead of extrapolating from a stale sample | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
volume | float | 0.0-1.0. Draws a thin volume bar between the volume buttons | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
favorite | boolean | true fills the heart. Toggle it back on your next push after the favorite webhook lands | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
controls | object | The transport buttons -- see Controls. All slots optional; no slots, no buttons | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
image_url, image_shape, image_thumbhash | string | Cover art. The same trio as the generic template; the default shape is square. The device downloads the image, the server never does -- send the ThumbHash so the card has something to show the instant it appears | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
icon | string | SF Symbol drawn where the art would be when there is none. Default: music.note | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
accent_color | string | Named color or hex for the icon and the filled heart. Default: pink | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
background_color, text_color | string | Color overrides | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Komt in app-versie 1.14.0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
compact_label | string | Optional, max 4 characters. Replaces the artwork (or the icon when there is none) in the Dynamic Island compact leading ear. The minimal presentation ignores it. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Sending any media field on another template is rejected with 422 rather than ignored, and switching an activity from media to another template clears them, so no card ends up carrying controls it cannot draw. url_action and secondary_url_action are accepted for compatibility but not rendered on the media card -- the transport row takes the button space; tap_action (tapping the card background) still works.
Controls
controls is an object of named slots. Each slot is an Action object -- the same shape as url_action, with the same limits on headers and body.
| Slot | Where it draws | Notes |
|---|---|---|
previous | Transport row | Skip back |
play_pause | Transport row (large, centre) | One toggle endpoint. The glyph shows pause while playback_state is playing and play otherwise |
play, pause | Transport row (same button) | Separate endpoints instead of a toggle. iOS picks pause while playing and play otherwise, and falls back to play_pause when the matching one is missing. Send whichever your player exposes; you can send all three |
next | Transport row | Skip forward |
stop | Transport row (small, trailing) | |
favorite | Transport row (small, leading) | Heart; filled when favorite: true |
volume_down, volume_up | Volume row | Speaker glyphs either side of the volume bar |
extra | Extras row | Array of up to 3 custom buttons (shuffle, repeat, a source switch). icon is required on each; title is the accessibility label. Replaced wholesale on PATCH, like every array field -- re-send the full array |
What a tap does
Controls follow the standard tap rules -- an http(s) URL is a silent webhook, a custom scheme opens that app, and a control is never allowed to open the browser: a transport button that opens Safari is not a transport button. Specific to this template:
- Play/pause flips optimistically on the device the moment it is tapped, then your next push is the truth. Previous, next and stop give no pending visual -- the tap fires the webhook, and your next push updating the card is the confirmation.
- Buttons exist on the Lock Screen and in the expanded Dynamic Island. The compact and minimal Island and the Apple Watch card are tap-to-open, except that the Watch shows one play/pause button.
Keep headers and body small -- the whole activity has to fit the 4 KB APNs payload (see Payload budget).
How the bar ticks
The device does not poll you. From position_seconds, position_at and duration_seconds it derives a start instant and, while playback_state is playing, animates the scrubber and both clocks (elapsed on the left, -remaining on the right) natively -- no update quota spent while a track plays.
playingwith a duration: the bar and clocks run fromposition_at - position_secondsuntil the end. Past the end they freeze at the end; they never wrap.playingwithout a duration: no bar, the elapsed clock counts up.paused,stopped,buffering: the bar and clocks freeze atposition_seconds.- An activity the server has marked stale (see
stale_ttl) freezes too, so a producer that died does not keep "playing". - Ending the activity forces the wire state to
stopped: an ended card never ticks.
Position-only patches (position_seconds / position_at) go out at low push priority and coalesce; anything else on the card -- title, state, duration, volume, favorite, controls -- is a structural change and goes out at high priority.
Updates are merge patches
A PATCH merges onto the stored content (update semantics); two things are specific to controls:
- The
controlsobject deep-merges: sending{"controls": {"stop": null}}removes the stop button and leaves the others alone; sending{"controls": {"next": {"url": "..."}}}replaces onlynext. - Re-sending a slot with a new
urland nomethodre-derives the method (POSTforhttp(s)): an explicit method from an earlier push is not remembered across a repoint, so send it again if you need something other than the default.titleis ignored on the fixed slots -- the glyph is the label -- onlyextrabuttons usetitleandicon.
Payload budget
The full example below is about 1.3 KB; eight controls each carrying a 40-character bearer header and a small body come to about 2.2 KB, well inside the 4 KB APNs limit. If a payload still does not fit, the server drops whole controls in this order until it does -- extra, then volume_down + volume_up, favorite, stop, then image_thumbhash, image_url, then previous + next -- and stops at the title, state, position and play slots. Headers and bodies are never stripped from a control that survives: a button that fires without its credentials and gets a 401 is worse than no button. What you GET back is always the full stored content; shedding only affects the push.
Surfaces
- Lock Screen -- art, title, subtitle, source, scrubber with both clocks, the transport row, then the volume row and extras when there is room (the extras row is hidden first, then the volume row, when the card would exceed the height iOS allows).
- Dynamic Island, expanded -- art leading, title and subtitle centred, elapsed trailing, scrubber and a compact transport row below. Buttons work here.
- Dynamic Island, compact -- art or icon leading; while playing the elapsed clock ticks trailing, otherwise a state glyph. Tap to open.
- Apple Watch -- art, title and one play/pause button. Mac and the in-app cards use the same layout as the Lock Screen.
Dit gedeelte beschrijft een functie in een app-update die door de App Store wordt beoordeeld. Zodra de update live is, wordt het hier automatisch ontgrendeld.
With compact_label set, the compact leading slot shows that text instead of the art or icon; the expanded and minimal presentations are unchanged.
Example: A Home Assistant player
Create the activity once, then push the player state. Every control here points at a Home Assistant webhook automation; the same shape works for anything that accepts an HTTP request.
curl -X POST https://api.pushward.app/activities \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "living-room-player",
"name": "Living Room"
}'curl -X PATCH https://api.pushward.app/activities/living-room-player \
-H "Authorization: Bearer hlk_YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"state": "ongoing",
"content": {
"template": "media",
"media_title": "Snooze",
"subtitle": "SZA",
"playback_state": "playing",
"position_seconds": 47.5,
"duration_seconds": 214,
"volume": 0.35,
"favorite": true,
"image_url": "https://cdn.example.com/art/snooze.jpg",
"image_shape": "square",
"image_thumbhash": "m8QNJRZ3d3B4aIh4iIiHd4iPjvcX",
"controls": {
"previous": { "url": "https://ha.example/api/webhook/pw-prev" },
"play_pause": { "url": "https://ha.example/api/webhook/pw-toggle" },
"next": { "url": "https://ha.example/api/webhook/pw-next" },
"stop": { "url": "https://ha.example/api/webhook/pw-stop" },
"favorite": { "url": "https://ha.example/api/webhook/pw-fav" },
"volume_down": { "url": "https://ha.example/api/webhook/pw-vol-down" },
"volume_up": { "url": "https://ha.example/api/webhook/pw-vol-up" },
"extra": [
{ "url": "https://ha.example/api/webhook/pw-shuffle", "icon": "shuffle", "title": "Shuffle" }
]
}
}
}'Then, as the player reports back (each one a merge patch onto the same activity):
{ "content": { "playback_state": "paused", "position_seconds": 61.2 } }
{ "content": { "playback_state": "playing", "position_seconds": 61.2 } }
{ "content": { "media_title": "Kill Bill", "position_seconds": 0, "duration_seconds": 153, "favorite": false } }
{ "state": "ended" }The one time position_at is worth sending is to backdate a sample you took a moment ago -- a poll that ran a few seconds before the push. The device starts the bar from that instant instead of from receipt:
{ "content": { "position_seconds": 61.2, "position_at": 1755500000 } }Use Cases
- Multi-room audio -- one activity per zone, buttons steer Sonos, HEOS or Music Assistant through Home Assistant
- TVs and media servers -- a Jellyfin or Plex session with cover art, pause and skip from the Lock Screen
- Radio and streams -- no duration, the elapsed clock counts up, favorite bookmarks the show