Skip to main content

Playback and the queue

Claude
Authored by Claude · Last updated AI generated documentation written by reading the source code
self-hosted

In Cardinal, the playback queue lives on the server, not in the app. The app holds one queue item, the one playing, and asks the server what comes next. That is what lets the server grow a dynamic queue as the user listens, place Play Next tracks in the same spot from any device, and keep a history that every app agrees on. An app that keeps its own copy of the order, or reports plays at the wrong moment, breaks all three.

The endpoints below are under /api/v1. The API reference has their full schemas, and Streaming and media covers streams and cover art.

Rules​

The queue​

  1. Every playback session starts by creating a queue. POST /playback-queues with either:

    • { "type": "static", "staticItems": [{ "mediaType": "music_track", "mediaId": "<musicTrackId>" }, ...] }, for a track, a release, or any chosen list, in play order.
    • { "type": "dynamic", "dynamicType": "<type>" }, where the type is one of true_shuffle, house_mix, encore, undertow, fresh_music, or fresh_release. encore and undertow also need "seedMediaType": "music_release" or "music_artist", and "seedMediaId"; house_mix accepts the same seed. A missing required seed is refused with 400, and an unknown seed with 404.

    Add "libraries": [{ "libraryId": "..." }] to draw only from those libraries. The response is the queue with its queueId and its items; play the first item.

  2. Hold the current item, never the order. Keep the queueId and the current item's queueItemId and mediaId. Do not keep a list of what comes next as the source of truth, and do not keep an index.

  3. An item's position is not an index. Positions are fractional: they sort the queue, but they are not contiguous, and position 3 is not the third item. Rely on the order the server returns items in, and never count, compare, or calculate with position.

  4. Ask the server for the neighbour on every move.

    • Next: GET /playback-queues/{queueId}/items?currentQueueItemId={id}&leading=1&includeCurrentItemInReturn=false.
    • Previous: the same with trailing=1 in place of leading=1.
    • The first item: the same without currentQueueItemId, and with includeCurrentItemInReturn=true.

    Each answers [items, total].

  5. Previous restarts the current track when more than 8 seconds of it have played, or when there is no previous item. Otherwise it plays the previous item.

  6. At the end of the queue, when there is no next item, playback stops. With repeat-queue on, play the first item instead. With repeat-track on, a track that ends starts again from 0 and the queue does not advance.

  7. Show upcoming items as a window: GET /playback-queues/{queueId}/items?currentQueueItemId={id}&leading=500&includeCurrentItemInReturn=false. Fetch it again when the current item changes, after any move or insert, and when a playback_queue.extended event names this queue.

  8. Reorder by naming the item to drop behind. PATCH /playback-queues/{queueId}/items/{queueItemId} with { "afterQueueItemId": "..." }. The server works out the position. To move an item to the top of the upcoming list, name the item that is playing. Never send a position. Reorder the app's own list right away so the drag feels immediate, then fetch the window again.

  9. Add tracks with POST /playback-queues/{queueId}/extend and { "insert": "next" | "end", "items": [{ "mediaType": "music_track", "mediaId": "..." }] }. next places them after the item in this queue with the most recent play report, which is normally the one playing (rule 13). With no items, a dynamic queue generates its next batch; a static queue refuses with 400.

  10. Dynamic queues extend themselves. When a play is reported for an item that has fewer than 10 items after it, the server appends the next batch and sends playback_queue.extended with the queueId to the user's apps. The app does not ask for more.

The stream​

  1. Build the stream URL when the item loads, from the current base URL and a current credential: {origin}/api/v1/music/stream/{mediaId}?transcode&bitrate=320&token={token}. Cardinal's own apps request MP3 at 320 kbps for every track, which plays on every platform; a platform that plays the original format can leave out transcode. A player that can set request headers can send the credential in Authorization instead of token.
  2. Give a stream 8 seconds to load. When it has not loaded by then, abandon it, tell the user, and advance to the next item.

