# kithara-queue — Context
Detailed contracts and invariants for the kithara-queue crate; the README is the overview.
## Public API
- [`Queue::new(QueueConfig)`]
- CRUD: `append`, `insert(source, after)`, `remove`, `clear`,
`set_tracks`
- Query: `tracks`, `track(id)`, `current`, `current_index`, `len`,
`is_empty`
- Navigation: `select(id)`, `advance_to_next`, `return_to_previous`,
`set_shuffle` / `is_shuffle_enabled`, `set_repeat` / `repeat_mode`,
`seek(seconds)`
- Delegated to `PlayerImpl`: `play`, `pause`, `is_playing`,
`crossfade_duration` / `set_crossfade_duration`, `default_rate` /
`set_default_rate`, `volume` / `set_volume`, `is_muted` / `set_muted`,
`eq_band_count`, `eq_gain`, `set_eq_gain`, `reset_eq`,
`position_seconds`, `duration_seconds`
- Lifecycle: `tick()` — call from the host loop to drive
`PlayerImpl::tick` and drain engine events into `QueueEvent`s.
[`TrackSource`] is the input to `append` / `insert` / `set_tracks`. It
has two shapes:
- `TrackSource::Uri(String)` — the [`Queue`] builds a default
`ResourceConfig` from the `QueueConfig` `net` / `store` templates.
- `TrackSource::Config(Box<ResourceConfig>)` — the caller provides a
pre-built `ResourceConfig` (useful for DRM keys, custom headers,
format hints). [`Queue`] leaves caller-set fields intact.
`From<&str>`, `From<String>`, `From<ResourceConfig>`, and
`From<Box<ResourceConfig>>` are implemented.
## Event Flow
[`Queue::subscribe`] returns an `EventReceiver` that sees everything
published on the underlying [`EventBus`]: `Event::Queue(QueueEvent::..)`
plus `Event::Player(..)`, `Event::Audio(..)`, `Event::Hls(..)`, and
`Event::File(..)`.
[`QueueEvent`] variants:
- `TrackAdded { id, index }`
- `TrackRemoved { id }`
- `TrackStatusChanged { id, status }` — `Pending` → `Loading` / `Slow`
→ `Loaded` → `Consumed` (after the engine takes the `Resource`
during `select_item`) → `Failed(reason)` on error
- `CurrentTrackChanged { id }` — forwarded from
`PlayerEvent::CurrentItemChanged`
- `QueueEnded` — emitted by `advance_to_next` when navigation returns
`None` and [`RepeatMode::Off`] is active
- `CrossfadeStarted { duration_seconds }` — emitted when the engine is
about to fade from a playing track to the newly-selected one
- `CrossfadeDurationChanged { seconds }`
## Auto-Advance Contract
`Queue` is the sole auto-advance orchestrator: `Queue::new` calls
`PlayerImpl::set_auto_advance_enabled(false)` to disable the player's
built-in linear handler, then drives transitions from
`PlayerEvent::PrefetchRequested` / `HandoverRequested`:
- on `PrefetchRequested`: resolve the next index via
`NavigationState::peek_next` (honouring shuffle / repeat); if the
resolved entry is `TrackStatus::Loaded`, call `arm_next(idx)`. Tracks
that are still loading are picked up via the
`TrackStatusChanged { Loaded }` retry path.
- on `HandoverRequested` (cf>0 only): call `commit_next(idx)`,
advance navigation, mark the just-promoted track `Consumed`, publish
`QueueEvent::CrossfadeStarted`.
- on `ItemDidPlayToEnd`: the audio thread already advanced (cf=0 arena
handover) or the queue did (cf>0 commit). `sync_navigation_after_handover`
brings `NavigationState::current_index` in line with the player and
emits `QueueEvent::QueueEnded` if no further track is reachable.
Auto-advance is gated on the user pause state (`Queue::is_paused`, i.e.
the player's live rate is `0.0`): a paused queue never auto-advances or
arms a crossfade. The advance path resumes playback (`autoplay: true`),
so firing it while paused would silently un-pause and let the committed
position run on — the user paused, the head must freeze. The event
handlers (`ItemDidPlayToEnd`, `ItemDidFail`, `maybe_arm_crossfade`)
therefore no-op while paused. The gate reads the rate, not
`is_playing()`: a natural end-of-track drops `is_playing()` to `false`
once the arena drains, and gating on that would wrongly block the
genuine end-of-track advance; the rate stays `> 0` through EOF and only
drops to `0.0` on a deliberate `pause`. Explicit user navigation
(`select`, `advance_to_next`, `play`) is unaffected — only the
automatic, event-driven transitions are gated.
`set_repeat`, `set_shuffle`, `Queue::remove`, and `Queue::clear` call
`PlayerImpl::unarm_next` so a stale arm cannot survive a navigation /
queue mutation. The previous `Queue::tick`-based polling
(`maybe_arm_crossfade`, `should_arm_crossfade`) is removed; `tick` now
only ticks the player and drains events.
## Loading Lifecycle
Each `append` allocates a monotonic [`TrackId`] and a queue entry with
status `Pending`, then spawns a background task:
1. Acquire a semaphore permit (up to
`QueueConfig::max_concurrent_loads`).
2. Publish `TrackStatusChanged { Loading }`.
3. Build the `ResourceConfig` (either from `TrackSource::Uri` templates
or the caller-supplied `Config`) and call `Resource::new`.
4. Spawn a LoadSlow listener on the config's `EventBus` — if
`FileEvent::LoadSlow` or `HlsEvent::LoadSlow` fires,
`TrackStatusChanged { Slow }` is published before completion.
5. On success: `PlayerImpl::replace_item(index, resource)`,
`TrackStatusChanged { Loaded }`.
6. If the loaded track was stashed in `pending_select` (a `select(id)`
arrived before loading finished), call `select_item(index, true)`.
Otherwise the track stays `Loaded` and does nothing until the caller
explicitly selects it.
After `select_item` succeeds the engine has consumed `items[index]`, so
the Queue immediately transitions the entry to `TrackStatus::Consumed`.
Re-selecting a `Consumed` track respawns the load.
The Queue never starts playback on its own: there is no autoplay. The
caller drives the first `select` / `play` explicitly so playback order
is deterministic and independent of which load finishes first.
## Selection serialization
`Queue::select` and a track's `spawn_apply_after_load` completion both mutate the
same selection state — `pending_select`, the navigation cursor, the current item,
and the `TrackStatus::Cancelled` supersede marker. They are serialized by an
internal `select_apply` lock, held only across each side's **synchronous**
critical section (never across an `.await`).
This closes a barge-in race: superseding a still-loading selection works by
marking the prior pending track `Cancelled` (`override_pending_select` /
`cancel_stale_pending`), which the completion path reads to skip its
`select_item`. Without serialization a completion could observe "not cancelled",
consume `pending_select`, and then run its `select_item` *after* a later
`select` had already committed — letting the superseded track barge in over the
new current. The lock makes the completion's cancelled-check and `select_item` a
single critical section, mutually exclusive with `select`. Pinned by
`tests/.../track_switch_race.rs`.
## Advance Commit Ownership
`Queue::advance_to_next` resolves the next selectable entry from a read-only
navigation snapshot. It must not mutate `NavigationState` before the player
selection commits. A `Loaded` entry commits synchronously inside `select`; a
`Pending` / `Loading` / `Consumed` entry commits later in
`spawn_apply_after_load` after the resource has been planted and
`select_item_with_crossfade` succeeds. If navigation moves before that commit,
repeated EOF / handover notifications can run ahead of the audible player and
exhaust the queue while the player still points at the old track.
After `QueueEnded`, a later `Queue::seek` reparks navigation from the last
navigation-owned index, not from `PlayerImpl::current_index`. The queue owns item
identity; the player cursor is only an engine slot cursor and may be stale after
EOF/drain.
## Migration From kithara-app
The previous `kithara-app::{playlist, controls}` combination collapses
into a single [`Queue`]:
| `Arc<PlayerImpl>` + `Arc<Playlist>` + `AppController` + `TrackLoadParams` | `Arc<Queue>` |
| `AppController::load_params.load_and_apply` | `queue.set_tracks(sources)` / `queue.append(src)` |
| `playlist.track_name(i)` | `queue.tracks()[i].name` |
| `playlist.track_status(i)` | `queue.tracks()[i].status` |
| `playlist.get_next_track()` + switch | `queue.advance_to_next()` |
| `playlist.get_prev_track()` + switch | `queue.return_to_previous()` |
| `player.seek_seconds(s)` | `queue.seek(s)` |
| `player.select_item(idx, true)` | `queue.select(id)` |
| `player.tick()` | `queue.tick()` |
DRM stays in the caller. `kithara-queue` is DRM-agnostic; apps that
need zvuk DRM keys build a `ResourceConfig::new(url).with_keys(..)` and
pass it via `TrackSource::Config(Box::new(cfg))`. See
`kithara-app::sources::build_source` for a reference implementation.