Skip to main content

kithara_queue/
config.rs

1use std::num::NonZeroUsize;
2
3use bon::Builder;
4use kithara_assets::AssetStore;
5use kithara_bufpool::HasPool;
6use kithara_derive::Patch;
7use kithara_platform::{CancelToken, tokio::runtime::Handle as RuntimeHandle};
8use kithara_play::{CrossfadeSettings, PlayerImpl};
9
10use crate::{ActionAtItemEnd, PlaybackOrder};
11
12/// Default parallelism cap for async track loads.
13pub(crate) const DEFAULT_MAX_CONCURRENT_LOADS: NonZeroUsize = match NonZeroUsize::new(3) {
14    Some(n) => n,
15    None => unreachable!(),
16};
17
18/// Default prefetch lead time before EOF, in seconds.
19///
20/// Mirrors `kithara_play::PlayerConfig::prefetch_duration` default.
21pub(crate) const DEFAULT_PREFETCH_DURATION: f32 = 3.5;
22
23/// Configuration for a [`Queue`](crate::Queue).
24///
25/// Holds queue-level defaults plus the owned [`PlayerImpl`] instance whose
26/// item list the queue coordinates.
27///
28/// [`TrackSource::Uri`](crate::TrackSource::Uri) resources share this queue's
29/// store. A caller-supplied [`ResourceConfig`](kithara_play::ResourceConfig)
30/// retains its own store.
31#[derive(Builder, derive_more::Debug, Patch)]
32#[builder(state_mod(vis = "pub"))]
33#[non_exhaustive]
34pub struct QueueConfig<S>
35where
36    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
37{
38    #[builder(default)]
39    pub action_at_item_end: ActionAtItemEnd,
40
41    #[builder(default)]
42    pub crossfade_settings: CrossfadeSettings,
43
44    /// Max concurrent background prefetch loads. Default: 3.
45    #[builder(default = DEFAULT_MAX_CONCURRENT_LOADS)]
46    pub max_concurrent_loads: NonZeroUsize,
47
48    /// Master cancel for the queue. `Some` threads the app master so the
49    /// queue subtree cascades from one app-wide owner; `None` falls back
50    /// to a fresh standalone token (test / library use). Must never be
51    /// `None` on the production app path.
52    #[patch(skip)]
53    #[debug(skip)]
54    pub cancel: Option<CancelToken>,
55
56    /// Shared store used for bare URI track sources.
57    #[patch(skip)]
58    #[debug(skip)]
59    pub store: Option<AssetStore<S>>,
60
61    #[builder(default)]
62    pub playback_order: PlaybackOrder,
63
64    /// Runtime the queue runs its loads and load completions on. `None`
65    /// takes the runtime current where the queue is built; an embedding
66    /// that drives the queue from threads without one (FFI hosts) passes
67    /// its own.
68    #[patch(skip)]
69    #[debug(skip)]
70    pub runtime: Option<RuntimeHandle>,
71
72    /// Player owned and decorated by this queue.
73    #[patch(skip)]
74    #[debug(skip)]
75    pub player: PlayerImpl<S>,
76
77    /// Whether the queue starts playback by itself once the first track
78    /// appended to a queue with nothing selected finishes loading. Off by
79    /// default: the embedding decides when playback starts. A document cannot
80    /// name it, because starting playback is the embedding's choice.
81    #[builder(default = false)]
82    #[patch(skip)]
83    pub should_autoplay: bool,
84
85    /// Lead time in seconds before EOF at which the next queued track
86    /// is preloaded into the audio processor. Default: 3.5. Stays `f32`
87    /// seconds rather than the campaign's `humantime` duration convention:
88    /// the value already reaches 10 setter and 14 read call sites as a bare
89    /// `f32`, and converting the type would only churn those for a
90    /// formatting preference.
91    #[builder(default = DEFAULT_PREFETCH_DURATION)]
92    pub prefetch_duration: f32,
93
94    /// Entries the navigation history keeps. Only explicit selections and
95    /// auto-advances land there, so the default is a listening session's
96    /// worth of back-steps; the queue's own track list is unbounded.
97    #[builder(default = 100)]
98    pub max_history_size: usize,
99}
100
101#[cfg(test)]
102mod tests {
103    use kithara_play::{PlayWorker, PlayWorkerConfig, PlayerConfig};
104    use kithara_test_utils::kithara;
105
106    use super::*;
107    use crate::{
108        queue::{TEST_SAMPLE_RATE, test_session},
109        test_pools::pools,
110    };
111
112    pub(super) fn config() -> QueueConfig<crate::test_pools::TestPools> {
113        let worker = PlayWorker::new(PlayWorkerConfig::builder(pools()).build());
114        let player = PlayerImpl::new(
115            PlayerConfig::builder()
116                .sample_rate(TEST_SAMPLE_RATE)
117                .worker(worker)
118                .session(test_session())
119                .build(),
120        );
121        QueueConfig::builder().player(player).build()
122    }
123
124    #[kithara::test]
125    fn default_config_has_reasonable_loader_cap() {
126        let cfg = config();
127
128        assert_eq!(cfg.max_concurrent_loads.get(), 3);
129        assert!(cfg.store.is_none());
130        assert!((cfg.prefetch_duration - 3.5).abs() < f32::EPSILON);
131    }
132}
133
134#[cfg(all(test, not(target_arch = "wasm32")))]
135mod document_tests {
136    use kithara_test_utils::kithara;
137
138    use super::{QueueConfigPatch, tests::config};
139
140    #[kithara::test(native, flash(false))]
141    fn a_document_sets_the_load_cap_and_leaves_the_history_size() {
142        let patch: QueueConfigPatch =
143            serde_yaml_ng::from_str("max_concurrent_loads: 5\n").expect("the document types");
144        // Seeded off the crate default so a merge that reset every unnamed
145        // field could not pass this by coincidence.
146        let mut config = config();
147        config.max_history_size = 37;
148
149        config.apply(patch);
150
151        assert_eq!(config.max_concurrent_loads.get(), 5);
152        assert_eq!(
153            config.max_history_size, 37,
154            "a key the document does not name must keep its seeded value"
155        );
156    }
157
158    /// `concurrent_load_cap` is neither a real key nor a substring of one,
159    /// so the refusal cannot pass off serde's list of valid names.
160    #[kithara::test(native, flash(false))]
161    fn an_unknown_field_is_rejected_and_named() {
162        let error = serde_yaml_ng::from_str::<QueueConfigPatch>("concurrent_load_cap: 5\n")
163            .expect_err("a typo must not be silently ignored");
164
165        assert!(error.to_string().contains("concurrent_load_cap"), "{error}");
166    }
167}