History​

  1. Report a play with PATCH /music/history and { "trackId": "<mediaId>", "queueItemId": "...", "seconds": <position> }, at three moments:

    • When the item has loaded and starts playing, with the position it starts at (normally 0).
    • When the item plays to its end.
    • When the user leaves the item early (skip, previous, stop), with the position reached.

    The server keeps one history entry per queue item and overwrites its progress each time, as seconds divided by the track's duration; anything above 99.1% counts as a full play. A report is also what drives rules 9 and 10, so an app that never reports breaks Play Next and stops dynamic queues from growing. Reporting needs the MusicHistory.Create capability; a refusal must never stop playback.

  2. Do not report a rebuild. Reloading the same item, for example after the connection moves, is not a new play.

When the connection moves​

  1. Rebuild the stream on the new base URL when the connection moves: read the current position, build a new stream URL, seek to the same position, and play only if the item is meant to be playing. A track that was still loading counts as playing. A media element keeps the URL it was created with, so without this the track fails part-way through.

Reference implementation​

getToken(), getOrigin(), api(), and onOriginChange() come from Connecting and staying connected. This uses an <audio> element; on other platforms, use the native player in its place.

declare function getToken(options?: { refresh?: boolean }): Promise<string>
declare function getOrigin(): string | null
declare function api(path: string, init?: RequestInit): Promise<Response>
declare function onOriginChange(listener: (origin: string) => void): () => void

type QueueItem = { queueItemId: string, mediaType: 'music_track', mediaId: string }

const LOAD_TIMEOUT_MS = 8_000
const RESTART_THRESHOLD_SECONDS = 8
const JSON_HEADERS = { 'Content-Type': 'application/json' }

const audio = new Audio()
let queueId: string | null = null
let current: QueueItem | null = null
let repeat: 'off' | 'track' | 'queue' = 'off'
let loading: AbortController | null = null
let shouldPlay = false

async function streamUrl(trackId: string): Promise<string> {
const token = encodeURIComponent(await getToken())
return `${getOrigin()}/api/v1/music/stream/${encodeURIComponent(trackId)}?transcode&bitrate=320&token=${token}`
}

function reportPlay(item: QueueItem, seconds: number): void {
void api('/music/history', {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ trackId: item.mediaId, queueItemId: item.queueItemId, seconds }),
}).catch(() => {})
}

async function load(item: QueueItem, { at = 0, play = true, report = true } = {}): Promise<void> {
loading?.abort()
const controller = new AbortController()
loading = controller
current = item
shouldPlay = play

const timer = setTimeout(() => {
controller.abort()
audio.removeAttribute('src')
audio.load()
void next() // and tell the user the track timed out
}, LOAD_TIMEOUT_MS)
controller.signal.addEventListener('abort', () => clearTimeout(timer))

audio.addEventListener('canplay', () => {
clearTimeout(timer)
audio.currentTime = at
if (report) reportPlay(item, at)
if (play) void audio.play()
}, { once: true, signal: controller.signal })

const src = await streamUrl(item.mediaId)
if (loading === controller) audio.src = src
}

// Reports the item being left early at the position it reached.
function leave(): void {
if (current && audio.currentTime > 0 && !audio.ended) reportPlay(current, audio.currentTime)
}

async function items(query: string): Promise<QueueItem[]> {
const res = await api(`/playback-queues/${queueId}/items?${query}`)
const [list] = await res.json()
return list
}

export async function playTracks(trackIds: string[]): Promise<void> {
const res = await api('/playback-queues', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({
type: 'static',
staticItems: trackIds.map((mediaId) => ({ mediaType: 'music_track', mediaId })),
}),
})
const queue = await res.json()
if (!queue?.items?.length) return
leave()
queueId = queue.queueId
await load(queue.items[0])
}

export async function next(): Promise<void> {
if (!queueId || !current) return
let [item] = await items(`currentQueueItemId=${current.queueItemId}&leading=1&includeCurrentItemInReturn=false`)
if (!item && repeat === 'queue') [item] = await items('includeCurrentItemInReturn=true')
leave()
if (item) await load(item)
else stop()
}

