-
Notifications
You must be signed in to change notification settings - Fork 3
kithara queue
Documentation reviewed from source revision 19ca073f2. This records the documented contract at that revision; it is not a new runtime validation. API and usage · All crates.
-
Queue<S>owns the player's item list. A caller-suppliedPlayerImpl<S>may still be driven forplay/pause/seek, butreplace_item,clear_item,reserve_slots,select_item*,remove_at,remove_all_itemsare Queue-owned. -
Tracksis the sole owner ofVec<TrackRecord>(status, source, live load attempt), shared withLoaderthroughArc<Tracks>. Every status transition MUST go throughTracks::set_status(or the attempt ops) so the polled view and theQueueEvent::TrackStatusChangedstream cannot drift. -
NavigationStateowns item identity;PlayerImpl::current_indexis only an engine slot cursor and may be stale after EOF / drain. -
Queue::newcallsPlayerImpl::set_auto_advance_enabled(false): the queue is the sole auto-advance orchestrator.
Queue::new(QueueConfig<S>); QueueConfig<S> is a bon builder. Its required
player field is an explicitly constructed PlayerImpl<S>; Queue never constructs
a player, playback worker, or pool region. S must provide HasPool<u8> and
HasPool<f32> and be Send + Sync + 'static; the same schema parameter flows
through Queue, QueueControl, Loader, TrackSource, and ResourceConfig.
-
cancel:Somethreads the app master so the queue subtree cascades from one owner;Nonefalls back to a fresh standalone root (test / library use only, never the production app path).Queue::dropcancels it. The caller owns the player's cancellation and worker lifetime separately. -
store:Nonebuilds anAssetStore<S>from the exactPoolRegion<S>returned byPlayerImpl::pools(). The store and playback allocations therefore remain under the same region-wide hard budget; the queue never extracts or constructs a standalone byte pool.TrackSource::Uriresources share it; a caller-suppliedResourceConfigkeeps its own. - Four tunables sit beside that wiring, three of which a configuration document may
override; see "Configuration document entry point" below.
-
max_concurrent_loads(default 3) sizes the prefetch lane only. -
prefetch_duration(default 3.5) is applied to the supplied player the queue drives, the same wayauto_advance_enabledis. -
max_history_size(default 100) capsNavigationState's history.QueueConfigPatchdoes not declare it, so namingqueue.should_autoplayis refused rather than parsed and dropped. -
should_autoplay(defaulttrue) is consumed only by thecfg(any(test, feature = "probe"))harness. The production append / insert path never starts playback: the caller drives the firstselect/play, so order is deterministic and independent of which load finishes first.
-
QueueConfig<S> is the one configuration struct this crate has: the tunables and the
live handles live in it together. QueueConfigPatch is the second way in: a
configuration document types into it — kithara-app's queue: section — and apply
writes only the fields the document names, leaving the rest of QueueConfig
standing. kithara-app carries the patch on AppConfig and Deck::build applies it
to the QueueConfig it builds — the only construction site a document reaches.
kithara-ffi builds two more (native/inner.rs, web/worker.rs); neither reads a
document. Deserialize only, never Serialize: a typed patch holds resolved secrets
in the clear.
prefetch_duration stays f32 seconds rather than a humantime
duration convention: the value already reaches setter and read call sites across
kithara-play as a bare f32, and converting the type would only churn those for a
formatting preference.
cancel, player, and store are absent from the patch: they are live,
per-construction handles (a CancelToken, an already-built PlayerImpl<S>, an
S-typed AssetStore) that a configuration document has no way to name, the same
reasoning kithara-hls::HlsConfig and kithara-file::FileConfig apply to their own
wiring fields.
-
TrackSource::Uri: the loader parses withResourceSrc::parse, thenResourceConfig::for_src(src).store(queue store).TrackSource::Config(Box<ResourceConfig<S>>)passes through untouched (DRM keys, headers, format hints preserved). Both then runPlayerImpl::prepare_config, which supplies the player's shared playback worker and its pools; a config with no bus gets the player busscoped_labeledwith the track id. An attempt requiresconfig.cancel()to beSome; a missing per-track cancel fails the load withQueueError::Resource. -
TrackSourceisClone, so aConsumed/Cancelled/Failedtrack can be respawned without the caller rebuilding anything.track_source(id)resolves by identity, not by index. - DRM stays in the caller; the crate is DRM-agnostic (see
kithara-app::sources::build_sourcefor building a keyed config).
The crate owns QueueEvent, TrackStatus, AdvanceReason, QueueRepeatMode, and the ItemEvent values translated by its engine-event bridge. They use the shared kithara-events bus; TrackId remains a bus identity owned there.
PR #354 makes the player event carry its item identity directly. The FFI bridge takes its current-item routing identity from that event; queue selection notifications retain their own observer contract.
Queue::subscribe returns typed receivers on the shared EventBus for QueueEvent and the underlying player, audio, HLS, file, and downloader events.
-
CurrentTrackChanged { id: Option<TrackId> }mirrorsPlayerEvent::CurrentItemChanged { item: Option<TrackId> }, re-announced byseekafter queue end. -
CurrentTrackAdvance { id, reason }is published wherever the queue itself commits a selection, carrying theAdvanceReason. -
NextTrackReady { id, index }fires when a load lands in a still-valid slot. -
AudioEvent::UnderrunStarted/UnderrunEndedtranslate toItemEvent::PlaybackStalled/PlaybackLikelyToKeepUp. -
player_rx(drain_player_events,engine_events.rs) shares the rootEventBuschannel with every descendant scope, so under contention it can lag and losePlayerEvent::CurrentItemChanged— edge-triggered and de-duplicated at the source (ItemQueue::announce_current_item), so a dropped copy never re-arrives on its own. OnTryRecvError::Lagged,drain_player_eventsfinishes draining, releases the receiver lock, then callshandle_current_item_changed()once to resync fromPlayerImpl::current_index()directly — the same resyncseekalready performs after an indeterminate gap. The resync runs after the lock is released so its own publish cannot evict the next unread slot and re-triggerLaggedagainst itself.
Pending → Loading (lane permit won) → Slow (on DownloaderEvent::LoadSlow) →
Loaded (after replace_item) → Consumed (the engine took the resource).
Failed(reason) on error; Cancelled when a later select supersedes an in-flight
load. Selecting a Consumed / Cancelled / Failed track respawns a load in the
interactive lane.
Two isolated semaphores so a prefetch parked on a dead host cannot starve a selection:
Prefetch (append-time, max_concurrent_loads permits) and Interactive
(select, one permit). Max in-flight is max_concurrent_loads + 1.
- One live attempt per track (
AttemptGuardinTrackRecord, under the singleTrackslock). Dropping the guard armed cancels the per-trackCancelToken, so removing the record (remove/clear) or flipping the status toCancelledaborts the load with no separate call.finish_attemptdisarms — the token then belongs to the builtResource. - Loader admission and resource creation observe the OR of the queue-owned loader token and the per-track token. Cancellation reaches the per-track subtree before attempt ownership is released.
- Tickets are generation-checked: a replaced ticket loses its claim (
mark_loadingreturnsfalse, the task releases its permit and returnsQueueError::Cancelled). -
selecton a track still waiting for a prefetch permit promotes it into the interactive lane;promote_attemptreplaces a waiting (or cancelled but still unwinding) attempt, keeps one that already holds a permit, and declines a vacant slot because the completion path then owns what happens next. - A cancelled attempt returns
QueueError::Cancelledand leavesTrackStatusto the superseding path. Byte-level dedupe of same-URL downloads is theAssetStore's job.
Queue::select_with_reason and the post-load apply in watch_apply mutate the same
selection state (pending_select, navigation cursor, current item, the Cancelled
supersede marker). The select_apply mutex serialises them, held only across each
side's synchronous critical section — never across .await; without it a
completion could observe "not cancelled", consume pending_select, then select_item
after a later select already committed.
Superseding a still-loading selection marks the prior pending track Cancelled
(override_pending_select / cancel_stale_pending) and evicts its player slot with
clear_item. The completion path reads that marker and skips its select_item; the
eviction closes the race where a fast loader planted the resource before the
override ran. Pinned by tests/tests/kithara_queue/track_switch_race.rs. Re-selecting
the already-playing track is a no-op apart from dropping stale pending state.
advance_to_next resolves the next entry from a read-only navigation snapshot
(next_selectable_entry skips Cancelled records) and must not mutate
NavigationState before the player selection commits: a Loaded entry commits
synchronously inside select, a Pending / Loading / Slow / Consumed entry
commits later in the watch_apply completion, after the resource is planted and
select_item_with_crossfade succeeds. Moving navigation early lets repeated EOF /
handover notifications run ahead of the audible player and exhaust the queue.
After QueueEnded, Queue::seek re-parks navigation from
NavigationState::last_selected_index and re-announces CurrentTrackChanged before
seeking — not from PlayerImpl::current_index.
-
HandoverRequested→advance_loaded_successor, which selects the successor only if it is alreadyLoaded. The queue never consumesPrefetchRequestedand never callsarm_next/commit_next. Gated onitem.track().id == current().idfor the same reasonItemDidPlayToEndis gated on its role: the request names the track that is running out, the advance moves navigation at once, and the outgoing track keeps rendering with its own triggers armed — so its handover can still land after the queue has left it, and applied to the successor it skips that successor unheard. -
ItemDidPlayToEnd:PlayerImpl::process_notificationswalks every active slot, and one slot holds more than one track, so the event names whichever track in the player's arena hit EOF — an orphaned slot decoding ahead, or the outgoing half of a crossfade, reaches its own end while the track being heard has minutes left. Advance (advance_to_next(Crossfade, NaturalEof)) only onitem: ItemRole::Leading. That role is the player's own verdict (kithara-playowns it; see its wiki page), and it is the only trustworthy one. Identity comes fromitem.track().id, never fromsrc:srcis a rendered resource identifier, not a queue key —file://URLs arrive as bare paths, and a playlist repeating a track gives two entries the same one, so resolving by source picks a sibling. -
ItemDidFail→ statusFailed,TrackLoadFailed { auto_skipped: true }, thenadvance_to_next(Transition::None, TrackFailed)— gated onitemfor the same reason asItemDidPlayToEnd, and flagging the entry the event names by id. A non-leading failure is dropped rather than flagged: the item that aborted is not the one being heard. Load-time failures reach the queue through the loader, not this path. - Both handlers publish
QueueEndedwhencurrent()isNone: a stale EOF after queue end must not restart from the first track. -
tick()→maybe_arm_crossfade:should_arm_crossfaderequirescrossfade > 0, positive position and duration, remaining time inside the crossfade window, and no existing arm for this track.crossfade_armed_foris recorded only when the player'scurrent_indexactually moved; the later EOF for that track is then consumed (consume_armed_advance) instead of advancing twice. Cleared onCurrentTrackChanged. - Pause gate: every automatic path no-ops only while
PlayerImpl::is_paused()observes the explicitPausedphase. Effective rate and live output both become inactive at natural EOF without turning that EOF into a user pause. Explicitselect/advance_to_next/return_to_previous/playare never gated. -
CrossfadeStartedis published only while the player's live playback snapshot reports an active predecessor. This is independent of the pause gate: a transport can retain playing intent after natural EOF, but completed audio cannot be the audible predecessor of a crossfade.
-
cached_positionis refreshed eachtick; a0.0sample is dropped when the previous position was above 0.5 s (transient blip on pause/resume).pause()freezes it,CurrentItemChangedresets it toUnknown, a landed seek writeslanded_at.position_seconds()reads this cache, not the engine. -
playback_view()is one coherent read: duration0.0collapses toNone,buffered = max(frontier, cached),positionreplaced by the cached value. The union is deliberate — the cached span is what a host progress bar means by "available without more network", and the decoded frontier stays a floor because a window behind the playhead deadlocks the host into buffering. - No queue-level seek watchdog: the audio pipeline's
#[hang_watchdog]already panics with context on a stalled seek. -
play()starts the engine, then underselect_applyreads back from the player which slot was consumed (item_has_resource) rather than inferring it from a beforehand status snapshot — a load can complete inside the engine-start window. Slot filled by aLoadedtrack → markConsumed(a staleLoadedover an emptied slot makes every later select fail withPlayError::ItemConsumed). Slot still empty with the trackPending/Loading/Slow→ record a pending select and promote the load, sowatch_applyapplies the intent when the resource lands; without this the (normal) case whereplay()wins the race stays silent forever.
-
remove(id)on the current track switches to the next entry — or the previous one at the tail — withTransition::NoneandAdvanceReason::RemovedCurrent; with nothing left it pauses the player. Dropping the record aborts its load. -
clear()drops all records (aborting loads), callsremove_all_items(), then publishesTrackRemovedper id. -
TrackId::allocate()is a process-wide monotonic counter.append_with_id/insert_with_idaccept a caller-owned id, which MUST come fromTrackId::allocateso it stays in that address space (FFI reserves the id at item construction and surfaces it asaudioId). -
NavigationStateis pure logic; the caller owns locking. History is deduped against its tail and capped at thehistory_limitit is constructed with (QueueConfig::max_history_size).next(): unselected →0;RepeatMode::One→ current;Allwraps to0;OffreturnsNoneand clears the current index at the end.prev()returnsNoneat index 0 or before the first selection.finish()pushes the current index into history and clears it, keepinglast_selected_index().shuffle_enabledis stored and reported but not consulted bynext()/prev().