harpocrates_musik_player

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

commit 0fa97ac2cbc74b20d24770cc0a179811d5c91d42
parent 0c15ef07cc39f16ae6175c203c0362b73ffc8a67
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Thu,  6 Aug 2026 17:48:54 +0900

refactor(app): modularize application state and UI logic

Extract application state handling into a dedicated App struct and split monolithic UI rendering and key event handling into separate library and settings modules.

Diffstat:
Dsrc/_old_main.rs | 43-------------------------------------------
Asrc/app.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 240+++----------------------------------------------------------------------------
Dsrc/ui.rs | 253-------------------------------------------------------------------------------
Asrc/ui/library_ui.rs | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/mod.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/setting_ui.rs | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 565 insertions(+), 528 deletions(-)

diff --git a/src/_old_main.rs b/src/_old_main.rs @@ -1,43 +0,0 @@ -mod audio; - -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; - -use crate::audio::{AudioEngine, AudioPlayer}; - -fn main() -> Result<(), Box<dyn std::error::Error>> { - let filepath = r"C:\Users\ryout\Music\ハチ\花束と水葬\01 - Persona Alice.flac"; - let mut audioengine: AudioEngine = audio::AudioEngine::new()?; - audioengine.append_a_track(filepath)?; - audioengine.set_volume(0.1); - - crossterm::terminal::enable_raw_mode()?; - loop { - if event::poll(std::time::Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - { - match key.code { - KeyCode::Char('p') => { - if audioengine.is_paused() { - audioengine.play(); - } else { - audioengine.pause(); - } - } - KeyCode::Char('s') => { - audioengine.stop(); - break; - } - _ => {} - } - } - } - - if audioengine.is_empty() { - break; - } - } - - crossterm::terminal::disable_raw_mode()?; - Ok(()) -} diff --git a/src/app.rs b/src/app.rs @@ -0,0 +1,69 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::{ + audio::{AudioEngine, AudioPlayer}, + library::Library, + setting::Settings, + ui::{self, AppState, Focus}, +}; + +pub struct App { + pub state: AppState, + pub settings: Settings, + pub library: Library, + pub audioengine: AudioEngine, +} + +impl App { + pub fn new() -> Result<Self, Box<dyn std::error::Error>> { + let mut settings = Settings::load()?; + let mut audioengine = AudioEngine::new(settings.audio())?; + audioengine.set_volume(settings.audio().volume()); + + let raw_items = crate::library::scan_library(settings.audio().library_path()); + let library = Library::build(raw_items); + + Ok(Self { + state: AppState::new(), + settings, + library, + audioengine, + }) + } + + pub fn recreate_audio_engine(&mut self) -> Result<(), Box<dyn std::error::Error>> { + let mut new_engine = AudioEngine::new(self.settings.audio())?; + new_engine.set_volume(self.settings.audio().volume()); + self.audioengine = new_engine; + Ok(()) + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result<bool, Box<dyn std::error::Error>> { + // 全画面共通のショートカット操作を先に判定 + if !self.state.is_device_dialog_open { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Char('s') => { + self.state.focus = if self.state.focus == Focus::Settings { + Focus::Artist + } else { + Focus::Settings + }; + return Ok(false); + } + KeyCode::Char('p') => { + if self.audioengine.is_paused() { + self.audioengine.play(); + } else { + self.audioengine.pause(); + } + return Ok(false); + } + _ => {} + } + } + + // 画面個別の処理へ委譲 + ui::handle_key_event(self, key) + } +} diff --git a/src/main.rs b/src/main.rs @@ -1,3 +1,4 @@ +mod app; mod audio; mod library; mod setting; @@ -7,257 +8,32 @@ use std::{io, time::Duration}; use crossterm::{ ExecutableCommand, - event::{self, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyEventKind}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, prelude::CrosstermBackend}; -use crate::{ - audio::{AudioEngine, AudioPlayer}, - library::Library, - setting::Settings, - ui::{AppState, Focus}, -}; +use crate::app::App; fn main() -> Result<(), Box<dyn std::error::Error>> { enable_raw_mode()?; io::stdout().execute(EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; - 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()); - let library = Library::build(raw_items); - let mut state = AppState::new(); + let mut app = App::new()?; loop { terminal.draw(|f| { - ui::render(f, &library, &mut state, &settings); + ui::render(f, &mut app); })?; 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(); - } else { - audioengine.pause(); - } - } - KeyCode::Left => { - 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 => { - 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()?; - } - } 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 => { - 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 => { - 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, - 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 => { - 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)) - { - 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())?; - } - audioengine.play(); - } - } - _ => {} - } - } - } - } - _ => {} + let should_quit = app.handle_key_event(key)?; + if should_quit { + break; } } } diff --git a/src/ui.rs b/src/ui.rs @@ -1,253 +0,0 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - 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 { - pub focus: Focus, - 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, 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()); - - 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, - ); - - 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(); - render_list( - f, - "Album", - album_items, - chunks[1], - state.focus == Focus::Album, - &mut state.album_state, - ); - - 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, - ); - } - - 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( - frame: &mut Frame, - title: &str, - items: Vec<ListItem>, - area: Rect, - focused: bool, - state: &mut ListState, -) { - let border_style = if focused { - Style::default().fg(Color::Yellow) - } else { - Style::default() - }; - let list = List::new(items) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ) - .highlight_style( - Style::default() - .bg(Color::Blue) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - frame.render_stateful_widget(list, area, state); -} - -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] -} diff --git a/src/ui/library_ui.rs b/src/ui/library_ui.rs @@ -0,0 +1,237 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +use crate::{app::App, ui::Focus}; + +pub fn render(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(f.area()); + + let artists: Vec<String> = app.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], + app.state.focus == Focus::Artist, + &mut app.state.artist_state, + ); + + let selected_artist = app + .state + .artist_state + .selected() + .and_then(|i| artists.get(i)); + let mut albums: Vec<String> = Vec::new(); + if let Some(artist) = selected_artist { + if let Some(artist_data) = app.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(); + render_list( + f, + "Album", + album_items, + chunks[1], + app.state.focus == Focus::Album, + &mut app.state.album_state, + ); + + let selected_album = app.state.album_state.selected().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) = app.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], + app.state.focus == Focus::Track, + &mut app.state.track_state, + ); +} + +fn render_list( + frame: &mut Frame, + title: &str, + items: Vec<ListItem>, + area: Rect, + focused: bool, + state: &mut ListState, +) { + let border_style = if focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + frame.render_stateful_widget(list, area, state); +} + +pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<bool, Box<dyn std::error::Error>> { + match key.code { + KeyCode::Left => { + app.state.focus = match app.state.focus { + Focus::Artist => Focus::Artist, + Focus::Album => Focus::Artist, + Focus::Track => Focus::Album, + Focus::Settings => Focus::Settings, + }; + } + KeyCode::Right => { + app.state.focus = match app.state.focus { + Focus::Artist => { + app.state.album_state.select(Some(0)); + Focus::Album + } + Focus::Album => { + app.state.track_state.select(Some(0)); + Focus::Track + } + Focus::Track => Focus::Track, + Focus::Settings => Focus::Settings, + }; + } + KeyCode::Up => { + let current_state = match app.state.focus { + Focus::Artist => &mut app.state.artist_state, + Focus::Album => &mut app.state.album_state, + Focus::Track => &mut app.state.track_state, + Focus::Settings => &mut app.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 app.state.focus { + Focus::Artist => app.library.artists.len(), + Focus::Album => { + let artists: Vec<_> = app.library.artists.keys().collect(); + app.state + .artist_state + .selected() + .and_then(|i| artists.get(i)) + .and_then(|a| app.library.artists.get(*a)) + .map(|m| m.len()) + .unwrap_or(0) + } + Focus::Track => { + let artists: Vec<_> = app.library.artists.keys().collect(); + let artist = app + .state + .artist_state + .selected() + .and_then(|i| artists.get(i)); + let albums: Vec<_> = artist + .and_then(|a| app.library.artists.get(*a)) + .map(|m| m.keys().collect()) + .unwrap_or_default(); + let album = app.state.album_state.selected().and_then(|i| albums.get(i)); + artist + .and_then(|a| app.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 app.state.focus { + Focus::Artist => &mut app.state.artist_state, + Focus::Album => &mut app.state.album_state, + Focus::Track => &mut app.state.track_state, + Focus::Settings => &mut app.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<_> = app.library.artists.keys().collect(); + if let Some(artist_name) = app + .state + .artist_state + .selected() + .and_then(|i| artists.get(i)) + { + let artist_data = app.library.artists.get(*artist_name).unwrap(); + let albums: Vec<_> = artist_data.keys().collect(); + + match app.state.focus { + Focus::Track => { + if let Some(album_name) = + app.state.album_state.selected().and_then(|i| albums.get(i)) + { + let tracks = artist_data.get(*album_name).unwrap(); + if let Some(track) = + app.state.track_state.selected().and_then(|i| tracks.get(i)) + { + app.audioengine.stop(); + app.audioengine + .append_a_track(track.path.to_str().unwrap())?; + app.audioengine.play(); + } + } + } + Focus::Album => { + if let Some(album_name) = + app.state.album_state.selected().and_then(|i| albums.get(i)) + { + let tracks = artist_data.get(*album_name).unwrap(); + app.audioengine.stop(); + for track in tracks { + app.audioengine + .append_a_track(track.path.to_str().unwrap())?; + } + app.audioengine.play(); + } + } + _ => {} + } + } + } + _ => {} + } + Ok(false) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -0,0 +1,73 @@ +pub mod library_ui; +pub mod setting_ui; + +use crossterm::event::KeyEvent; +use ratatui::{Frame, widgets::ListState}; + +use crate::app::App; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum Focus { + Artist, + Album, + Track, + Settings, +} + +pub struct AppState { + pub focus: Focus, + 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, app: &mut App) { + if app.state.focus == Focus::Settings { + setting_ui::render(f, app); + } else { + library_ui::render(f, app); + } +} + +pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<bool, Box<dyn std::error::Error>> { + if app.state.focus == Focus::Settings { + setting_ui::handle_key_event(app, key) + } else { + library_ui::handle_key_event(app, key) + } +} diff --git a/src/ui/setting_ui.rs b/src/ui/setting_ui.rs @@ -0,0 +1,178 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, List, ListItem}, +}; + +use crate::{ + app::App, + audio::{AudioEngine, DeviceManager}, +}; + +pub fn render(f: &mut Frame, app: &mut App) { + let area = f.area(); + let settings_items = vec![ + ListItem::new(format!("Volume: {:.2}", app.settings.audio().volume())), + ListItem::new(format!( + "Library Path: {}", + app.settings.audio().library_path() + )), + ListItem::new(format!( + "Output Device: {}", + app.settings.audio().output_device() + )), + ]; + + let list = List::new(settings_items) + .block( + Block::default() + .title("Settings") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, area, &mut app.state.settings_state); + + if app.state.is_device_dialog_open { + render_device_dialog(f, app); + } +} + +fn render_device_dialog(f: &mut Frame, app: &mut App) { + let area = centered_rect(60, 40, f.area()); + f.render_widget(Clear, area); + + let items: Vec<ListItem> = app + .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(">> "); + + f.render_stateful_widget(list, area, &mut app.state.device_dialog_state); +} + +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] +} + +pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<bool, Box<dyn std::error::Error>> { + if app.state.is_device_dialog_open { + return handle_dialog_key_event(app, key); + } + + match key.code { + KeyCode::Up => { + let i = app.state.settings_state.selected().unwrap_or(0); + if i > 0 { + app.state.settings_state.select(Some(i - 1)); + } + } + KeyCode::Down => { + let i = app.state.settings_state.selected().unwrap_or(0); + if i < 2 { + app.state.settings_state.select(Some(i + 1)); + } + } + KeyCode::Left => { + if app.state.settings_state.selected() == Some(0) { + let new_vol = (app.settings.audio().volume() - 0.1).max(0.0); + app.settings.audio_mut().set_volume(new_vol); + app.audioengine.set_volume(new_vol); + app.settings.save()?; + } + } + KeyCode::Right => { + if app.state.settings_state.selected() == Some(0) { + let new_vol = (app.settings.audio().volume() + 0.1).min(1.0); + app.settings.audio_mut().set_volume(new_vol); + app.audioengine.set_volume(new_vol); + app.settings.save()?; + } + } + KeyCode::Enter => { + if app.state.settings_state.selected() == Some(2) { + let devices = DeviceManager::list_device_names(); + app.state.open_device_dialog(devices); + } + } + _ => {} + } + Ok(false) +} + +fn handle_dialog_key_event( + app: &mut App, + key: KeyEvent, +) -> Result<bool, Box<dyn std::error::Error>> { + match key.code { + KeyCode::Up => { + let i = app.state.device_dialog_state.selected().unwrap_or(0); + if i > 0 { + app.state.device_dialog_state.select(Some(i - 1)); + } + } + KeyCode::Down => { + let i = app.state.device_dialog_state.selected().unwrap_or(0); + if !app.state.available_devices.is_empty() && i < app.state.available_devices.len() - 1 + { + app.state.device_dialog_state.select(Some(i + 1)); + } + } + KeyCode::Enter => { + if let Some(i) = app.state.device_dialog_state.selected() { + if let Some(device_name) = app.state.available_devices.get(i) { + app.settings + .audio_mut() + .set_output_device(device_name.clone()); + app.recreate_audio_engine()?; + app.settings.save()?; + } + } + app.state.close_device_dialog(); + } + KeyCode::Esc => { + app.state.close_device_dialog(); + } + _ => {} + } + Ok(false) +}