use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use rand::seq::SliceRandom;
use ratatui::DefaultTerminal;
use std::time::{Duration, Instant};
use crate::{
cli::Cli,
filepicker::{FilePicker, FilePickerOutcome},
library::{ALL_TRACKS_ID, Library, PlaylistId, Track, TrackId},
lyrics,
modal::{Modal, ModalConfirm, ModalOutcome, RemoveTarget, TextInput, make_lyrics_textarea},
player::{PlayerEvent, PlayerHandle, resolve_duration, spawn_audio_thread},
settings::Settings,
ui,
ui::layout::{Theme, theme_by_name},
};
const SPEED_STEP: f32 = 0.05;
const SPEED_MIN: f32 = 0.05;
const SPEED_MAX: f32 = 3.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoopMode {
#[default]
Off,
Queue,
Track,
}
impl LoopMode {
pub fn cycle(self) -> Self {
match self {
Self::Off => Self::Queue,
Self::Queue => Self::Track,
Self::Track => Self::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Sidebar,
TrackList,
Queue,
}
impl Focus {
pub fn cycle(self) -> Self {
match self {
Self::Sidebar => Self::TrackList,
Self::TrackList => Self::Queue,
Self::Queue => Self::Sidebar,
}
}
}
pub struct AppState {
pub library: Library,
pub player: PlayerHandle,
pub focus: Focus,
pub active_playlist: PlaylistId,
pub sidebar_cursor: usize,
pub tracklist_cursor: usize,
pub queue_cursor: usize,
pub queue: Vec<Track>,
pub now_playing: Option<usize>,
pub track_start: Option<Instant>,
pub seek_offset: Duration,
pub track_duration: Option<Duration>,
pub modal: Option<Modal>,
pub file_picker: Option<FilePicker>,
pub loop_mode: LoopMode,
pub theme: Theme,
pub settings: Settings,
pub should_quit: bool,
pub tracklist_filter: String,
pub filter_active: bool,
pub show_lyrics: bool,
pub lyrics_scroll: usize,
pub lyrics_lines: Vec<lyrics::LyricLine>,
filtered_ids: Vec<TrackId>,
}
impl AppState {
pub fn new(library: Library, player: PlayerHandle, settings: Settings) -> Self {
let mut theme = theme_by_name(&settings.theme_name).clone();
theme.transparent = settings.transparent;
let mut s = Self {
library,
player,
focus: Focus::Sidebar,
active_playlist: ALL_TRACKS_ID,
sidebar_cursor: 0,
tracklist_cursor: 0,
queue_cursor: 0,
queue: Vec::new(),
now_playing: None,
track_start: None,
seek_offset: Duration::ZERO,
track_duration: None,
modal: None,
file_picker: None,
loop_mode: LoopMode::Off,
theme,
settings,
should_quit: false,
tracklist_filter: String::new(),
filter_active: false,
show_lyrics: false,
lyrics_scroll: 0,
lyrics_lines: Vec::new(),
filtered_ids: Vec::new(),
};
s.rebuild_filter_cache();
s
}
pub fn elapsed(&self) -> Duration {
if self.now_playing.is_none() {
return Duration::ZERO;
}
if self.player.is_paused {
return self.seek_offset;
}
let wall = self
.track_start
.map(|s| s.elapsed())
.unwrap_or(Duration::ZERO);
self.seek_offset + wall.mul_f32(self.player.playback_speed)
}
pub fn progress_ratio(&self) -> f64 {
let elapsed = self.elapsed().as_secs_f64();
match self.track_duration {
Some(d) if d.as_secs_f64() > 0.0 => (elapsed / d.as_secs_f64()).clamp(0.0, 1.0),
_ => 0.0,
}
}
pub fn enqueue(&mut self, track: Track) {
self.queue.push(track);
}
pub fn play_queue_index(&mut self, idx: usize) {
if idx >= self.queue.len() {
return;
}
let path = self.queue[idx].path.clone();
self.player.play(path.clone());
self.now_playing = Some(idx);
self.track_start = Some(Instant::now());
self.seek_offset = Duration::ZERO;
self.track_duration = resolve_duration(&path);
if self.show_lyrics {
self.lyrics_scroll = 0;
self.refresh_lyrics_cache();
}
}
pub fn play_next(&mut self) {
match self.loop_mode {
LoopMode::Track => {
let idx = self.now_playing.unwrap_or(0);
self.play_queue_index(idx);
}
LoopMode::Queue => {
let next = self.now_playing.map(|i| i + 1).unwrap_or(0);
let idx = if next < self.queue.len() { next } else { 0 };
if !self.queue.is_empty() {
self.play_queue_index(idx);
}
}
LoopMode::Off => {
let next = self.now_playing.map(|i| i + 1).unwrap_or(0);
if next < self.queue.len() {
self.play_queue_index(next);
} else {
self.halt_playback();
}
}
}
}
pub fn play_prev(&mut self) {
match self.loop_mode {
LoopMode::Track => {
let idx = self.now_playing.unwrap_or(0);
self.play_queue_index(idx);
}
LoopMode::Queue => {
if !self.queue.is_empty() {
let idx = match self.now_playing {
None | Some(0) => self.queue.len() - 1,
Some(i) => i - 1,
};
self.play_queue_index(idx);
}
}
LoopMode::Off => {
if let Some(cur) = self.now_playing
&& cur > 0
{
self.play_queue_index(cur - 1);
}
}
}
}
fn halt_playback(&mut self) {
self.now_playing = None;
self.track_start = None;
self.seek_offset = Duration::ZERO;
self.track_duration = None;
self.player.stop();
}
pub fn rebuild_filter_cache(&mut self) {
let all = self.library.playlist_tracks(self.active_playlist);
if self.tracklist_filter.is_empty() {
self.filtered_ids = all.iter().map(|t| t.id).collect();
return;
}
let q = self.tracklist_filter.to_lowercase();
self.filtered_ids = all
.iter()
.filter(|t| {
t.name.to_lowercase().contains(&q)
|| t.artist
.as_deref()
.is_some_and(|s| s.to_lowercase().contains(&q))
|| t.album
.as_deref()
.is_some_and(|s| s.to_lowercase().contains(&q))
|| t.genre
.as_deref()
.is_some_and(|s| s.to_lowercase().contains(&q))
|| t.year.is_some_and(|y| y.to_string().contains(&*q))
})
.map(|t| t.id)
.collect();
}
pub fn active_tracks(&self) -> Vec<&Track> {
self.filtered_ids
.iter()
.filter_map(|&id| self.library.track(id))
.collect()
}
fn selected_track(&self) -> Option<Track> {
self.active_tracks()
.get(self.tracklist_cursor)
.map(|t| (*t).clone())
}
pub fn tick(&mut self) {
for event in self.player.drain_events() {
match event {
PlayerEvent::TrackFinished => self.play_next(),
PlayerEvent::Error(msg) => {
self.modal = Some(Modal::Notify { message: msg });
}
}
}
}
pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
if matches!(self.modal, Some(Modal::ConfirmQuit)) {
self.modal = None;
self.apply_modal_confirm(ModalConfirm::Quit);
} else {
self.file_picker = None;
self.modal = Some(Modal::ConfirmQuit);
}
return;
}
if let Some(picker) = &mut self.file_picker {
match picker.handle_key(code) {
FilePickerOutcome::Continue => return,
FilePickerOutcome::Dismissed => {
self.file_picker = None;
return;
}
FilePickerOutcome::Selected(path) => {
self.file_picker = None;
self.import_file(&path);
return;
}
}
}
if let Some(modal) = &mut self.modal {
match modal.handle_key(code, modifiers) {
ModalOutcome::Consumed => return,
ModalOutcome::Dismissed => {
self.modal = None;
return;
}
ModalOutcome::Confirm(c) => {
if !matches!(c, ModalConfirm::PreviewTheme { .. }) {
self.modal = None;
}
self.apply_modal_confirm(c);
return;
}
}
}
if self.show_lyrics && self.modal.is_none() && self.file_picker.is_none() {
match code {
KeyCode::Char('j') | KeyCode::Down => {
self.lyrics_scroll = self.lyrics_scroll.saturating_add(1);
return;
}
KeyCode::Char('k') | KeyCode::Up => {
self.lyrics_scroll = self.lyrics_scroll.saturating_sub(1);
return;
}
KeyCode::Esc | KeyCode::Char('y') => {
self.show_lyrics = false;
return;
}
_ => {}
}
}
if self.filter_active {
match code {
KeyCode::Esc => {
self.tracklist_filter.clear();
self.filter_active = false;
self.tracklist_cursor = 0;
self.rebuild_filter_cache();
return;
}
KeyCode::Backspace => {
self.tracklist_filter.pop();
self.tracklist_cursor = 0;
self.rebuild_filter_cache();
return;
}
KeyCode::Char(c) => {
self.tracklist_filter.push(c);
self.tracklist_cursor = 0;
self.rebuild_filter_cache();
return;
}
KeyCode::Enter => {
self.filter_active = false;
}
_ => {}
}
}
match code {
KeyCode::Char('q') => self.modal = Some(Modal::ConfirmQuit),
KeyCode::Char('?') => self.modal = Some(Modal::Help),
KeyCode::Char(' ') => self.action_toggle_play(),
KeyCode::Char('n') => self.play_next(),
KeyCode::Char('N') => self.play_prev(),
KeyCode::Left => self.action_seek(-(self.settings.seek_step_secs as i64)),
KeyCode::Right => self.action_seek(self.settings.seek_step_secs as i64),
KeyCode::Char('+') | KeyCode::Char('=') => self.player.volume_up(),
KeyCode::Char('-') => self.player.volume_down(),
KeyCode::Tab => {
self.focus = self.focus.cycle();
self.filter_active = false;
self.tracklist_filter.clear();
self.tracklist_cursor = 0;
self.rebuild_filter_cache();
}
KeyCode::Char('j') | KeyCode::Down => self.cursor_down(),
KeyCode::Char('k') | KeyCode::Up => self.cursor_up(),
KeyCode::Enter => self.action_enter(),
KeyCode::Char('a') => self.action_add_to_queue(),
KeyCode::Char('p') => self.action_add_to_playlist(),
KeyCode::Char('d') => self.action_remove(),
KeyCode::Char('r') => self.action_rename(),
KeyCode::Char('c') => self.action_new_playlist(),
KeyCode::Char('f') => self.action_open_filepicker(),
KeyCode::Char('m') => self.action_open_menu(),
KeyCode::Char('l') => self.loop_mode = self.loop_mode.cycle(),
KeyCode::Char('z') => self.action_shuffle_playlist(),
KeyCode::Char('[') => self.action_speed_down(),
KeyCode::Char(']') => self.action_speed_up(),
KeyCode::Char('e') => self.action_edit_metadata(),
KeyCode::Char('y') => self.action_toggle_lyrics(),
KeyCode::Char('/') if self.focus == Focus::TrackList => {
self.filter_active = true;
}
_ => {}
}
}
fn cursor_down(&mut self) {
match self.focus {
Focus::Sidebar => {
let len = self.library.playlists.len();
if len > 0 {
self.sidebar_cursor = (self.sidebar_cursor + 1).min(len - 1);
self.sync_active_playlist();
}
}
Focus::TrackList => {
let len = self.active_tracks().len();
if len > 0 {
self.tracklist_cursor = (self.tracklist_cursor + 1).min(len - 1);
}
}
Focus::Queue => {
let len = self.queue.len();
if len > 0 {
self.queue_cursor = (self.queue_cursor + 1).min(len - 1);
}
}
}
}
fn cursor_up(&mut self) {
match self.focus {
Focus::Sidebar => {
self.sidebar_cursor = self.sidebar_cursor.saturating_sub(1);
self.sync_active_playlist();
}
Focus::TrackList => {
self.tracklist_cursor = self.tracklist_cursor.saturating_sub(1);
}
Focus::Queue => {
self.queue_cursor = self.queue_cursor.saturating_sub(1);
}
}
}
fn sync_active_playlist(&mut self) {
if let Some(pl) = self.library.playlists.get(self.sidebar_cursor) {
self.active_playlist = pl.id;
self.tracklist_cursor = 0;
self.tracklist_filter.clear();
self.filter_active = false;
self.rebuild_filter_cache();
}
}
fn action_toggle_play(&mut self) {
if self.now_playing.is_none() {
if !self.queue.is_empty() {
self.play_queue_index(0);
}
} else if self.player.is_paused {
self.track_start = Some(Instant::now());
self.player.resume();
} else {
self.seek_offset = self.elapsed();
self.track_start = None;
self.player.pause();
}
}
fn action_seek(&mut self, delta_secs: i64) {
let idx = match self.now_playing {
Some(i) => i,
None => return,
};
let path = match self.queue.get(idx) {
Some(t) => t.path.clone(),
None => return,
};
let current = self.elapsed().as_secs() as i64;
let max_secs = self
.track_duration
.map(|d| d.as_secs().saturating_sub(1) as i64)
.unwrap_or(i64::MAX);
let target_secs = (current + delta_secs).clamp(0, max_secs) as u64;
let target = Duration::from_secs(target_secs);
self.seek_offset = target;
self.track_start = if self.player.is_paused {
None
} else {
Some(Instant::now())
};
self.player.seek(path, target, self.player.is_paused);
}
fn action_enter(&mut self) {
match self.focus {
Focus::Sidebar => {
self.sync_active_playlist();
self.focus = Focus::TrackList;
}
Focus::TrackList => {
if let Some(track) = self.selected_track() {
let insert_at = self.now_playing.map(|i| i + 1).unwrap_or(0);
self.queue.insert(insert_at, track);
self.play_queue_index(insert_at);
}
}
Focus::Queue => {
let idx = self.queue_cursor;
self.play_queue_index(idx);
}
}
}
fn action_add_to_queue(&mut self) {
if let Some(track) = self.selected_track() {
self.queue.push(track);
}
}
fn action_add_to_playlist(&mut self) {
if let Some(track) = self.selected_track() {
let choices: Vec<(u64, String)> = self
.library
.playlists
.iter()
.filter(|p| p.id != ALL_TRACKS_ID)
.map(|p| (p.id, p.name.clone()))
.collect();
self.modal = Some(Modal::AddToPlaylist {
track_id: track.id,
track_name: track.name,
choices,
cursor: 0,
});
}
}
fn action_remove(&mut self) {
match self.focus {
Focus::Sidebar => {
if let Some(pl) = self.library.playlists.get(self.sidebar_cursor) {
if pl.id == ALL_TRACKS_ID {
return; }
self.modal = Some(Modal::ConfirmRemove {
description: format!("Delete playlist \"{}\"?", pl.name),
target: RemoveTarget::Playlist { playlist_id: pl.id },
});
}
}
Focus::TrackList => {
if let Some(track) = self.selected_track() {
self.modal = Some(Modal::ConfirmRemove {
description: format!("Remove \"{}\" from library?", track.name),
target: RemoveTarget::TrackFromLibrary { track_id: track.id },
});
}
}
Focus::Queue => {
if self.queue_cursor < self.queue.len() {
self.modal = Some(Modal::ConfirmRemove {
description: "Remove this track from the queue?".into(),
target: RemoveTarget::TrackFromQueue {
queue_idx: self.queue_cursor,
},
});
}
}
}
}
fn action_rename(&mut self) {
match self.focus {
Focus::Sidebar => {
if let Some(pl) = self.library.playlists.get(self.sidebar_cursor) {
if pl.id == ALL_TRACKS_ID {
return;
}
self.modal = Some(Modal::Rename {
kind: "Playlist".into(),
id: pl.id,
input: TextInput::with_value(&pl.name),
});
}
}
Focus::TrackList => {
if let Some(t) = self.selected_track() {
self.modal = Some(Modal::Rename {
kind: "Track".into(),
id: t.id,
input: TextInput::with_value(&t.name),
});
}
}
Focus::Queue => {
if let Some(t) = self.queue.get(self.queue_cursor).cloned() {
self.modal = Some(Modal::Rename {
kind: "Track".into(),
id: t.id,
input: TextInput::with_value(&t.name),
});
}
}
}
}
fn action_new_playlist(&mut self) {
self.modal = Some(Modal::NewPlaylist {
input: TextInput::default(),
});
}
fn action_open_filepicker(&mut self) {
let start = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "/".into());
self.file_picker = Some(FilePicker::new(start));
}
fn action_open_menu(&mut self) {
self.modal = Some(Modal::Menu { cursor: 0 });
}
fn open_settings(&mut self) {
let vol_pct = (self.settings.default_volume * 100.0).round() as u32;
let preview_theme_idx = crate::ui::layout::themes()
.iter()
.position(|t| t.name == self.settings.theme_name.as_str())
.unwrap_or(0);
self.modal = Some(Modal::Settings {
cursor: 0,
volume_pct: vol_pct,
seek_secs: self.settings.seek_step_secs,
preview_theme_idx,
transparent: self.settings.transparent,
});
}
fn open_about(&mut self) {
self.modal = Some(Modal::About);
}
fn action_shuffle_playlist(&mut self) {
if let Some(pl) = self.library.playlist(self.active_playlist) {
if pl.tracks.is_empty() {
return;
}
self.modal = Some(Modal::ShufflePlaylist {
playlist_id: pl.id,
playlist_name: pl.name.clone(),
});
}
}
fn action_speed_up(&mut self) {
let new = ((self.player.playback_speed + SPEED_STEP) * 100.0).round() / 100.0;
self.change_speed(new.min(SPEED_MAX));
}
fn action_speed_down(&mut self) {
let new = ((self.player.playback_speed - SPEED_STEP) * 100.0).round() / 100.0;
self.change_speed(new.max(SPEED_MIN));
}
fn change_speed(&mut self, new_speed: f32) {
if (new_speed - self.player.playback_speed).abs() < 0.001 {
return;
}
let current_pos = if self.now_playing.is_some() {
Some(self.elapsed())
} else {
None
};
self.player.playback_speed = new_speed;
if let Some(pos) = current_pos
&& let Some(np) = self.now_playing
&& let Some(track) = self.queue.get(np)
{
let path = track.path.clone();
let paused = self.player.is_paused;
self.seek_offset = pos;
self.track_start = if paused { None } else { Some(Instant::now()) };
self.player.seek(path, pos, paused);
}
}
fn selected_track_for_edit(&self) -> Option<Track> {
match self.focus {
Focus::TrackList => self.selected_track(),
Focus::Queue => self.queue.get(self.queue_cursor).cloned(),
Focus::Sidebar => None,
}
}
fn action_edit_metadata(&mut self) {
if let Some(t) = self.selected_track_for_edit() {
self.modal = Some(Modal::EditMetadata {
track_id: t.id,
fields: [
TextInput::with_value(&t.name),
TextInput::with_value(t.artist.as_deref().unwrap_or("")),
TextInput::with_value(t.album.as_deref().unwrap_or("")),
TextInput::with_value(t.year.map(|y| y.to_string()).unwrap_or_default()),
TextInput::with_value(t.genre.as_deref().unwrap_or("")),
],
active_field: 0,
year_error: false,
});
}
}
fn refresh_lyrics_cache(&mut self) {
self.lyrics_lines = self
.now_playing
.and_then(|i| self.queue.get(i))
.and_then(|t| self.library.track(t.id))
.and_then(|t| t.lyrics.as_ref())
.map(|raw| lyrics::parse_lrc(raw))
.unwrap_or_default();
}
fn sync_queue_name(&mut self, track_id: TrackId, name: &str) {
for t in &mut self.queue {
if t.id == track_id {
t.name = name.to_string();
}
}
}
fn sync_queue_metadata(
&mut self,
track_id: TrackId,
name: &str,
artist: &Option<String>,
album: &Option<String>,
year: Option<u32>,
genre: &Option<String>,
) {
for t in &mut self.queue {
if t.id == track_id {
t.name = name.to_string();
t.artist = artist.clone();
t.album = album.clone();
t.year = year;
t.genre = genre.clone();
}
}
}
fn action_edit_lyrics(&mut self, track_id: TrackId) {
let raw = self
.library
.track(track_id)
.and_then(|t| t.lyrics.as_deref())
.unwrap_or("")
.to_string();
self.modal = Some(Modal::EditLyrics {
track_id,
textarea: make_lyrics_textarea(&raw),
});
}
fn action_toggle_lyrics(&mut self) {
if let Some(np) = self.now_playing.and_then(|i| self.queue.get(i)) {
let has_lyrics = self
.library
.track(np.id)
.and_then(|t| t.lyrics.as_ref())
.is_some();
if has_lyrics {
self.show_lyrics = !self.show_lyrics;
if self.show_lyrics {
self.lyrics_scroll = 0;
self.refresh_lyrics_cache();
}
} else {
self.show_lyrics = false;
self.modal = Some(Modal::Notify {
message: "No lyrics for this track — press e to edit metadata and add them."
.into(),
});
}
} else {
self.show_lyrics = false;
self.modal = Some(Modal::Notify {
message: "Nothing is playing.".into(),
});
}
}
fn apply_modal_confirm(&mut self, confirm: ModalConfirm) {
match confirm {
ModalConfirm::Remove(target) => match target {
RemoveTarget::TrackFromQueue { queue_idx } => {
if queue_idx < self.queue.len() {
self.queue.remove(queue_idx);
if let Some(np) = self.now_playing {
if queue_idx < np {
self.now_playing = Some(np - 1);
} else if queue_idx == np {
self.halt_playback();
}
}
self.queue_cursor =
self.queue_cursor.min(self.queue.len().saturating_sub(1));
}
}
RemoveTarget::TrackFromLibrary { track_id } => {
let _ = self.library.remove_track(track_id);
let playing_removed = self
.now_playing
.and_then(|np| self.queue.get(np))
.map(|t| t.id == track_id)
.unwrap_or(false);
let removed_before_np = self
.now_playing
.map(|np| self.queue[..np].iter().filter(|t| t.id == track_id).count())
.unwrap_or(0);
self.queue.retain(|t| t.id != track_id);
if playing_removed {
self.halt_playback();
} else if removed_before_np > 0
&& let Some(np) = self.now_playing.as_mut()
{
*np -= removed_before_np;
}
self.queue_cursor = self.queue_cursor.min(self.queue.len().saturating_sub(1));
self.rebuild_filter_cache();
self.tracklist_cursor = self
.tracklist_cursor
.min(self.active_tracks().len().saturating_sub(1));
}
RemoveTarget::Playlist { playlist_id } => {
let _ = self.library.delete_playlist(playlist_id);
self.sidebar_cursor = self
.sidebar_cursor
.min(self.library.playlists.len().saturating_sub(1));
self.sync_active_playlist();
}
},
ModalConfirm::Rename { kind, id, new_name } => {
if kind == "Track" {
let _ = self.library.rename_track(id, &new_name);
self.sync_queue_name(id, &new_name);
self.rebuild_filter_cache();
} else {
let _ = self.library.rename_playlist(id, new_name);
}
}
ModalConfirm::NewPlaylist { name } => {
let _ = self.library.create_playlist(name);
}
ModalConfirm::AddToPlaylist {
track_id,
playlist_id,
} => {
let _ = self.library.playlist_add_track(playlist_id, track_id);
self.rebuild_filter_cache();
}
ModalConfirm::OpenSettings => {
self.open_settings();
}
ModalConfirm::OpenAbout => {
self.open_about();
}
ModalConfirm::Quit => {
self.should_quit = true;
}
ModalConfirm::SaveSettings {
volume_pct,
seek_secs,
theme_name,
transparent,
} => {
self.settings.set_default_volume(volume_pct as f32 / 100.0);
self.settings.set_seek_step_secs(seek_secs);
self.settings.set_theme(&theme_name);
self.settings.transparent = transparent;
let mut t = theme_by_name(&theme_name).clone();
t.transparent = transparent;
self.theme = t;
let _ = self.settings.save();
}
ModalConfirm::PreviewTheme {
theme_name,
transparent,
} => {
let mut t = theme_by_name(&theme_name).clone();
t.transparent = transparent;
self.theme = t;
}
ModalConfirm::ShufflePlaylist { playlist_id } => {
let mut tracks: Vec<Track> = self
.library
.playlist_tracks(playlist_id)
.into_iter()
.cloned()
.collect();
if tracks.is_empty() {
return;
}
tracks.shuffle(&mut rand::rng());
self.halt_playback();
self.queue = tracks;
self.queue_cursor = 0;
self.play_queue_index(0);
}
ModalConfirm::SaveMetadata {
track_id,
name,
artist,
album,
year,
genre,
} => {
let _ = self.library.update_track_metadata(
track_id,
name.clone(),
artist.clone(),
album.clone(),
year,
genre.clone(),
);
self.sync_queue_metadata(track_id, &name, &artist, &album, year, &genre);
self.rebuild_filter_cache();
}
ModalConfirm::SaveLyrics { track_id, lyrics } => {
let _ = self.library.set_track_lyrics(track_id, lyrics);
if self.show_lyrics {
self.refresh_lyrics_cache();
}
}
ModalConfirm::SaveMetadataAndEditLyrics {
track_id,
name,
artist,
album,
year,
genre,
} => {
let _ = self.library.update_track_metadata(
track_id,
name.clone(),
artist.clone(),
album.clone(),
year,
genre.clone(),
);
self.sync_queue_metadata(track_id, &name, &artist, &album, year, &genre);
self.rebuild_filter_cache();
self.action_edit_lyrics(track_id);
}
}
}
fn import_file(&mut self, path: &std::path::Path) {
match self.library.add_file(path) {
Ok((track, is_new)) => {
self.rebuild_filter_cache();
self.modal = Some(Modal::Notify {
message: if is_new {
format!("\"{}\" added to library.", track.name)
} else {
format!("\"{}\" is already in the library.", track.name)
},
});
}
Err(e) => {
self.modal = Some(Modal::Notify {
message: format!("Error importing file: {e}"),
});
}
}
}
}
pub fn run(cli: Cli) -> Result<()> {
let mut library = Library::load()?;
let settings = Settings::load();
let initial_track: Option<(Track, bool)> = if let Some(file) = cli.file {
let result = library.add_file(&file)?;
Some(result)
} else {
None
};
let player = spawn_audio_thread(settings.default_volume)?;
let mut state = AppState::new(library, player, settings);
if let Some((track, is_new)) = initial_track {
let msg = if is_new {
format!("\"{}\" added to library.", track.name)
} else {
format!("\"{}\" is already in the library.", track.name)
};
state.enqueue(track);
state.play_queue_index(0);
state.modal = Some(Modal::Notify { message: msg });
}
let terminal = ratatui::init();
let result = event_loop(terminal, &mut state);
ratatui::restore();
result
}
fn event_loop(mut terminal: DefaultTerminal, state: &mut AppState) -> Result<()> {
loop {
state.tick();
terminal.draw(|frame| ui::render(frame, state))?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
state.handle_key(key.code, key.modifiers);
}
if state.should_quit {
break;
}
}
Ok(())
}