ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit 53fbcf0468bac8e2754a5e0613e3d34d5e44a8d2
parent e7fc72c56f69f3deb70e1e6248a910bfaae83f17
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:31:13 +0900

Add audio playback engine and event plumbing

Playback thread wrapping rodio with play/pause/seek/volume control and
position reporting. Input and tick threads feed a single crossbeam
channel, so key, mouse, tick, audio, and library events share one queue.

Diffstat:
Asrc/audio/mod.rs | 3+++
Asrc/audio/player.rs | 323+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/event/input.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/event/mod.rs | 23+++++++++++++++++++++++
4 files changed, 392 insertion(+), 0 deletion(-)

diff --git a/src/audio/mod.rs b/src/audio/mod.rs
@@ -0,0 +1,3 @@
+pub mod player;
+
+pub use player::{AudioEngine, PlayerCommand};
diff --git a/src/audio/player.rs b/src/audio/player.rs
@@ -0,0 +1,323 @@
+use anyhow::Result;
+use crossbeam_channel::{Receiver, Sender, select, tick};
+use rodio::buffer::SamplesBuffer;
+use rodio::mixer::Mixer;
+use rodio::{Decoder, OutputStreamBuilder, Sink, Source};
+use std::fs::File;
+use std::io::BufReader;
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
+use symphonia::core::audio::SampleBuffer;
+use symphonia::core::codecs::DecoderOptions;
+use symphonia::core::formats::FormatOptions;
+use symphonia::core::io::MediaSourceStream;
+use symphonia::core::meta::MetadataOptions;
+use symphonia::core::probe::Hint;
+
+use crate::event::{AudioEvent, Event};
+
+#[derive(Debug, Clone)]
+pub enum PlayerCommand {
+    Play(PathBuf),
+    Pause,
+    Resume,
+    Stop,
+    SetVolume(f32),
+    Seek(f64),
+}
+
+pub struct AudioEngine {
+    cmd_tx: Sender<PlayerCommand>,
+    _handle: std::thread::JoinHandle<()>,
+}
+
+impl AudioEngine {
+    pub fn new(event_tx: Sender<Event>) -> Result<Self> {
+        let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
+
+        let handle = std::thread::spawn(move || {
+            player_thread(cmd_rx, event_tx);
+        });
+
+        Ok(Self {
+            cmd_tx,
+            _handle: handle,
+        })
+    }
+
+    pub fn send(&self, cmd: PlayerCommand) {
+        let _ = self.cmd_tx.send(cmd);
+    }
+}
+
+fn player_thread(cmd_rx: Receiver<PlayerCommand>, event_tx: Sender<Event>) {
+    let stream = match OutputStreamBuilder::open_default_stream() {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("Failed to open audio output: {}", e);
+            return;
+        }
+    };
+
+    let mixer = stream.mixer().clone();
+    let position_ticker = tick(Duration::from_millis(250));
+
+    loop {
+        select! {
+            recv(cmd_rx) -> msg => {
+                match msg {
+                    Ok(PlayerCommand::Play(path)) => {
+                        match open_and_play(&mixer, &path) {
+                            Ok((sink, duration)) => {
+                                let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
+                                run_playback_loop(
+                                    sink, &mixer, &cmd_rx, &event_tx,
+                                    &position_ticker, duration,
+                                );
+                            }
+                            Err(e) => {
+                                let _ = event_tx.send(Event::Audio(AudioEvent::TrackError(e)));
+                            }
+                        }
+                    }
+                    Ok(PlayerCommand::Stop) => {
+                        let _ = event_tx.send(Event::Audio(AudioEvent::Stopped));
+                    }
+                    Ok(_) => {}
+                    Err(_) => break,
+                }
+            }
+            recv(position_ticker) -> _ => {}
+        }
+    }
+}
+
+fn open_and_play(mixer: &Mixer, path: &PathBuf) -> Result<(Sink, f64), String> {
+    let ext = path
+        .extension()
+        .and_then(|e| e.to_str())
+        .map(|e| e.to_lowercase())
+        .unwrap_or_default();
+
+    // First try rodio's Decoder
+    if let Ok(file) = File::open(path) {
+        let reader = BufReader::new(file);
+        if let Ok(source) = Decoder::new(reader) {
+            let duration = Source::total_duration(&source)
+                .map(|d| d.as_secs_f64())
+                .unwrap_or(0.0);
+            let sink = Sink::connect_new(mixer);
+            sink.append(source);
+            sink.play();
+            return Ok((sink, duration));
+        }
+    }
+
+    // Rodio failed — try extension-specific rodio decoders
+    if let Ok(file) = File::open(path) {
+        let reader = BufReader::new(file);
+        let result = match ext.as_str() {
+            "mp3" => Decoder::new_mp3(reader).ok(),
+            "flac" => Decoder::new_flac(reader).ok(),
+            "wav" => Decoder::new_wav(reader).ok(),
+            "ogg" => Decoder::new_vorbis(reader).ok(),
+            _ => None,
+        };
+        if let Some(source) = result {
+            let duration = Source::total_duration(&source)
+                .map(|d| d.as_secs_f64())
+                .unwrap_or(0.0);
+            let sink = Sink::connect_new(mixer);
+            sink.append(source);
+            sink.play();
+            return Ok((sink, duration));
+        }
+    }
+
+    // Fall back to symphonia direct decoding for m4a/mp4/etc
+    decode_with_symphonia(mixer, path)
+}
+
+/// Decode using symphonia directly, buffer the entire track, and play via rodio Sink.
+fn decode_with_symphonia(mixer: &Mixer, path: &Path) -> Result<(Sink, f64), String> {
+    let file = File::open(path).map_err(|e| format!("Open: {}", e))?;
+    let mss = MediaSourceStream::new(Box::new(file), Default::default());
+
+    let mut hint = Hint::new();
+    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
+        hint.with_extension(ext);
+    }
+
+    let probed = symphonia::default::get_probe()
+        .format(
+            &hint,
+            mss,
+            &FormatOptions::default(),
+            &MetadataOptions::default(),
+        )
+        .map_err(|e| format!("Probe: {}", e))?;
+
+    let mut format = probed.format;
+    let track = format
+        .tracks()
+        .iter()
+        .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
+        .ok_or_else(|| "No audio track found".to_string())?;
+
+    let track_id = track.id;
+    let codec_params = track.codec_params.clone();
+    let sample_rate = codec_params.sample_rate.unwrap_or(44100);
+    let channels = codec_params.channels.map(|c| c.count()).unwrap_or(2) as u16;
+
+    // Calculate duration from track params
+    let duration_secs = codec_params
+        .n_frames
+        .map(|n| n as f64 / sample_rate as f64)
+        .or_else(|| {
+            codec_params
+                .time_base
+                .and_then(|tb| codec_params.n_frames.map(|n| tb.calc_time(n).seconds as f64))
+        })
+        .unwrap_or(0.0);
+
+    let mut decoder = symphonia::default::get_codecs()
+        .make(&codec_params, &DecoderOptions::default())
+        .map_err(|e| format!("Codec: {}", e))?;
+
+    let mut all_samples: Vec<f32> = Vec::new();
+
+    loop {
+        let packet = match format.next_packet() {
+            Ok(p) => p,
+            Err(symphonia::core::errors::Error::IoError(ref e))
+                if e.kind() == std::io::ErrorKind::UnexpectedEof =>
+            {
+                break;
+            }
+            Err(_) => break,
+        };
+
+        if packet.track_id() != track_id {
+            continue;
+        }
+
+        let decoded = match decoder.decode(&packet) {
+            Ok(d) => d,
+            Err(_) => continue,
+        };
+
+        let spec = *decoded.spec();
+        let num_frames = decoded.frames();
+        let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, spec);
+        sample_buf.copy_interleaved_ref(decoded);
+        all_samples.extend_from_slice(sample_buf.samples());
+    }
+
+    if all_samples.is_empty() {
+        return Err("No audio data decoded".to_string());
+    }
+
+    let buffer = SamplesBuffer::new(channels, sample_rate, all_samples);
+    let actual_duration = Source::total_duration(&buffer)
+        .map(|d| d.as_secs_f64())
+        .unwrap_or(duration_secs);
+
+    let sink = Sink::connect_new(mixer);
+    sink.append(buffer);
+    sink.play();
+    Ok((sink, actual_duration))
+}
+
+fn run_playback_loop(
+    sink: Sink,
+    mixer: &Mixer,
+    cmd_rx: &Receiver<PlayerCommand>,
+    event_tx: &Sender<Event>,
+    position_ticker: &Receiver<Instant>,
+    mut duration: f64,
+) {
+    let mut play_start: Option<Instant> = Some(Instant::now());
+    let mut accumulated_secs: f64 = 0.0;
+    let mut is_paused = false;
+
+    loop {
+        select! {
+            recv(cmd_rx) -> msg => {
+                match msg {
+                    Ok(PlayerCommand::Play(path)) => {
+                        sink.stop();
+                        match open_and_play(mixer, &path) {
+                            Ok((new_sink, new_dur)) => {
+                                new_sink.set_volume(sink.volume());
+                                duration = new_dur;
+                                let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
+                                run_playback_loop(
+                                    new_sink, mixer, cmd_rx, event_tx,
+                                    position_ticker, duration,
+                                );
+                            }
+                            Err(e) => {
+                                let _ = event_tx.send(Event::Audio(AudioEvent::TrackError(e)));
+                            }
+                        }
+                        return;
+                    }
+                    Ok(PlayerCommand::Pause) => {
+                        if !is_paused {
+                            sink.pause();
+                            if let Some(start) = play_start.take() {
+                                accumulated_secs += start.elapsed().as_secs_f64();
+                            }
+                            is_paused = true;
+                            let _ = event_tx.send(Event::Audio(AudioEvent::Paused));
+                        }
+                    }
+                    Ok(PlayerCommand::Resume) => {
+                        if is_paused {
+                            sink.play();
+                            play_start = Some(Instant::now());
+                            is_paused = false;
+                            let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
+                        }
+                    }
+                    Ok(PlayerCommand::Stop) => {
+                        sink.stop();
+                        let _ = event_tx.send(Event::Audio(AudioEvent::Stopped));
+                        return;
+                    }
+                    Ok(PlayerCommand::SetVolume(vol)) => {
+                        sink.set_volume(vol);
+                    }
+                    Ok(PlayerCommand::Seek(secs)) => {
+                        if sink.try_seek(Duration::from_secs_f64(secs)).is_ok() {
+                            accumulated_secs = secs;
+                            if !is_paused {
+                                play_start = Some(Instant::now());
+                            }
+                        }
+                    }
+                    Err(_) => return,
+                }
+            }
+            recv(position_ticker) -> _ => {
+                if sink.empty() && !is_paused {
+                    let _ = event_tx.send(Event::Audio(AudioEvent::TrackFinished));
+                    return;
+                }
+
+                let pos = if is_paused {
+                    accumulated_secs
+                } else if let Some(start) = play_start {
+                    accumulated_secs + start.elapsed().as_secs_f64()
+                } else {
+                    accumulated_secs
+                };
+
+                let _ = event_tx.send(Event::Audio(AudioEvent::PositionUpdate {
+                    position_secs: pos.min(duration),
+                    duration_secs: duration,
+                }));
+            }
+        }
+    }
+}
diff --git a/src/event/input.rs b/src/event/input.rs
@@ -0,0 +1,43 @@
+use crossbeam_channel::Sender;
+use crossterm::event::{self, Event as CtEvent, KeyEventKind};
+use std::time::Duration;
+
+use super::Event;
+
+pub fn spawn_input_thread(tx: Sender<Event>) -> std::thread::JoinHandle<()> {
+    std::thread::spawn(move || {
+        loop {
+            if event::poll(Duration::from_millis(100)).unwrap_or(false) {
+                match event::read() {
+                    Ok(CtEvent::Key(key)) => {
+                        if key.kind == KeyEventKind::Press
+                            && tx.send(Event::Key(key)).is_err()
+                        {
+                            break;
+                        }
+                    }
+                    Ok(CtEvent::Mouse(mouse)) => {
+                        if tx.send(Event::Mouse(mouse)).is_err() {
+                            break;
+                        }
+                    }
+                    Ok(CtEvent::Resize(w, h)) => {
+                        if tx.send(Event::Resize(w, h)).is_err() {
+                            break;
+                        }
+                    }
+                    _ => {}
+                }
+            }
+        }
+    })
+}
+
+pub fn spawn_tick_thread(tx: Sender<Event>, interval: Duration) -> std::thread::JoinHandle<()> {
+    std::thread::spawn(move || loop {
+        std::thread::sleep(interval);
+        if tx.send(Event::Tick).is_err() {
+            break;
+        }
+    })
+}
diff --git a/src/event/mod.rs b/src/event/mod.rs
@@ -0,0 +1,23 @@
+pub mod input;
+
+#[derive(Debug)]
+#[allow(dead_code)]
+pub enum Event {
+    Key(crossterm::event::KeyEvent),
+    Mouse(crossterm::event::MouseEvent),
+    Resize(u16, u16),
+    Tick,
+    Audio(AudioEvent),
+    LibraryReady(crate::library::Library),
+}
+
+#[derive(Debug, Clone)]
+#[allow(dead_code)]
+pub enum AudioEvent {
+    PositionUpdate { position_secs: f64, duration_secs: f64 },
+    TrackFinished,
+    TrackError(String),
+    Playing,
+    Paused,
+    Stopped,
+}