harpocrates_musik_player

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 0c15ef07cc39f16ae6175c203c0362b73ffc8a67
parent 91f3b13632ae1cd334bfd9094aeab5ad0aaf9297
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Thu,  6 Aug 2026 17:42:06 +0900

feat(audio): add output device management and settings UI view

Diffstat:
MCargo.toml | 4++--
Msrc/audio.rs | 50+++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/main.rs | 280++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/setting.rs | 21+++++++++++++++++++++
Msrc/ui.rs | 249++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
5 files changed, 432 insertions(+), 172 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -6,8 +6,8 @@ edition = "2024" [dependencies] crossterm = "0.29.0" ratatui = {version = "0.30.2"} -rodio = {version="0.22.2", default-features = true} -toml = {version = "1.1.4+spec-1.1.0"} +rodio = {version="0.22.2", features = ["playback"]} +toml = {version = "1.1.4"} serde = {version = "1.0.190", features = ["derive"]} directories = {version = "6.0.0"} diff --git a/src/audio.rs b/src/audio.rs @@ -1,9 +1,14 @@ +use crate::setting::AudioSettings; +use rodio::Device; +use rodio::cpal::traits::{DeviceTrait, HostTrait}; +use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink}; + pub struct AudioEngine { player: rodio::Player, stream_handle: rodio::MixerDeviceSink, } pub trait AudioPlayer { - fn new() -> Result<AudioEngine, Box<dyn std::error::Error>>; + fn new(audio_settings: &AudioSettings) -> Result<AudioEngine, Box<dyn std::error::Error>>; fn append_a_track(&mut self, file: &str) -> Result<(), Box<dyn std::error::Error>>; fn play(&mut self); fn pause(&mut self); @@ -19,8 +24,8 @@ pub trait AudioPlayer { } impl AudioPlayer for AudioEngine { - fn new() -> Result<Self, Box<dyn std::error::Error>> { - let stream_handle = rodio::DeviceSinkBuilder::open_default_sink()?; + fn new(audio_settings: &AudioSettings) -> Result<Self, Box<dyn std::error::Error>> { + let stream_handle = DeviceManager::open_sink(&audio_settings.output_device())?; let player = rodio::Player::connect_new(stream_handle.mixer()); Ok(Self { player, @@ -70,3 +75,42 @@ impl AudioPlayer for AudioEngine { Ok(()) } } + +pub struct DeviceManager; + +impl DeviceManager { + pub fn list_device_names() -> Vec<String> { + let host = rodio::cpal::default_host(); + let Ok(devices) = host.output_devices() else { + return Vec::new(); + }; + + devices.filter_map(|d| d.name().ok()).collect() + } + + pub fn find_device_by_name(name: &str) -> Option<Device> { + if name.trim().is_empty() { + return None; + } + + let host = rodio::cpal::default_host(); + let Ok(mut devices) = host.output_devices() else { + return None; + }; + + devices.find(|d| d.name().map(|n| n == name).unwrap_or(false)) + } + + pub fn open_sink(device_name: &str) -> Result<MixerDeviceSink, Box<dyn std::error::Error>> { + if let Some(device) = Self::find_device_by_name(device_name) { + if let Ok(builder) = DeviceSinkBuilder::from_device(device) { + if let Ok(sink) = builder.open_stream() { + return Ok(sink); + } + } + } + + let sink = DeviceSinkBuilder::open_default_sink()?; + Ok(sink) + } +} diff --git a/src/main.rs b/src/main.rs @@ -24,8 +24,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { io::stdout().execute(EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; - let settings: Settings = Settings::load()?; - let mut audioengine: AudioEngine = AudioEngine::new()?; + let mut settings: Settings = Settings::load()?; + let mut audioengine: AudioEngine = AudioEngine::new(settings.audio())?; audioengine.set_volume(settings.audio().volume()); let raw_items = library::scan_library(settings.audio().library_path()); @@ -34,15 +34,57 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { loop { terminal.draw(|f| { - ui::render(f, &library, &mut state); + ui::render(f, &library, &mut state, &settings); })?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { + if state.is_device_dialog_open { + match key.code { + KeyCode::Up => { + let i = state.device_dialog_state.selected().unwrap_or(0); + if i > 0 { + state.device_dialog_state.select(Some(i - 1)); + } + } + KeyCode::Down => { + let i = state.device_dialog_state.selected().unwrap_or(0); + if !state.available_devices.is_empty() + && i < state.available_devices.len() - 1 + { + state.device_dialog_state.select(Some(i + 1)); + } + } + KeyCode::Enter => { + if let Some(i) = state.device_dialog_state.selected() { + if let Some(device_name) = state.available_devices.get(i) { + settings.audio_mut().set_output_device(device_name.clone()); + audioengine = AudioEngine::new(settings.audio())?; + audioengine.set_volume(settings.audio().volume()); + settings.save()?; + } + } + state.close_device_dialog(); + } + KeyCode::Esc => { + state.close_device_dialog(); + } + _ => {} + } + continue; + } + match key.code { KeyCode::Char('q') => break, + KeyCode::Char('s') => { + state.focus = if state.focus == Focus::Settings { + Focus::Artist + } else { + Focus::Settings + }; + } KeyCode::Char('p') => { if audioengine.is_paused() { audioengine.play(); @@ -51,117 +93,167 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { } } KeyCode::Left => { - state.focus = match state.focus { - Focus::Artist => Focus::Artist, - Focus::Album => Focus::Artist, - Focus::Track => Focus::Album, - }; + if state.focus == Focus::Settings { + let selected_setting = state.settings_state.selected().unwrap_or(0); + if selected_setting == 0 { + let current_volume = settings.audio().volume(); + let new_volume = (current_volume - 0.01).max(0.0); + settings.audio_mut().set_volume(new_volume); + audioengine.set_volume(new_volume); + settings.save()?; + } + } else { + state.focus = match state.focus { + Focus::Artist => Focus::Artist, + Focus::Album => Focus::Artist, + Focus::Track => Focus::Album, + Focus::Settings => Focus::Settings, + }; + } } KeyCode::Right => { - state.focus = match state.focus { - Focus::Artist => { - state.album_state.select(Some(0)); - Focus::Album + if state.focus == Focus::Settings { + let selected_setting = state.settings_state.selected().unwrap_or(0); + if selected_setting == 0 { + let current_volume = settings.audio().volume(); + let new_volume = (current_volume + 0.01).min(1.0); + settings.audio_mut().set_volume(new_volume); + audioengine.set_volume(new_volume); + settings.save()?; } - Focus::Album => { - state.track_state.select(Some(0)); - Focus::Track - } - Focus::Track => Focus::Track, - }; + } else { + state.focus = match state.focus { + Focus::Artist => { + state.album_state.select(Some(0)); + Focus::Album + } + Focus::Album => { + state.track_state.select(Some(0)); + Focus::Track + } + Focus::Track => Focus::Track, + Focus::Settings => Focus::Settings, + }; + } } KeyCode::Up => { - let current_state = match state.focus { - Focus::Artist => &mut state.artist_state, - Focus::Album => &mut state.album_state, - Focus::Track => &mut state.track_state, - }; - let i = current_state.selected().unwrap_or(0); - if i > 0 { - current_state.select(Some(i - 1)); + if state.focus == Focus::Settings { + let i = state.settings_state.selected().unwrap_or(0); + if i > 0 { + state.settings_state.select(Some(i - 1)); + } + } else { + let current_state = match state.focus { + Focus::Artist => &mut state.artist_state, + Focus::Album => &mut state.album_state, + Focus::Track => &mut state.track_state, + Focus::Settings => &mut state.artist_state, + }; + let i = current_state.selected().unwrap_or(0); + if i > 0 { + current_state.select(Some(i - 1)); + } } } KeyCode::Down => { - // カウントを取得するためにデータを取得 - let max_len = match state.focus { - Focus::Artist => library.artists.len(), - Focus::Album => { - let artists: Vec<_> = library.artists.keys().collect(); - state - .artist_state - .selected() - .and_then(|i| artists.get(i)) - .and_then(|a| library.artists.get(*a)) - .map(|m| m.len()) - .unwrap_or(0) - } - Focus::Track => { - let artists: Vec<_> = library.artists.keys().collect(); - let artist = - state.artist_state.selected().and_then(|i| artists.get(i)); - let albums: Vec<_> = artist - .and_then(|a| library.artists.get(*a)) - .map(|m| m.keys().collect()) - .unwrap_or_default(); - let album = - state.album_state.selected().and_then(|i| albums.get(i)); - artist - .and_then(|a| library.artists.get(*a)) - .and_then(|m| album.and_then(|al| m.get(*al))) - .map(|v| v.len()) - .unwrap_or(0) + if state.focus == Focus::Settings { + let max_len = 3; + let i = state.settings_state.selected().unwrap_or(0); + if i < max_len - 1 { + state.settings_state.select(Some(i + 1)); } - }; + } else { + let max_len = match state.focus { + Focus::Artist => library.artists.len(), + Focus::Album => { + let artists: Vec<_> = library.artists.keys().collect(); + state + .artist_state + .selected() + .and_then(|i| artists.get(i)) + .and_then(|a| library.artists.get(*a)) + .map(|m| m.len()) + .unwrap_or(0) + } + Focus::Track => { + let artists: Vec<_> = library.artists.keys().collect(); + let artist = + state.artist_state.selected().and_then(|i| artists.get(i)); + let albums: Vec<_> = artist + .and_then(|a| library.artists.get(*a)) + .map(|m| m.keys().collect()) + .unwrap_or_default(); + let album = + state.album_state.selected().and_then(|i| albums.get(i)); + artist + .and_then(|a| library.artists.get(*a)) + .and_then(|m| album.and_then(|al| m.get(*al))) + .map(|v| v.len()) + .unwrap_or(0) + } + Focus::Settings => 0, + }; - let current_state = match state.focus { - Focus::Artist => &mut state.artist_state, - Focus::Album => &mut state.album_state, - Focus::Track => &mut state.track_state, - }; - let i = current_state.selected().unwrap_or(0); - if max_len > 0 && i < max_len - 1 { - current_state.select(Some(i + 1)); + let current_state = match state.focus { + Focus::Artist => &mut state.artist_state, + Focus::Album => &mut state.album_state, + Focus::Track => &mut state.track_state, + Focus::Settings => &mut state.artist_state, + }; + let i = current_state.selected().unwrap_or(0); + if max_len > 0 && i < max_len - 1 { + current_state.select(Some(i + 1)); + } } } KeyCode::Enter => { - // 再生ロジック - let artists: Vec<_> = library.artists.keys().collect(); - if let Some(artist_name) = - state.artist_state.selected().and_then(|i| artists.get(i)) - { - let artist_data = library.artists.get(*artist_name).unwrap(); - let albums: Vec<_> = artist_data.keys().collect(); + if state.focus == Focus::Settings { + let selected_setting = state.settings_state.selected().unwrap_or(0); + if selected_setting == 2 { + let devices = audio::DeviceManager::list_device_names(); + state.open_device_dialog(devices); + } + } else { + let artists: Vec<_> = library.artists.keys().collect(); + if let Some(artist_name) = + state.artist_state.selected().and_then(|i| artists.get(i)) + { + let artist_data = library.artists.get(*artist_name).unwrap(); + let albums: Vec<_> = artist_data.keys().collect(); - match state.focus { - Focus::Track => { - if let Some(album_name) = - state.album_state.selected().and_then(|i| albums.get(i)) - { - let tracks = artist_data.get(*album_name).unwrap(); - if let Some(track) = - state.track_state.selected().and_then(|i| tracks.get(i)) + match state.focus { + Focus::Track => { + if let Some(album_name) = + state.album_state.selected().and_then(|i| albums.get(i)) { - audioengine.stop(); - audioengine - .append_a_track(track.path.to_str().unwrap())?; - audioengine.play(); + let tracks = artist_data.get(*album_name).unwrap(); + if let Some(track) = state + .track_state + .selected() + .and_then(|i| tracks.get(i)) + { + audioengine.stop(); + audioengine + .append_a_track(track.path.to_str().unwrap())?; + audioengine.play(); + } } } - } - Focus::Album => { - if let Some(album_name) = - state.album_state.selected().and_then(|i| albums.get(i)) - { - let tracks = artist_data.get(*album_name).unwrap(); - audioengine.stop(); - for track in tracks { - audioengine - .append_a_track(track.path.to_str().unwrap())?; + Focus::Album => { + if let Some(album_name) = + state.album_state.selected().and_then(|i| albums.get(i)) + { + let tracks = artist_data.get(*album_name).unwrap(); + audioengine.stop(); + for track in tracks { + audioengine + .append_a_track(track.path.to_str().unwrap())?; + } + audioengine.play(); } - audioengine.play(); } + _ => {} } - _ => {} } } } diff --git a/src/setting.rs b/src/setting.rs @@ -43,6 +43,17 @@ impl Settings { &self.audio } + pub fn audio_mut(&mut self) -> &mut AudioSettings { + &mut self.audio + } + + pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> { + let path = Self::get_path().ok_or("Failed to get config directory path")?; + let content = toml::to_string(self)?; + std::fs::write(path, content)?; + Ok(()) + } + pub fn load() -> Result<Self, Box<dyn std::error::Error>> { let path = Self::get_path().ok_or("Failed to get config directory path")?; if !path.exists() { @@ -72,4 +83,14 @@ impl AudioSettings { pub fn volume(&self) -> f32 { self.volume } + + pub fn set_volume(&mut self, volume: f32) { + self.volume = volume; + } + pub fn output_device(&self) -> &str { + &self.output_device + } + pub fn set_output_device(&mut self, output_device: String) { + self.output_device = output_device; + } } diff --git a/src/ui.rs b/src/ui.rs @@ -1,17 +1,19 @@ use ratatui::{ Frame, - layout::{Constraint, Direction, Layout}, + layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, List, ListItem, ListState}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, }; use crate::library::Library; +use crate::setting::Settings; #[derive(PartialEq, Eq, Clone, Copy)] pub enum Focus { Artist, Album, Track, + Settings, } pub struct AppState { @@ -19,100 +21,156 @@ pub struct AppState { pub artist_state: ListState, pub album_state: ListState, pub track_state: ListState, + pub settings_state: ListState, + pub is_device_dialog_open: bool, + pub available_devices: Vec<String>, + pub device_dialog_state: ListState, } impl AppState { pub fn new() -> Self { let mut artist_state = ListState::default(); artist_state.select(Some(0)); + let mut settings_state = ListState::default(); + settings_state.select(Some(0)); + let mut device_dialog_state = ListState::default(); + device_dialog_state.select(Some(0)); Self { focus: Focus::Artist, artist_state, album_state: ListState::default(), track_state: ListState::default(), + settings_state, + is_device_dialog_open: false, + available_devices: Vec::new(), + device_dialog_state, } } + + pub fn open_device_dialog(&mut self, devices: Vec<String>) { + self.available_devices = devices; + self.is_device_dialog_open = true; + self.device_dialog_state.select(Some(0)); + } + + pub fn close_device_dialog(&mut self) { + self.is_device_dialog_open = false; + } } -pub fn render(f: &mut Frame, library: &Library, state: &mut AppState) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(f.area()); +pub fn render(f: &mut Frame, library: &Library, state: &mut AppState, settings: &Settings) { + if state.focus == Focus::Settings { + render_settings(f, state, settings); + } else { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(f.area()); - // 1. Artists - let artists: Vec<String> = library.artists.keys().cloned().collect(); - let artist_items: Vec<ListItem> = artists - .iter() - .map(|name| ListItem::new(name.as_str())) - .collect(); + let artists: Vec<String> = library.artists.keys().cloned().collect(); + let artist_items: Vec<ListItem> = artists + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + render_list( + f, + "Artist", + artist_items, + chunks[0], + state.focus == Focus::Artist, + &mut state.artist_state, + ); - render_list( - f, - "Artist", - artist_items, - chunks[0], - state.focus == Focus::Artist, - &mut state.artist_state, - ); - - // 2. Albums (selected artist) - let selected_artist = state - .artist_state - .select_index() - .and_then(|i| artists.get(i)); - let mut albums: Vec<String> = Vec::new(); - if let Some(artist) = selected_artist { - if let Some(artist_data) = library.artists.get(artist) { - albums = artist_data.keys().cloned().collect(); + let selected_artist = state + .artist_state + .select_index() + .and_then(|i| artists.get(i)); + let mut albums: Vec<String> = Vec::new(); + if let Some(artist) = selected_artist { + if let Some(artist_data) = library.artists.get(artist) { + albums = artist_data.keys().cloned().collect(); + } } - } - let album_items: Vec<ListItem> = albums - .iter() - .map(|name| ListItem::new(name.as_str())) - .collect(); + let album_items: Vec<ListItem> = albums + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + render_list( + f, + "Album", + album_items, + chunks[1], + state.focus == Focus::Album, + &mut state.album_state, + ); - render_list( - f, - "Album", - album_items, - chunks[1], - state.focus == Focus::Album, - &mut state.album_state, - ); - - // 3. Tracks (selected album) - let selected_album = state.album_state.select_index().and_then(|i| albums.get(i)); - let mut tracks: Vec<String> = Vec::new(); - if let (Some(artist), Some(album)) = (selected_artist, selected_album) { - if let Some(album_data) = library.artists.get(artist).and_then(|a| a.get(album)) { - tracks = album_data.iter().map(|t| t.title.clone()).collect(); + let selected_album = state.album_state.select_index().and_then(|i| albums.get(i)); + let mut tracks: Vec<String> = Vec::new(); + if let (Some(artist), Some(album)) = (selected_artist, selected_album) { + if let Some(album_data) = library.artists.get(artist).and_then(|a| a.get(album)) { + tracks = album_data.iter().map(|t| t.title.clone()).collect(); + } } + let track_items: Vec<ListItem> = tracks + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + render_list( + f, + "Track", + track_items, + chunks[2], + state.focus == Focus::Track, + &mut state.track_state, + ); } - let track_items: Vec<ListItem> = tracks - .iter() - .map(|name| ListItem::new(name.as_str())) - .collect(); - render_list( - f, - "Track", - track_items, - chunks[2], - state.focus == Focus::Track, - &mut state.track_state, - ); + if state.is_device_dialog_open { + render_device_dialog(f, state); + } +} + +pub fn render_settings(frame: &mut Frame, state: &mut AppState, settings: &Settings) { + let area = frame.area(); + let settings_items = vec![ + ListItem::new(format!("Volume: {:.2}", settings.audio().volume())), + ListItem::new(format!("Library Path: {}", settings.audio().library_path())), + ListItem::new(format!( + "Output Device: {}", + settings.audio().output_device() + )), + ]; + + let list = List::new(settings_items) + .block( + Block::default() + .title("Settings") + .borders(Borders::ALL) + .border_style(if state.focus == Focus::Settings { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, area, &mut state.settings_state); } fn render_list( - f: &mut Frame, + frame: &mut Frame, title: &str, items: Vec<ListItem>, - area: ratatui::layout::Rect, + area: Rect, focused: bool, state: &mut ListState, ) { @@ -121,7 +179,6 @@ fn render_list( } else { Style::default() }; - let list = List::new(items) .block( Block::default() @@ -135,16 +192,62 @@ fn render_list( .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); - - f.render_stateful_widget(list, area, state); + frame.render_stateful_widget(list, area, state); } -// Helper trait to get selection index safely trait ListStateExt { fn select_index(&self) -> Option<usize>; } + impl ListStateExt for ListState { fn select_index(&self) -> Option<usize> { self.selected() } } + +pub fn render_device_dialog(frame: &mut Frame, state: &mut AppState) { + let area = centered_rect(60, 40, frame.area()); + frame.render_widget(Clear, area); + + let items: Vec<ListItem> = state + .available_devices + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title("Select Output Device") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, area, &mut state.device_dialog_state); +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +}