Skip to main content

phemios_core/playback/
mod.rs

1//! Native consumed-frame PCM client for Aède's authenticated playback contract.
2mod actor;
3mod cd;
4mod cd_authority;
5mod cd_reader;
6mod cd_stream;
7mod crossfade;
8mod crossfade_feedback;
9mod crossfade_mix;
10mod crossfade_source;
11mod output;
12mod protocol;
13mod queue;
14mod removal;
15mod socket;
16mod spectrum;
17mod stream;
18mod transport;
19use crate::network::Client;
20use output::Signals;
21use serde_json::{Value, json};
22use std::sync::{
23    Arc, Mutex,
24    atomic::{AtomicU64, Ordering},
25    mpsc::{self, SyncSender},
26};
27use std::thread::JoinHandle;
28#[derive(Clone)]
29enum Command {
30    Policy(Policy),
31    Crossfade(u64),
32    Jump(usize),
33    Remove(usize, SyncSender<Result<(), String>>),
34    Pause,
35    Resume,
36    Next,
37    Previous,
38    Seek(u64, u64),
39    Stop,
40    Output(String),
41}
42#[derive(Clone, Copy, Default, PartialEq, Eq)]
43pub(super) enum Policy {
44    #[default]
45    WithoutEffects,
46    Dsp,
47}
48impl Policy {
49    fn parse(value: &str) -> Result<Self, String> {
50        match value {
51            "without-effects" => Ok(Self::WithoutEffects),
52            "dsp" => Ok(Self::Dsp),
53            _ => Err("Le flux PCM ne prend pas en charge ce mode de lecture.".into()),
54        }
55    }
56    fn name(self) -> &'static str {
57        match self {
58            Self::WithoutEffects => "without-effects",
59            Self::Dsp => "dsp",
60        }
61    }
62}
63struct Worker {
64    commands: SyncSender<Command>,
65    handle: JoinHandle<()>,
66}
67#[derive(Default)]
68struct State {
69    local_cd: bool,
70    cd_disc: Option<String>,
71    status: &'static str,
72    reference: String,
73    index: usize,
74    total: usize,
75    position: u64,
76    duration: Option<u64>,
77    seek_id: u64,
78    queue_id: u64,
79    rate: u32,
80    error: String,
81    confirmed: bool,
82    device: String,
83    policy: Policy,
84    shuffled: bool,
85    repeat: &'static str,
86    crossfade_ms: u64,
87    queue: Vec<usize>,
88    tracks: Vec<String>,
89    protected: Vec<usize>,
90}
91impl State {
92    fn clear_selection(&mut self) {
93        *self = State {
94            status: "idle",
95            device: std::mem::take(&mut self.device),
96            policy: self.policy,
97            repeat: self.repeat,
98            crossfade_ms: self.crossfade_ms,
99            error: std::mem::take(&mut self.error),
100            ..State::default()
101        };
102    }
103}
104/// One bounded actor owns the socket and device; no credential enters the UI or audio callback.
105pub struct Player {
106    worker: Mutex<Option<Worker>>,
107    next_seek: AtomicU64,
108    next_queue: AtomicU64,
109    state: Arc<Mutex<State>>,
110    signals: Arc<Signals>,
111}
112impl Default for Player {
113    fn default() -> Self {
114        Self {
115            worker: Mutex::new(None),
116            next_seek: AtomicU64::new(1),
117            next_queue: AtomicU64::new(1),
118            state: Arc::new(Mutex::new(State {
119                status: "idle",
120                ..State::default()
121            })),
122            signals: Arc::new(Signals::default()),
123        }
124    }
125}
126impl Player {
127    /// Discover current native outputs only on an explicit client request.
128    pub fn devices(&self) -> Result<Value, String> {
129        output::devices()
130    }
131    /// Return public player state, never source paths, session credentials or buffered PCM.
132    pub fn snapshot(&self) -> Result<Value, String> {
133        let s = self.state.lock().map_err(|_| "État audio indisponible.")?;
134        let levels = if s.status == "playing" && !self.signals.paused.load(Ordering::Relaxed) {
135            self.signals
136                .levels
137                .each_ref()
138                .map(|v| f32::from_bits(v.load(Ordering::Relaxed)))
139        } else {
140            [0.; 4]
141        };
142        let level = if s.status == "playing" && !self.signals.paused.load(Ordering::Relaxed) {
143            f32::from_bits(self.signals.level.load(Ordering::Relaxed))
144        } else {
145            0.
146        };
147        let spectrum = if s.status == "playing" && !self.signals.paused.load(Ordering::Relaxed) {
148            self.signals
149                .spectrum
150                .each_ref()
151                .map(|v| f32::from_bits(v.load(Ordering::Relaxed)))
152        } else {
153            [0.; 24]
154        };
155        Ok(
156            json!({"source":if s.local_cd { "cd" } else { "aede" },"cd_disc":s.cd_disc,"underrun_frames":self.signals.underrun_frames.load(Ordering::Relaxed),"spectrum":spectrum,"spectrum_hz":spectrum::frequencies(),"crossfade_ms":s.crossfade_ms,"repeat":if s.repeat.is_empty() { "off" } else { s.repeat },"queue_id":s.queue_id,"shuffled":s.shuffled,"queue":s.queue,"protected_occurrences":s.protected,"seek_id":s.seek_id,"level":level,"levels":levels,"status":s.status,"reference":s.reference,"index":s.index,"total":s.total,"position_ms":s.position,"duration_ms":s.duration,"sample_rate":s.rate,"error":s.error,"history_confirmed":s.confirmed,"device":s.device,"policy":s.policy.name(),"volume":f32::from_bits(self.signals.volume.load(Ordering::Relaxed))}),
157        )
158    }
159    /// Replace an explicit ordered selection after stopping its previous stream and device.
160    pub fn start(
161        &self,
162        client: Client,
163        tracks: Vec<String>,
164        device: String,
165        index: usize,
166        policy: &str,
167    ) -> Result<(), String> {
168        let policy = Policy::parse(policy)?;
169        if tracks.is_empty()
170            || tracks.len() > 4096
171            || index >= tracks.len()
172            || device.len() > 2048
173            || tracks
174                .iter()
175                .any(|r| protocol::start(r, 48000, policy).is_err())
176        {
177            return Err(
178                "Sélection audio invalide ou trop volumineuse (4096 pistes maximum).".into(),
179            );
180        }
181        if !matches!(
182            client.account()["account"]["role"].as_str(),
183            Some("user" | "admin")
184        ) {
185            return Err("Votre compte ne permet pas la lecture.".into());
186        }
187        self.launch(
188            actor::Source::Remote(client),
189            tracks,
190            device,
191            index,
192            policy,
193            None,
194        )
195    }
196    fn launch(
197        &self,
198        source: actor::Source,
199        tracks: Vec<String>,
200        device: String,
201        index: usize,
202        policy: Policy,
203        permission: Option<cd_authority::Check>,
204    ) -> Result<(), String> {
205        let local_cd = matches!(source, actor::Source::Disc(..));
206        let cd_disc = match &source {
207            actor::Source::Disc(disc, _) => Some(disc.token.clone()),
208            actor::Source::Remote(_) => None,
209        };
210        let mut slot = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
211        self.signals.paused.store(true, Ordering::Relaxed);
212        if let Some(old) = slot.take() {
213            let _ = old.commands.send(Command::Stop);
214            old.handle
215                .join()
216                .map_err(|_| "Arrêt du lecteur interrompu.")?;
217        }
218        let (tx, rx) = mpsc::sync_channel(32);
219        let state = self.state.clone();
220        let signals = self.signals.clone();
221        let crossfade_ms = self
222            .state
223            .lock()
224            .map_err(|_| "État audio indisponible.")?
225            .crossfade_ms;
226        let repeat = self
227            .state
228            .lock()
229            .map_err(|_| "État audio indisponible.")?
230            .repeat;
231        *self.state.lock().map_err(|_| "État audio indisponible.")? = State {
232            local_cd,
233            cd_disc,
234            repeat,
235            crossfade_ms,
236            status: "connecting",
237            total: tracks.len(),
238            index,
239            queue: (0..tracks.len()).collect(),
240            tracks,
241            queue_id: self.next_queue.fetch_add(1, Ordering::Relaxed),
242            device: device.clone(),
243            policy,
244            ..State::default()
245        };
246        let handle = std::thread::spawn(move || {
247            let _authority = cd_authority::Guard::new(permission, source.failure());
248            if let Err(error) = actor::run(source, index, (device, policy), rx, &state, &signals) {
249                signals.paused.store(true, Ordering::Relaxed);
250                if let Ok(mut s) = state.lock() {
251                    s.status = "error";
252                    s.error = error;
253                }
254            }
255        });
256        *slot = Some(Worker {
257            commands: tx,
258            handle,
259        });
260        Ok(())
261    }
262    /// Append occurrences without touching the device, PCM buffers, consumption or visit.
263    /// Both queue identity and expected size refuse delayed or repeated submissions.
264    pub fn append(
265        &self,
266        id: u64,
267        expected_total: usize,
268        tracks: Vec<String>,
269    ) -> Result<(), String> {
270        if tracks.is_empty()
271            || tracks.len() > 4096
272            || tracks
273                .iter()
274                .any(|r| protocol::start(r, 48000, Policy::WithoutEffects).is_err())
275        {
276            return Err(
277                "Sélection audio invalide ou trop volumineuse (4096 pistes maximum).".into(),
278            );
279        }
280        let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
281        if worker.as_ref().is_none_or(|w| w.handle.is_finished()) {
282            return Err("Aucune lecture active.".into());
283        }
284        let mut state = self.state.lock().map_err(|_| "État audio indisponible.")?;
285        if state.local_cd {
286            return Err("Une file CD ne peut pas recevoir de pistes du catalogue.".into());
287        }
288        if state.queue_id != id
289            || state.total != expected_total
290            || state.total == 0
291            || state.tracks.len() != state.total
292        {
293            return Err("La liste de lecture a changé.".into());
294        }
295        let total = state
296            .total
297            .checked_add(tracks.len())
298            .filter(|n| *n <= 4096)
299            .ok_or("Sélection limitée à 4096 pistes.")?;
300        let previous = state.total;
301        state.tracks.extend(tracks);
302        state.queue.extend(previous..total);
303        state.total = total;
304        Ok(())
305    }
306    /// Stop and join the native worker before revoking its authentication session.
307    pub fn halt(&self) -> Result<(), String> {
308        self.stop(false)
309    }
310    /// Stop the worker and forget its selection, retaining device and audio configuration.
311    pub fn clear(&self) -> Result<(), String> {
312        self.stop(true)
313    }
314    fn stop(&self, clear: bool) -> Result<(), String> {
315        let mut slot = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
316        self.signals.paused.store(true, Ordering::Relaxed);
317        if let Some(worker) = slot.as_ref()
318            && !worker.handle.is_finished()
319        {
320            worker
321                .commands
322                .try_send(Command::Stop)
323                .map_err(|_| "Lecteur occupé, réessayez.")?;
324        }
325        if let Some(worker) = slot.take() {
326            worker
327                .handle
328                .join()
329                .map_err(|_| "Arrêt audio interrompu.")?;
330        }
331        update(&self.state, |s| {
332            if clear {
333                s.clear_selection();
334            } else {
335                s.status = "idle";
336                s.tracks.clear();
337                s.protected.clear();
338            }
339        })
340    }
341    /// Queue a seek and return its ticket; snapshots acknowledge it only after the actor applies it.
342    pub fn seek(&self, position: u64) -> Result<u64, String> {
343        if position > 86_400_000 {
344            return Err("Position invalide.".into());
345        }
346        let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
347        let worker = worker
348            .as_ref()
349            .filter(|w| !w.handle.is_finished())
350            .ok_or("Aucune lecture active.")?;
351        let ticket = self.next_seek.fetch_add(1, Ordering::Relaxed);
352        worker
353            .commands
354            .try_send(Command::Seek(position, ticket))
355            .map_err(|_| "Lecteur occupé, réessayez.")?;
356        self.signals.paused.store(true, Ordering::Relaxed);
357        Ok(ticket)
358    }
359    /// Narrow transport controls; unsupported values are explicit errors.
360    pub fn control(&self, action: &str, value: Value) -> Result<(), String> {
361        if action == "crossfade" {
362            let ms = value
363                .as_u64()
364                .filter(|ms| *ms <= 10000)
365                .ok_or("Durée du fondu invalide.")?;
366            let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
367            if let Some(worker) = worker
368                .as_ref()
369                .filter(|worker| !worker.handle.is_finished())
370            {
371                worker
372                    .commands
373                    .try_send(Command::Crossfade(ms))
374                    .map_err(|_| "Lecteur occupé, réessayez.".to_owned())?;
375                // This hot policy command neither resets nor suspends consumed output.
376                return Ok(());
377            }
378            return update(&self.state, |state| state.crossfade_ms = ms);
379        }
380        if action == "repeat" {
381            let repeat = match value.as_str() {
382                Some("off") => "off",
383                Some("one") => "one",
384                Some("all") => "all",
385                _ => return Err("Répétition invalide.".into()),
386            };
387            return update(&self.state, |s| s.repeat = repeat);
388        }
389        if action == "volume" {
390            let volume = value
391                .as_f64()
392                .filter(|v| v.is_finite() && (0.0..=1.0).contains(v))
393                .ok_or("Volume invalide.")?;
394            self.signals
395                .volume
396                .store((volume as f32).to_bits(), Ordering::Relaxed);
397            return Ok(());
398        }
399        if action == "seek" {
400            return self
401                .seek(value.as_u64().ok_or("Position invalide.")?)
402                .map(|_| ());
403        }
404        if action == "queue" {
405            let id = value["queue_id"]
406                .as_u64()
407                .ok_or("File de lecture invalide.")?;
408            let order = value["order"]
409                .as_array()
410                .filter(|order| !order.is_empty() && order.len() <= 4096)
411                .ok_or("File de lecture invalide.")?
412                .iter()
413                .map(|index| {
414                    index
415                        .as_u64()
416                        .filter(|index| *index < 4096)
417                        .map(|index| index as usize)
418                        .ok_or("File de lecture invalide.")
419                })
420                .collect::<Result<Vec<_>, _>>()?;
421            let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
422            if worker
423                .as_ref()
424                .is_none_or(|worker| worker.handle.is_finished())
425            {
426                return Err("Aucune lecture active.".into());
427            }
428            let mut state = self.state.lock().map_err(|_| "État audio indisponible.")?;
429            if state.queue_id != id
430                || order.len() != state.queue.len()
431                || {
432                    let mut requested = order.clone();
433                    requested.sort_unstable();
434                    let mut retained = state.queue.clone();
435                    retained.sort_unstable();
436                    requested != retained
437                }
438                || state.total == 0
439                || state.index >= state.total
440            {
441                return Err("La liste de lecture a changé.".into());
442            }
443            let mut queue = queue::Queue::new(state.total, state.index);
444            queue.synchronize(&order, false, state.total)?;
445            state.queue = queue.order().to_vec();
446            state.shuffled = false;
447            return Ok(());
448        }
449        if action == "shuffle" {
450            let enabled = value.as_bool().ok_or("Lecture aléatoire invalide.")?;
451            let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
452            if worker
453                .as_ref()
454                .is_none_or(|worker| worker.handle.is_finished())
455            {
456                return Err("Aucune lecture active.".into());
457            }
458            let mut state = self.state.lock().map_err(|_| "État audio indisponible.")?;
459            if state.queue.is_empty() {
460                return Err("Aucune lecture active.".into());
461            }
462            let mut queue = queue::Queue::new(state.total, state.index);
463            queue.synchronize(&state.queue, state.shuffled, state.total)?;
464            queue.shuffle(enabled)?;
465            state.queue = queue.order().to_vec();
466            state.shuffled = queue.shuffled();
467            return Ok(());
468        }
469        if action == "policy" && self.is_cd()? {
470            if value == "without-effects" {
471                return Ok(());
472            }
473            return Err("Le traitement DSP n’est pas disponible pour les CD.".into());
474        }
475        let command = match action {
476            "policy" => Command::Policy(Policy::parse(value.as_str().ok_or("Mode invalide.")?)?),
477            "pause" => Command::Pause,
478            "resume" => Command::Resume,
479            "jump" => Command::Jump(
480                value
481                    .as_u64()
482                    .filter(|index| {
483                        self.state
484                            .lock()
485                            .is_ok_and(|state| state.queue.contains(&(*index as usize)))
486                    })
487                    .ok_or("Position de file invalide.")? as usize,
488            ),
489            "next" => Command::Next,
490            "previous" => Command::Previous,
491            "stop" => Command::Stop,
492            "output" => Command::Output(
493                value
494                    .as_str()
495                    .filter(|v| v.len() <= 2048)
496                    .ok_or("Sortie invalide.")?
497                    .into(),
498            ),
499            _ => return Err("Commande audio non prise en charge.".into()),
500        };
501        let worker = self.worker.lock().map_err(|_| "Lecteur indisponible.")?;
502        if matches!(command, Command::Stop) && worker.is_none() {
503            return Ok(());
504        }
505        let worker = worker
506            .as_ref()
507            .filter(|w| !w.handle.is_finished())
508            .ok_or("Aucune lecture active.")?;
509        worker
510            .commands
511            .try_send(command)
512            .map_err(|_| "Lecteur occupé, réessayez.")?;
513        if action != "resume" && action != "policy" {
514            self.signals.paused.store(true, Ordering::Relaxed);
515        }
516        Ok(())
517    }
518}
519impl Drop for Player {
520    fn drop(&mut self) {
521        self.signals.paused.store(true, Ordering::Relaxed);
522        if let Ok(slot) = self.worker.get_mut()
523            && let Some(worker) = slot.take()
524        {
525            let _ = worker.commands.try_send(Command::Stop);
526        }
527    }
528}
529fn update(state: &Mutex<State>, work: impl FnOnce(&mut State)) -> Result<(), String> {
530    let mut s = state.lock().map_err(|_| "État audio indisponible.")?;
531    work(&mut s);
532    Ok(())
533}
534
535#[cfg(test)]
536#[path = "player_tests.rs"]
537mod tests;
538
539#[cfg(test)]
540#[path = "append_tests.rs"]
541mod append_tests;
542
543#[cfg(test)]
544#[path = "clear_tests.rs"]
545mod clear_tests;
546
547#[cfg(test)]
548#[path = "cd_tests.rs"]
549mod cd_tests;