Extract handle_artists_click and handle_playlists_click into mouse_artists.rs and mouse_playlists.rs respectively, reducing mouse.rs from ~530 to ~247 lines. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
329 lines
11 KiB
Rust
329 lines
11 KiB
Rust
//! Main application module
|
|
|
|
pub mod actions;
|
|
mod cava;
|
|
mod input;
|
|
mod input_artists;
|
|
mod input_playlists;
|
|
mod input_queue;
|
|
mod input_server;
|
|
mod input_settings;
|
|
mod mouse;
|
|
mod mouse_artists;
|
|
mod mouse_playlists;
|
|
mod playback;
|
|
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::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::*;
|
|
|
|
/// Channel buffer size
|
|
const CHANNEL_SIZE: usize = 256;
|
|
|
|
/// Main application
|
|
pub struct App {
|
|
/// Shared application state
|
|
state: SharedState,
|
|
/// Subsonic client
|
|
subsonic: Option<SubsonicClient>,
|
|
/// MPV audio controller
|
|
mpv: MpvController,
|
|
/// PipeWire sample rate controller
|
|
pipewire: PipeWireController,
|
|
/// Channel to send audio actions
|
|
audio_tx: mpsc::Sender<AudioAction>,
|
|
/// Cava child process
|
|
cava_process: Option<std::process::Child>,
|
|
/// Cava pty master fd for reading output
|
|
cava_pty_master: Option<std::fs::File>,
|
|
/// Cava terminal parser
|
|
cava_parser: Option<vt100::Parser>,
|
|
/// Last mouse click position and time (for second-click detection)
|
|
last_click: Option<(u16, u16, std::time::Instant)>,
|
|
/// Channel to receive audio actions (from MPRIS)
|
|
audio_rx: mpsc::Receiver<AudioAction>,
|
|
/// MPRIS D-Bus server
|
|
mpris_server: Option<mpris_server::Server<MprisPlayer>>,
|
|
}
|
|
|
|
impl App {
|
|
/// Create a new application instance
|
|
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,
|
|
last_click: None,
|
|
audio_rx,
|
|
mpris_server: None,
|
|
}
|
|
}
|
|
|
|
/// Run the application
|
|
pub async fn run(&mut self) -> Result<(), Error> {
|
|
// Start MPV
|
|
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");
|
|
}
|
|
|
|
// Start MPRIS server for media key support
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Seed and load themes
|
|
{
|
|
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);
|
|
}
|
|
|
|
// Check if cava is available
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Start cava if enabled and available
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Setup terminal
|
|
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");
|
|
|
|
// Load initial data if configured
|
|
if self.subsonic.is_some() {
|
|
self.load_initial_data().await;
|
|
}
|
|
|
|
// Main event loop
|
|
let result = self.event_loop(&mut terminal).await;
|
|
|
|
// Cleanup cava
|
|
self.stop_cava();
|
|
|
|
// Cleanup MPV
|
|
let _ = self.mpv.quit();
|
|
|
|
// Cleanup terminal
|
|
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
|
|
}
|
|
|
|
/// Load initial data from server
|
|
async fn load_initial_data(&mut self) {
|
|
if let Some(ref client) = self.subsonic {
|
|
// Load artists
|
|
match client.get_artists().await {
|
|
Ok(artists) => {
|
|
let mut state = self.state.write().await;
|
|
let count = artists.len();
|
|
state.artists.artists = artists;
|
|
// Select first artist by default
|
|
if count > 0 {
|
|
state.artists.selected_index = Some(0);
|
|
}
|
|
info!("Loaded {} artists", count);
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to load artists: {}", e);
|
|
let mut state = self.state.write().await;
|
|
state.notify_error(format!("Failed to load artists: {}", e));
|
|
}
|
|
}
|
|
|
|
// Load playlists
|
|
match client.get_playlists().await {
|
|
Ok(playlists) => {
|
|
let mut state = self.state.write().await;
|
|
let count = playlists.len();
|
|
state.playlists.playlists = playlists;
|
|
info!("Loaded {} playlists", count);
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to load playlists: {}", e);
|
|
// Don't show error for playlists if artists loaded
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main event loop
|
|
async fn event_loop(
|
|
&mut self,
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
) -> Result<(), Error> {
|
|
let mut last_playback_update = std::time::Instant::now();
|
|
|
|
loop {
|
|
// Determine tick rate based on whether cava is active
|
|
let cava_active = self.cava_parser.is_some();
|
|
let tick_rate = if cava_active {
|
|
Duration::from_millis(16) // ~60fps
|
|
} else {
|
|
Duration::from_millis(100)
|
|
};
|
|
|
|
// Draw UI
|
|
{
|
|
let mut state = self.state.write().await;
|
|
terminal
|
|
.draw(|frame| ui::draw(frame, &mut state))
|
|
.map_err(UiError::Render)?;
|
|
}
|
|
|
|
// Check for quit
|
|
{
|
|
let state = self.state.read().await;
|
|
if state.should_quit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Handle events with timeout
|
|
if event::poll(tick_rate).map_err(UiError::Input)? {
|
|
let event = event::read().map_err(UiError::Input)?;
|
|
self.handle_event(event).await?;
|
|
}
|
|
|
|
// Process any pending audio actions (from MPRIS)
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read cava output (non-blocking)
|
|
self.read_cava_output().await;
|
|
|
|
// Update playback position every ~500ms
|
|
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;
|
|
}
|
|
|
|
// Check for notification auto-clear (after 2 seconds)
|
|
{
|
|
let mut state = self.state.write().await;
|
|
state.check_notification_timeout();
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|