pub mod actions;
mod cava;
mod input;
mod input_artists;
mod input_browse;
mod input_playlists;
mod input_queue;
mod input_radio;
mod input_server;
mod input_settings;
pub mod models;
mod mouse;
mod mouse_artists;
mod mouse_browse;
mod mouse_playlists;
mod mouse_radio;
mod notifications;
mod playback;
mod repo;
pub mod state;
use std::io;
use std::time::Duration;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use crate::app::models::{BrowseTab, SongOption};
use crate::audio::mpv::MpvController;
use crate::audio::pipewire::PipeWireController;
use crate::config::Config;
use crate::error::{Error, UiError};
use crate::mpris::server::{start_mpris_server, update_mpris_properties, MprisPlayer};
use crate::subsonic::SubsonicClient;
use crate::ui;
pub use actions::*;
pub use state::*;
const CHANNEL_SIZE: usize = 256;
pub(super) const INFINITE_SCROLL_LOOKAHEAD: usize = 10;
pub(super) const FILTER_DEBOUNCE_MS: u64 = 300;
pub struct App {
state: SharedState,
subsonic: Option<SubsonicClient>,
mpv: MpvController,
pipewire: PipeWireController,
audio_tx: mpsc::Sender<AudioAction>,
cava_process: Option<std::process::Child>,
cava_pty_master: Option<std::fs::File>,
cava_parser: Option<vt100::Parser>,
cava_config_path: Option<std::path::PathBuf>,
last_click: Option<(u16, u16, std::time::Instant)>,
songs_filter_debounce: Option<std::time::Instant>,
audio_rx: mpsc::Receiver<AudioAction>,
mpris_server: Option<mpris_server::Server<MprisPlayer>>,
}
impl App {
pub fn new(config: Config) -> Self {
let (audio_tx, audio_rx) = mpsc::channel(CHANNEL_SIZE);
let state = new_shared_state(config.clone());
let subsonic = if config.is_configured() {
match SubsonicClient::new(&config.base_url, &config.username, &config.password) {
Ok(client) => Some(client),
Err(e) => {
warn!("Failed to create Subsonic client: {}", e);
None
}
}
} else {
None
};
Self {
state,
subsonic,
mpv: MpvController::new(),
pipewire: PipeWireController::new(),
audio_tx,
cava_process: None,
cava_pty_master: None,
cava_parser: None,
cava_config_path: None,
last_click: None,
songs_filter_debounce: None,
audio_rx,
mpris_server: None,
}
}
pub async fn run(&mut self) -> Result<(), Error> {
if let Err(e) = self.mpv.start() {
warn!("Failed to start MPV: {} - audio playback won't work", e);
let mut state = self.state.write().await;
state.notify_error(format!("Failed to start MPV: {}. Is mpv installed?", e));
drop(state);
} else {
info!("MPV started successfully, ready for playback");
}
match start_mpris_server(self.state.clone(), self.audio_tx.clone()).await {
Ok(server) => {
info!("MPRIS server started");
self.mpris_server = Some(server);
}
Err(e) => {
warn!(
"Failed to start MPRIS server: {} — media keys won't work",
e
);
}
}
{
use crate::ui::theme::{load_themes, seed_default_themes};
if let Some(themes_dir) = crate::config::paths::themes_dir() {
seed_default_themes(&themes_dir);
}
let themes = load_themes();
let mut state = self.state.write().await;
let theme_name = state.config.theme.clone();
state.settings_state.themes = themes;
state.settings_state.set_theme_by_name(&theme_name);
}
let cava_available = std::process::Command::new("which")
.arg("cava")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
{
let mut state = self.state.write().await;
state.cava_available = cava_available;
if !cava_available {
state.settings_state.cava_enabled = false;
}
}
{
let state = self.state.read().await;
if state.settings_state.cava_enabled && cava_available {
let td = state.settings_state.current_theme();
let g = td.cava_gradient.clone();
let h = td.cava_horizontal_gradient.clone();
let cs = state.settings_state.cava_size as u32;
drop(state);
self.start_cava(&g, &h, cs);
}
}
enable_raw_mode().map_err(UiError::TerminalInit)?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(UiError::TerminalInit)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(UiError::TerminalInit)?;
info!("Terminal initialized");
if self.subsonic.is_some() {
self.load_initial_data().await;
}
self.restore_queue().await;
let result = self.event_loop(&mut terminal).await;
self.save_queue().await;
self.stop_cava();
let _ = self.mpv.quit();
disable_raw_mode().map_err(UiError::TerminalInit)?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.map_err(UiError::TerminalInit)?;
terminal.show_cursor().map_err(UiError::Render)?;
info!("Terminal restored");
result
}
async fn load_initial_data(&mut self) {
{
let mut state = self.state.write().await;
state.browse.selected_option = Some(SongOption::All);
state.browse.all_songs_offset = 0;
state.browse.all_songs_has_more = true;
}
self.get_all_songs(false).await;
self.get_artists().await;
self.get_playlists().await;
self.get_radio_stations().await;
}
async fn restore_queue(&mut self) {
let state = self.state.read().await;
let save_queue = state.config.save_queue;
drop(state);
if !save_queue {
return;
}
if let Some(persisted) = crate::config::queue::QueuePersist::load_default() {
if !persisted.queue.is_empty() {
let mut state = self.state.write().await;
let queue_len = persisted.queue.len();
state.queue = persisted.queue;
state.queue_position = persisted.queue_position;
if let Some(pos) = state.queue_position {
if pos < queue_len {
state.queue_state.selected = Some(pos);
} else if queue_len > 0 {
state.queue_state.selected = Some(queue_len - 1);
}
} else if queue_len > 0 {
state.queue_state.selected = Some(0);
}
info!(
"Queue restored: {} songs, position: {:?}",
queue_len, state.queue_position
);
drop(state);
}
}
}
async fn save_queue(&self) {
let state = self.state.read().await;
let save_queue = state.config.save_queue;
let queue = state.queue.clone();
let queue_position = state.queue_position;
drop(state);
if !save_queue {
let _ = crate::config::queue::QueuePersist::clear_default();
return;
}
let persist = crate::config::queue::QueuePersist {
queue,
queue_position,
};
if let Err(e) = persist.save_default() {
warn!("Failed to save queue: {}", e);
}
}
fn save_queue_sync(&self) {
let (save_queue, queue, queue_position) = {
let Ok(state) = self.state.try_read() else {
return;
};
(
state.config.save_queue,
state.queue.clone(),
state.queue_position,
)
};
if !save_queue {
let _ = crate::config::queue::QueuePersist::clear_default();
return;
}
let persist = crate::config::queue::QueuePersist {
queue,
queue_position,
};
if let Err(e) = persist.save_default() {
warn!("Failed to save queue: {}", e);
}
}
async fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), Error> {
let mut last_playback_update = std::time::Instant::now();
loop {
let cava_active = self.cava_parser.is_some();
let tick_rate = if cava_active {
Duration::from_millis(16) } else {
Duration::from_millis(100)
};
let mut mutations = RenderMutations::default();
{
let state = self.state.read().await;
terminal
.draw(|frame| ui::draw(frame, &state, &mut mutations))
.map_err(UiError::Render)?;
}
{
let mut state = self.state.write().await;
state.layout = mutations.layout;
if let Some(v) = mutations.browse_scroll_offset {
state.browse.scroll_offset = v;
}
if let Some(v) = mutations.browse_album_scroll_offset {
state.browse.album_scroll_offset = v;
}
if let Some(v) = mutations.queue_scroll_offset {
state.queue_state.scroll_offset = v;
}
if let Some(v) = mutations.radio_scroll_offset {
state.radio.scroll_offset = v;
}
if let Some(v) = mutations.playlists_playlist_scroll_offset {
state.playlists.playlist_scroll_offset = v;
}
if let Some(v) = mutations.playlists_song_scroll_offset {
state.playlists.song_scroll_offset = v;
}
if let Some(v) = mutations.artists_tree_scroll_offset {
state.artists.tree_scroll_offset = v;
}
if let Some(v) = mutations.artists_song_scroll_offset {
state.artists.song_scroll_offset = v;
}
}
{
let state = self.state.read().await;
if state.should_quit {
break;
}
}
if event::poll(tick_rate).map_err(UiError::Input)? {
let event = event::read().map_err(UiError::Input)?;
self.handle_event(event).await?;
}
if let Some(changed_at) = self.songs_filter_debounce {
if changed_at.elapsed() >= Duration::from_millis(FILTER_DEBOUNCE_MS) {
self.songs_filter_debounce = None;
let state = self.state.read().await;
let is_all = state.browse.selected_option == Some(SongOption::All)
&& state.browse.browse_tab == BrowseTab::Songs;
drop(state);
if is_all {
{
let mut state = self.state.write().await;
state.browse.all_songs_offset = 0;
state.browse.all_songs_has_more = true;
}
self.get_all_songs(false).await;
}
}
}
while let Ok(action) = self.audio_rx.try_recv() {
match action {
AudioAction::TogglePause => {
let _ = self.toggle_pause().await;
}
AudioAction::Pause => {
let _ = self.pause_playback().await;
}
AudioAction::Resume => {
let _ = self.resume_playback().await;
}
AudioAction::Next => {
let _ = self.next_track().await;
}
AudioAction::Previous => {
let _ = self.prev_track().await;
}
AudioAction::Stop => {
let _ = self.stop_playback().await;
}
AudioAction::Seek(pos) => {
if let Err(e) = self.mpv.seek(pos) {
warn!("MPRIS seek failed: {}", e);
} else {
let mut state = self.state.write().await;
state.now_playing.position = pos;
}
}
AudioAction::SeekRelative(offset) => {
let _ = self.mpv.seek_relative(offset);
}
AudioAction::SetVolume(vol) => {
let _ = self.mpv.set_volume(vol);
}
}
}
self.read_cava_output().await;
let now = std::time::Instant::now();
if now.duration_since(last_playback_update) >= Duration::from_millis(500) {
last_playback_update = now;
self.update_playback_info().await;
}
{
let mut state = self.state.write().await;
state.check_notification_timeout();
}
}
Ok(())
}
}