export async function previous(): Promise<void> {
if (!queueId || !current) return
const [item] = audio.currentTime > RESTART_THRESHOLD_SECONDS
? []
: await items(`currentQueueItemId=${current.queueItemId}&trailing=1&includeCurrentItemInReturn=false`)
if (!item) {
audio.currentTime = 0
return
}
leave()
await load(item)
}

export function setRepeat(mode: 'off' | 'track' | 'queue'): void {
repeat = mode
}

export function pause(): void {
shouldPlay = false
audio.pause()
}

export function resume(): void {
shouldPlay = true
void audio.play()
}

export function stop(): void {
shouldPlay = false
leave()
loading?.abort()
audio.pause()
audio.removeAttribute('src')
audio.load()
current = null
}

export const moveAfter = (queueItemId: string, afterQueueItemId: string) =>
api(`/playback-queues/${queueId}/items/${queueItemId}`, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ afterQueueItemId }),
})

export const addTracks = (trackIds: string[], insert: 'next' | 'end') =>
api(`/playback-queues/${queueId}/extend`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ insert, items: trackIds.map((mediaId) => ({ mediaType: 'music_track', mediaId })) }),
})

audio.addEventListener('ended', () => {
if (!current) return
reportPlay(current, audio.duration)
if (repeat === 'track') {
audio.currentTime = 0
void audio.play()
return
}
void next()
})

onOriginChange(() => {
if (!current) return
void load(current, { at: audio.currentTime, play: shouldPlay, report: false })
})

Media controls​

The bundled web apps do not register system media controls. An app that does, such as through the Media Session API in a browser, MPNowPlayingInfoCenter on Apple platforms, or MediaSession on Android, routes them to the same functions as its own buttons, so the rules above still hold: the previous action restarts a track past 8 seconds, and every skip reports the item being left.

if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', resume)
navigator.mediaSession.setActionHandler('pause', pause)
navigator.mediaSession.setActionHandler('nexttrack', () => void next())
navigator.mediaSession.setActionHandler('previoustrack', () => void previous())
}

For the title, artists, and artwork, GET /music/track/{mediaId} returns the track with its artists and release. Artwork is the release cover, which needs the token query parameter when the platform loads it by URL.

Edge cases​

  • A queue with no items. A dynamic queue on a library with nothing to draw from is created empty. There is nothing to play; tell the user.
  • The user skips while a track is loading. The load in progress is abandoned. Only an item that loaded and started is reported at start.
  • Preloading the next track. Cardinal's own apps load each track when it becomes current. An app that fetches ahead must still report the next item only when it starts playing: a report marks the item as played and moves where Play Next inserts.
  • The queue changes on another device or on the server. Because the app only holds the current item, the next neighbour it asks for is always the server's current answer.
  • A credential expires during a long track. A credential in a stream URL is checked on every range request, including a seek. When a seek fails after the credential has been renewed, rebuild the stream the same way as after a connection move (rule 15).
  • History is refused. A user without MusicHistory.Create can still play. Keep playing, and expect Play Next to fall back to the end of the queue and dynamic queues not to grow.
  • Movies and TV. The API does not stream video yet; this recipe covers music only.

How to verify it works​

  1. Play a release. Confirm a POST /playback-queues with every track in order, then one PATCH /music/history with seconds of 0 when audio starts.
  2. Skip twice. Confirm each skip asks the server for the next item, and reports the item being left with the position it reached.
  3. Press previous after 10 seconds and confirm the track restarts; press it within 8 seconds and confirm the previous item plays.
  4. Let a track play to its end and confirm a report with seconds equal to its duration. GET /music/history should then show its progress as 1.
  5. Use Play Next on a track and confirm it plays after the current item, including when it was added from another device.
  6. Start a true_shuffle queue, skip until fewer than 10 items remain, and confirm a playback_queue.extended event arrives and the upcoming list grows.
  7. Change the base URL while a track plays and confirm it continues from the same position, with no new history report.

Was this article helpful?