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:
| D | src/_old_main.rs | | | 43 | ------------------------------------------- |
| A | src/app.rs | | | 69 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main.rs | | | 240 | +++---------------------------------------------------------------------------- |
| D | src/ui.rs | | | 253 | ------------------------------------------------------------------------------- |
| A | src/ui/library_ui.rs | | | 237 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/ui/mod.rs | | | 73 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/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)
+}