# Queue and playback The server owns one play queue and one audio output. Clients send queue commands and playback commands over gRPC and watch the result on the update stream; the queue you see in a terminal is the same queue the browser tab sees, live (see [Introduction](./intro.md)). ## The queue model The queue is an ordered list of tracks with a **current position** and two modifiers: - **repeat** — after the last track, wrap to the first instead of stopping. - **shuffle** — play in a shuffled order. The shuffle order is derived around the current track and is deliberately *not* persisted; toggling shuffle off restores track order. Every queue operation names its effect relative to the current track: - **replace** — drop the queue and start the new tracks playing. - **append** — add to the end. - **queue** — insert right after the current track. - **insert** — insert at a given position. - **remove** / **clear** — drop tracks, or everything (optionally keeping the current track). ## The playback loop is the single writer All queue state lives inside one task, the **playback loop**. Every mutation — from a client command, from a chunk arriving mid-resolve, from the playback loop advancing tracks — happens inside that loop and nowhere else. Each change funnels through one broadcast site, so the queue snapshot pushed to clients and the snapshot handed to persistence can never drift apart. Because the loop is the only writer, work that would block it is spawned off: disk writes for a queue save run on a separate task, and large collections are resolved on their own tasks (below). User commands keep flowing through the loop while that background work runs. ```admonish note Clients never poll. The loop pushes a `Queue` update on every content change, a `QueueTrack` update when the current track moves, and `Mods` updates when shuffle or repeat toggles. A client that connects mid-flight gets the full state — including whether a resolve is in progress — in its init response. ``` ## Progressive queueing Queueing a large collection — an artist with many albums, a thousand-track playlist — does not wait for the whole thing to resolve. Tracks are resolved in **chunks**, in playback order, and streamed into the queue as they arrive. Playback can start on the first chunk while later chunks are still being fetched. ```d2 shape: sequence_diagram user: { shape: person } tui: client loop: playback loop fwd: forwarder task provider: provider user -> tui: queue a large artist tui -> loop: "Append(paths)" loop -> loop: register pending resolve op loop -> tui: "Queue update (resolving = true)" loop -> fwd: spawn forwarder fwd -> provider: "ResolveTracks(path, chunk channel)" provider -> fwd: chunk 1 fwd -> loop: "ApplyResolvedChunk(op, chunk 1)" loop -> loop: "apply + play() first track" loop -> tui: "Queue update (resolving = true)" provider -> fwd: chunk 2 fwd -> loop: "ApplyResolvedChunk(op, chunk 2)" loop -> tui: "Queue update (resolving = true)" fwd -> loop: "ResolveFinished(op)" loop -> tui: "Queue update (resolving = false)" ``` The mechanics: - The loop registers a **pending op** (an id, the kind, an insertion cursor, and a cancel flag) and spawns a **forwarder** task. It immediately broadcasts a `Queue` update with the unchanged tracks and `resolving = true` so feedback appears within one round trip. - The forwarder drives the provider's chunked resolve over a small bounded channel and forwards each chunk back to the loop as an `ApplyResolvedChunk` command, then a final `ResolveFinished`. Queue state is mutated only when the loop processes those commands. - Each chunk is applied per op kind: **replace** resets on its first chunk and appends the rest; **append** appends every chunk; **queue**/**insert** advance an insertion cursor so tracks land in order after the current one. Only the chunk that first makes a track current starts the player; later chunks never restart it. - Backpressure is real at every hop — a slow consumer slows the fetching rather than buffering without bound. ```admonish tip While the latest `Queue` update carries `resolving = true`, the TUI shows an animated dots pseudo-item after the last track. It is drawn at render time only and never enters the list, so it cannot be selected or removed. ``` ### Replace and clear cancel in-flight resolves A `replace` or a `clear` marks every pending op cancelled and drops it. Each forwarder sees the flag and drops its chunk channel; the provider's next send fails and it stops fetching. Any chunk already in flight for a dropped op is ignored by the loop, so stale tracks from the old operation can never trickle into the queue you just replaced or emptied. Additive ops (`append`, `queue`, `insert`) do not cancel each other — their chunks interleave between ops while each op keeps its own internal order. ## Skipped tracks in playback A track can be marked **skipped** — a capture recorded its source as uncapturable (see [The crabidy store](./store.md)). Skipped tracks stay in the queue (you see the gap, not a silently shorter list) and render red in the TUI. When playback looks for the next track to play, it advances past unplayable ones: - a track marked skipped is passed over **without a provider round trip**; - a track whose stream URLs fail to resolve is passed over with a warning. The whole skip loop is **bounded by the queue length at entry** — one full pass at most. Without that bound, an all-skipped queue with repeat on would cycle forever and hammer the provider; instead the search gives up after one pass and the player stops. ## Editing the queue from a client Queue edits are ordinary RPCs — `Insert`, `Remove`, `Clear`, `SetCurrent` — and both clients drive them the same way: mark rows with `s`, sweep them with visual mode (`v`/`V`), then `d` to remove or `y` to yank. Removed and yanked tracks land in a client-side **register** that `p`/`P` paste back, which is the closest thing to an undo the queue has. It is per client and in memory; the server keeps no edit history. See [the terminal client](./clients/tui.md#the-register-y-d-and-pp) for the keys and the limits. Two consequences worth knowing: - `Insert` takes the index to insert **at**: the row that was there, and everything after it, shifts down. `0` is the front and a position at or past the end appends, so every insertion point is reachable — which is what pasting *before* the first row needs. "Play after track N" is `N + 1`. - `Remove` takes **positions**, so a client must send positions from the queue state it is currently showing. Both clients carry their marks across each pushed queue snapshot by matching track paths, so a mark follows its own track when playback advances or another client edits the queue — rather than silently pointing at whatever now sits at that index. - `Insert` takes **paths**, so a paste re-resolves them. Pasting a yanked node expands it to tracks at paste time, and a path that no longer resolves simply does not come back. ## Persistence The live queue survives a server restart. It is mirrored to the reserved `/crabidy/current` folder (see [The crabidy store](./store.md)): - a **flat set of link tomls**, one per track in queue order, plus - a hidden **`.queue-state.toml`** sidecar holding the current position, repeat, and shuffle flags. Writes never block the loop. Every queue-state change sends a snapshot into a single-slot **latest-wins** channel; a dedicated persister task debounces briefly (so a burst of resolve chunks coalesces into one write), skips writes whose snapshot is unchanged from the last, and rewrites `current` with the same atomic temp-and-swap the store uses for any save. Disk failures are warnings — playback is never affected. When no usable state directory exists, persistence is simply disabled and the queue lives in memory only. ```admonish warning Only the queue's *track order* and position are persisted — not the shuffled play order, and not the position within the current track. Restoring a shuffled queue reshuffles around the restored current track. ``` ### Restore on startup, never autoplay Before the loop starts serving, the server reads `current` directly — the sorted link tomls rewritten back to their targets, plus the state sidecar — and applies them to the queue, restoring the position and the repeat/shuffle flags. It leaves the player **stopped**: a restarted server comes back silent, with your queue intact, waiting for you to press play. A missing folder is a fresh start; a broken entry or an out-of-range position is skipped or clamped with a warning. ```admonish note The `current` folder is an ordinary-looking queue folder under `/crabidy`, but its name is reserved: the playback loop overwrites it on every change, and a save can never use the name `current`. ``` ## See also - [The crabidy store](./store.md) — saves, captures, and the `current` mirror. - [The library model](./library.md) — tracks, paths, and links. - [The terminal UI](./clients/tui.md) — the queue pane and its key bindings. - [Configuration](./config.md) — where the persisted queue is stored.