Refactor app/mod.rs into focused submodules
Split the 2495-line mod.rs into 10 files by concern: - playback.rs: playback controls and track management - cava.rs: cava process management and VT100 parsing - input.rs: event dispatch and global keybindings - input_artists.rs: artists page keyboard handling - input_queue.rs: queue page keyboard handling - input_playlists.rs: playlists page keyboard handling - input_server.rs: server page keyboard handling - input_settings.rs: settings page keyboard handling - mouse.rs: all mouse click and scroll handling - mod.rs: App struct, new(), run(), event_loop(), load_initial_data() Pure code reorganization — no behavioral changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
243
src/app/cava.rs
Normal file
243
src/app/cava.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
use std::io::Read as _;
|
||||||
|
use std::os::unix::io::FromRawFd;
|
||||||
|
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Start cava process in noncurses mode via a pty
|
||||||
|
pub(super) fn start_cava(&mut self, cava_gradient: &[String; 8], cava_horizontal_gradient: &[String; 8], cava_size: u32) {
|
||||||
|
self.stop_cava();
|
||||||
|
|
||||||
|
// Compute pty dimensions to match the cava widget area
|
||||||
|
let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||||
|
let cava_h = (term_h as u32 * cava_size / 100).max(4) as u16;
|
||||||
|
let cava_w = term_w;
|
||||||
|
|
||||||
|
// Open a pty pair
|
||||||
|
let mut master: libc::c_int = 0;
|
||||||
|
let mut slave: libc::c_int = 0;
|
||||||
|
unsafe {
|
||||||
|
if libc::openpty(
|
||||||
|
&mut master,
|
||||||
|
&mut slave,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
) != 0
|
||||||
|
{
|
||||||
|
error!("openpty failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pty size so cava knows its dimensions
|
||||||
|
let ws = libc::winsize {
|
||||||
|
ws_row: cava_h,
|
||||||
|
ws_col: cava_w,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
};
|
||||||
|
libc::ioctl(slave, libc::TIOCSWINSZ, &ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate themed cava config and write to temp file
|
||||||
|
// Dup slave fd before converting to File (from_raw_fd takes ownership)
|
||||||
|
let slave_stdin_fd = unsafe { libc::dup(slave) };
|
||||||
|
let slave_stderr_fd = unsafe { libc::dup(slave) };
|
||||||
|
let slave_stdout = unsafe { std::fs::File::from_raw_fd(slave) };
|
||||||
|
let slave_stdin = unsafe { std::fs::File::from_raw_fd(slave_stdin_fd) };
|
||||||
|
let slave_stderr = unsafe { std::fs::File::from_raw_fd(slave_stderr_fd) };
|
||||||
|
let config_path = std::env::temp_dir().join("ferrosonic-cava.conf");
|
||||||
|
if let Err(e) = std::fs::write(&config_path, generate_cava_config(cava_gradient, cava_horizontal_gradient)) {
|
||||||
|
error!("Failed to write cava config: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut cmd = std::process::Command::new("cava");
|
||||||
|
cmd.arg("-p").arg(&config_path);
|
||||||
|
cmd.stdout(std::process::Stdio::from(slave_stdout))
|
||||||
|
.stderr(std::process::Stdio::from(slave_stderr))
|
||||||
|
.stdin(std::process::Stdio::from(slave_stdin))
|
||||||
|
.env("TERM", "xterm-256color");
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok(child) => {
|
||||||
|
// Set master to non-blocking
|
||||||
|
unsafe {
|
||||||
|
let flags = libc::fcntl(master, libc::F_GETFL);
|
||||||
|
libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
let master_file = unsafe { std::fs::File::from_raw_fd(master) };
|
||||||
|
let parser = vt100::Parser::new(cava_h, cava_w, 0);
|
||||||
|
|
||||||
|
self.cava_process = Some(child);
|
||||||
|
self.cava_pty_master = Some(master_file);
|
||||||
|
self.cava_parser = Some(parser);
|
||||||
|
info!("Cava started in noncurses mode ({}x{})", cava_w, cava_h);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to start cava: {}", e);
|
||||||
|
unsafe {
|
||||||
|
libc::close(master);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop cava process and clean up
|
||||||
|
pub(super) fn stop_cava(&mut self) {
|
||||||
|
if let Some(ref mut child) = self.cava_process {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
self.cava_process = None;
|
||||||
|
self.cava_pty_master = None;
|
||||||
|
self.cava_parser = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read cava pty output and snapshot screen to state
|
||||||
|
pub(super) async fn read_cava_output(&mut self) {
|
||||||
|
let (Some(ref mut master), Some(ref mut parser)) =
|
||||||
|
(&mut self.cava_pty_master, &mut self.cava_parser)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read all available bytes from the pty master
|
||||||
|
let mut buf = [0u8; 16384];
|
||||||
|
let mut got_data = false;
|
||||||
|
loop {
|
||||||
|
match master.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
parser.process(&buf[..n]);
|
||||||
|
got_data = true;
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got_data {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the vt100 screen into shared state
|
||||||
|
let screen = parser.screen();
|
||||||
|
let (rows, cols) = screen.size();
|
||||||
|
let mut cava_screen = Vec::with_capacity(rows as usize);
|
||||||
|
|
||||||
|
for row in 0..rows {
|
||||||
|
let mut spans: Vec<CavaSpan> = Vec::new();
|
||||||
|
let mut cur_text = String::new();
|
||||||
|
let mut cur_fg = CavaColor::Default;
|
||||||
|
let mut cur_bg = CavaColor::Default;
|
||||||
|
|
||||||
|
for col in 0..cols {
|
||||||
|
let cell = screen.cell(row, col).unwrap();
|
||||||
|
let fg = vt100_color_to_cava(cell.fgcolor());
|
||||||
|
let bg = vt100_color_to_cava(cell.bgcolor());
|
||||||
|
|
||||||
|
if fg != cur_fg || bg != cur_bg {
|
||||||
|
if !cur_text.is_empty() {
|
||||||
|
spans.push(CavaSpan {
|
||||||
|
text: std::mem::take(&mut cur_text),
|
||||||
|
fg: cur_fg,
|
||||||
|
bg: cur_bg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cur_fg = fg;
|
||||||
|
cur_bg = bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = cell.contents();
|
||||||
|
if contents.is_empty() {
|
||||||
|
cur_text.push(' ');
|
||||||
|
} else {
|
||||||
|
cur_text.push_str(&contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur_text.is_empty() {
|
||||||
|
spans.push(CavaSpan {
|
||||||
|
text: cur_text,
|
||||||
|
fg: cur_fg,
|
||||||
|
bg: cur_bg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cava_screen.push(CavaRow { spans });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.cava_screen = cava_screen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert vt100 color to our CavaColor type
|
||||||
|
fn vt100_color_to_cava(color: vt100::Color) -> CavaColor {
|
||||||
|
match color {
|
||||||
|
vt100::Color::Default => CavaColor::Default,
|
||||||
|
vt100::Color::Idx(i) => CavaColor::Indexed(i),
|
||||||
|
vt100::Color::Rgb(r, g, b) => CavaColor::Rgb(r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a cava configuration string with theme-appropriate gradient colors
|
||||||
|
pub(super) fn generate_cava_config(g: &[String; 8], h: &[String; 8]) -> String {
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
[general]
|
||||||
|
framerate = 60
|
||||||
|
autosens = 1
|
||||||
|
overshoot = 0
|
||||||
|
bars = 0
|
||||||
|
bar_width = 1
|
||||||
|
bar_spacing = 0
|
||||||
|
lower_cutoff_freq = 10
|
||||||
|
higher_cutoff_freq = 18000
|
||||||
|
|
||||||
|
[input]
|
||||||
|
sample_rate = 96000
|
||||||
|
sample_bits = 32
|
||||||
|
remix = 1
|
||||||
|
|
||||||
|
[output]
|
||||||
|
method = noncurses
|
||||||
|
orientation = horizontal
|
||||||
|
channels = mono
|
||||||
|
mono_option = average
|
||||||
|
synchronized_sync = 1
|
||||||
|
disable_blanking = 1
|
||||||
|
|
||||||
|
[color]
|
||||||
|
gradient = 1
|
||||||
|
gradient_color_1 = '{g0}'
|
||||||
|
gradient_color_2 = '{g1}'
|
||||||
|
gradient_color_3 = '{g2}'
|
||||||
|
gradient_color_4 = '{g3}'
|
||||||
|
gradient_color_5 = '{g4}'
|
||||||
|
gradient_color_6 = '{g5}'
|
||||||
|
gradient_color_7 = '{g6}'
|
||||||
|
gradient_color_8 = '{g7}'
|
||||||
|
horizontal_gradient = 1
|
||||||
|
horizontal_gradient_color_1 = '{h0}'
|
||||||
|
horizontal_gradient_color_2 = '{h1}'
|
||||||
|
horizontal_gradient_color_3 = '{h2}'
|
||||||
|
horizontal_gradient_color_4 = '{h3}'
|
||||||
|
horizontal_gradient_color_5 = '{h4}'
|
||||||
|
horizontal_gradient_color_6 = '{h5}'
|
||||||
|
horizontal_gradient_color_7 = '{h6}'
|
||||||
|
horizontal_gradient_color_8 = '{h7}'
|
||||||
|
|
||||||
|
[smoothing]
|
||||||
|
monstercat = 0
|
||||||
|
waves = 0
|
||||||
|
noise_reduction = 11
|
||||||
|
",
|
||||||
|
g0 = g[0], g1 = g[1], g2 = g[2], g3 = g[3],
|
||||||
|
g4 = g[4], g5 = g[5], g6 = g[6], g7 = g[7],
|
||||||
|
h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3],
|
||||||
|
h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7],
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/app/input.rs
Normal file
146
src/app/input.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle terminal events
|
||||||
|
pub(super) async fn handle_event(&mut self, event: Event) -> Result<(), Error> {
|
||||||
|
match event {
|
||||||
|
Event::Key(key) => {
|
||||||
|
// Only handle key press events, ignore release and repeat
|
||||||
|
if key.kind == event::KeyEventKind::Press {
|
||||||
|
self.handle_key(key).await
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse) => self.handle_mouse(mouse).await,
|
||||||
|
Event::Resize(_, _) => {
|
||||||
|
// Restart cava so it picks up the new terminal dimensions
|
||||||
|
if self.cava_parser.is_some() {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
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);
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.cava_screen.clear();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle keyboard input
|
||||||
|
pub(super) async fn handle_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
|
||||||
|
// Clear notification on any keypress
|
||||||
|
state.clear_notification();
|
||||||
|
|
||||||
|
// Bypass global keybindings when typing in server text fields or filtering artists
|
||||||
|
let is_server_text_field =
|
||||||
|
state.page == Page::Server && state.server_state.selected_field <= 2;
|
||||||
|
let is_filtering = state.page == Page::Artists && state.artists.filter_active;
|
||||||
|
|
||||||
|
if is_server_text_field || is_filtering {
|
||||||
|
let page = state.page;
|
||||||
|
drop(state);
|
||||||
|
return match page {
|
||||||
|
Page::Server => self.handle_server_key(key).await,
|
||||||
|
Page::Artists => self.handle_artists_key(key).await,
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global keybindings
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
// Quit
|
||||||
|
(KeyCode::Char('q'), KeyModifiers::NONE) => {
|
||||||
|
state.should_quit = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Page switching
|
||||||
|
(KeyCode::F(1), _) => {
|
||||||
|
state.page = Page::Artists;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
(KeyCode::F(2), _) => {
|
||||||
|
state.page = Page::Queue;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
(KeyCode::F(3), _) => {
|
||||||
|
state.page = Page::Playlists;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
(KeyCode::F(4), _) => {
|
||||||
|
state.page = Page::Server;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
(KeyCode::F(5), _) => {
|
||||||
|
state.page = Page::Settings;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Playback controls (global)
|
||||||
|
(KeyCode::Char('p'), KeyModifiers::NONE) | (KeyCode::Char(' '), KeyModifiers::NONE) => {
|
||||||
|
// Toggle pause
|
||||||
|
drop(state);
|
||||||
|
return self.toggle_pause().await;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('l'), KeyModifiers::NONE) => {
|
||||||
|
// Next track
|
||||||
|
drop(state);
|
||||||
|
return self.next_track().await;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('h'), KeyModifiers::NONE) => {
|
||||||
|
// Previous track
|
||||||
|
drop(state);
|
||||||
|
return self.prev_track().await;
|
||||||
|
}
|
||||||
|
// Cycle theme (global)
|
||||||
|
(KeyCode::Char('t'), KeyModifiers::NONE) => {
|
||||||
|
state.settings_state.next_theme();
|
||||||
|
state.config.theme = state.settings_state.theme_name().to_string();
|
||||||
|
let label = state.settings_state.theme_name().to_string();
|
||||||
|
state.notify(format!("Theme: {}", label));
|
||||||
|
let _ = state.config.save_default();
|
||||||
|
let cava_enabled = state.settings_state.cava_enabled;
|
||||||
|
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);
|
||||||
|
if cava_enabled {
|
||||||
|
self.start_cava(&g, &h, cs);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Ctrl+R to refresh data from server
|
||||||
|
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
||||||
|
state.notify("Refreshing...");
|
||||||
|
drop(state);
|
||||||
|
self.load_initial_data().await;
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify("Data refreshed");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-specific keybindings
|
||||||
|
let page = state.page;
|
||||||
|
drop(state);
|
||||||
|
match page {
|
||||||
|
Page::Artists => self.handle_artists_key(key).await,
|
||||||
|
Page::Queue => self.handle_queue_key(key).await,
|
||||||
|
Page::Playlists => self.handle_playlists_key(key).await,
|
||||||
|
Page::Server => self.handle_server_key(key).await,
|
||||||
|
Page::Settings => self.handle_settings_key(key).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
334
src/app/input_artists.rs
Normal file
334
src/app/input_artists.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
use crossterm::event::{self, KeyCode};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle artists page keys
|
||||||
|
pub(super) async fn handle_artists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
use crate::ui::pages::artists::{build_tree_items, TreeItem};
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
|
||||||
|
// Handle filter input mode
|
||||||
|
if state.artists.filter_active {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.artists.filter_active = false;
|
||||||
|
state.artists.filter.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
state.artists.filter_active = false;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
state.artists.filter.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
state.artists.filter.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
state.artists.filter_active = true;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.artists.filter.clear();
|
||||||
|
state.artists.expanded.clear();
|
||||||
|
state.artists.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
state.artists.focus = (state.artists.focus + 1) % 2;
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
state.artists.focus = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
// Move focus to songs (right pane)
|
||||||
|
if !state.artists.songs.is_empty() {
|
||||||
|
state.artists.focus = 1;
|
||||||
|
if state.artists.selected_song.is_none() {
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if state.artists.focus == 0 {
|
||||||
|
// Tree navigation
|
||||||
|
let tree_items = build_tree_items(&state);
|
||||||
|
if let Some(sel) = state.artists.selected_index {
|
||||||
|
if sel > 0 {
|
||||||
|
state.artists.selected_index = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !tree_items.is_empty() {
|
||||||
|
state.artists.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
// Preview album songs in right pane
|
||||||
|
let album_id = state
|
||||||
|
.artists
|
||||||
|
.selected_index
|
||||||
|
.and_then(|i| tree_items.get(i))
|
||||||
|
.and_then(|item| match item {
|
||||||
|
TreeItem::Album { album } => Some(album.id.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
drop(state);
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.artists.songs = songs;
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Song list
|
||||||
|
if let Some(sel) = state.artists.selected_song {
|
||||||
|
if sel > 0 {
|
||||||
|
state.artists.selected_song = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !state.artists.songs.is_empty() {
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if state.artists.focus == 0 {
|
||||||
|
// Tree navigation
|
||||||
|
let tree_items = build_tree_items(&state);
|
||||||
|
let max = tree_items.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.artists.selected_index {
|
||||||
|
if sel < max {
|
||||||
|
state.artists.selected_index = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !tree_items.is_empty() {
|
||||||
|
state.artists.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
// Preview album songs in right pane
|
||||||
|
let album_id = state
|
||||||
|
.artists
|
||||||
|
.selected_index
|
||||||
|
.and_then(|i| tree_items.get(i))
|
||||||
|
.and_then(|item| match item {
|
||||||
|
TreeItem::Album { album } => Some(album.id.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
drop(state);
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.artists.songs = songs;
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Song list
|
||||||
|
let max = state.artists.songs.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.artists.selected_song {
|
||||||
|
if sel < max {
|
||||||
|
state.artists.selected_song = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.artists.songs.is_empty() {
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.artists.focus == 0 {
|
||||||
|
// Get current tree item
|
||||||
|
let tree_items = build_tree_items(&state);
|
||||||
|
if let Some(idx) = state.artists.selected_index {
|
||||||
|
if let Some(item) = tree_items.get(idx) {
|
||||||
|
match item {
|
||||||
|
TreeItem::Artist { artist, expanded } => {
|
||||||
|
let artist_id = artist.id.clone();
|
||||||
|
let artist_name = artist.name.clone();
|
||||||
|
let was_expanded = *expanded;
|
||||||
|
|
||||||
|
if was_expanded {
|
||||||
|
state.artists.expanded.remove(&artist_id);
|
||||||
|
} else {
|
||||||
|
if !state.artists.albums_cache.contains_key(&artist_id) {
|
||||||
|
drop(state);
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_artist(&artist_id).await {
|
||||||
|
Ok((_artist, albums)) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = albums.len();
|
||||||
|
state.artists.albums_cache.insert(artist_id.clone(), albums);
|
||||||
|
state.artists.expanded.insert(artist_id);
|
||||||
|
info!("Loaded {} albums for {}", count, artist_name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to load: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
state.artists.expanded.insert(artist_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TreeItem::Album { album } => {
|
||||||
|
let album_id = album.id.clone();
|
||||||
|
let album_name = album.name.clone();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_album(&album_id).await {
|
||||||
|
Ok((_album, songs)) => {
|
||||||
|
if songs.is_empty() {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error("Album has no songs");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_song = songs[0].clone();
|
||||||
|
let stream_url = client.get_stream_url(&first_song.id);
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = songs.len();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs.clone());
|
||||||
|
state.queue_position = Some(0);
|
||||||
|
state.artists.songs = songs;
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
state.artists.focus = 1;
|
||||||
|
state.now_playing.song = Some(first_song.clone());
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
match stream_url {
|
||||||
|
Ok(url) => {
|
||||||
|
if self.mpv.is_paused().unwrap_or(false) {
|
||||||
|
let _ = self.mpv.resume();
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.loadfile(&url) {
|
||||||
|
error!("Failed to play: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get stream URL: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to load album: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Play selected song from current position
|
||||||
|
if let Some(idx) = state.artists.selected_song {
|
||||||
|
if idx < state.artists.songs.len() {
|
||||||
|
let song = state.artists.songs[idx].clone();
|
||||||
|
let songs = state.artists.songs.clone();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
state.queue_position = Some(idx);
|
||||||
|
state.now_playing.song = Some(song.clone());
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
state.notify(format!("Playing: {}", song.title));
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_stream_url(&song.id) {
|
||||||
|
Ok(url) => {
|
||||||
|
if self.mpv.is_paused().unwrap_or(false) {
|
||||||
|
let _ = self.mpv.resume();
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.loadfile(&url) {
|
||||||
|
error!("Failed to play: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get stream URL: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if state.artists.focus == 1 {
|
||||||
|
state.artists.focus = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
if state.artists.focus == 1 {
|
||||||
|
if let Some(idx) = state.artists.selected_song {
|
||||||
|
if let Some(song) = state.artists.songs.get(idx).cloned() {
|
||||||
|
let title = song.title.clone();
|
||||||
|
state.queue.push(song);
|
||||||
|
state.notify(format!("Added to queue: {}", title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !state.artists.songs.is_empty() {
|
||||||
|
let count = state.artists.songs.len();
|
||||||
|
let songs = state.artists.songs.clone();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
state.notify(format!("Added {} songs to queue", count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
|
||||||
|
if state.artists.focus == 1 {
|
||||||
|
if let Some(idx) = state.artists.selected_song {
|
||||||
|
if let Some(song) = state.artists.songs.get(idx).cloned() {
|
||||||
|
let title = song.title.clone();
|
||||||
|
state.queue.insert(insert_pos, song);
|
||||||
|
state.notify(format!("Playing next: {}", title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !state.artists.songs.is_empty() {
|
||||||
|
let count = state.artists.songs.len();
|
||||||
|
let songs: Vec<_> = state.artists.songs.iter().cloned().collect();
|
||||||
|
for (i, song) in songs.into_iter().enumerate() {
|
||||||
|
state.queue.insert(insert_pos + i, song);
|
||||||
|
}
|
||||||
|
state.notify(format!("Playing {} songs next", count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/app/input_playlists.rs
Normal file
167
src/app/input_playlists.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use crossterm::event::{self, KeyCode};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle playlists page keys
|
||||||
|
pub(super) async fn handle_playlists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
state.playlists.focus = (state.playlists.focus + 1) % 2;
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
state.playlists.focus = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if !state.playlists.songs.is_empty() {
|
||||||
|
state.playlists.focus = 1;
|
||||||
|
if state.playlists.selected_song.is_none() {
|
||||||
|
state.playlists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if state.playlists.focus == 0 {
|
||||||
|
// Playlist list
|
||||||
|
if let Some(sel) = state.playlists.selected_playlist {
|
||||||
|
if sel > 0 {
|
||||||
|
state.playlists.selected_playlist = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.playlists.is_empty() {
|
||||||
|
state.playlists.selected_playlist = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Song list
|
||||||
|
if let Some(sel) = state.playlists.selected_song {
|
||||||
|
if sel > 0 {
|
||||||
|
state.playlists.selected_song = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.songs.is_empty() {
|
||||||
|
state.playlists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if state.playlists.focus == 0 {
|
||||||
|
let max = state.playlists.playlists.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.playlists.selected_playlist {
|
||||||
|
if sel < max {
|
||||||
|
state.playlists.selected_playlist = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.playlists.is_empty() {
|
||||||
|
state.playlists.selected_playlist = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let max = state.playlists.songs.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.playlists.selected_song {
|
||||||
|
if sel < max {
|
||||||
|
state.playlists.selected_song = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.songs.is_empty() {
|
||||||
|
state.playlists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.playlists.focus == 0 {
|
||||||
|
// Load playlist songs
|
||||||
|
if let Some(idx) = state.playlists.selected_playlist {
|
||||||
|
if let Some(playlist) = state.playlists.playlists.get(idx) {
|
||||||
|
let playlist_id = playlist.id.clone();
|
||||||
|
let playlist_name = playlist.name.clone();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_playlist(&playlist_id).await {
|
||||||
|
Ok((_playlist, songs)) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = songs.len();
|
||||||
|
state.playlists.songs = songs;
|
||||||
|
state.playlists.selected_song =
|
||||||
|
if count > 0 { Some(0) } else { None };
|
||||||
|
state.playlists.focus = 1;
|
||||||
|
state.notify(format!(
|
||||||
|
"Loaded playlist: {} ({} songs)",
|
||||||
|
playlist_name, count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!(
|
||||||
|
"Failed to load playlist: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Play selected song from playlist
|
||||||
|
if let Some(idx) = state.playlists.selected_song {
|
||||||
|
if idx < state.playlists.songs.len() {
|
||||||
|
let songs = state.playlists.songs.clone();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
drop(state);
|
||||||
|
return self.play_queue_position(idx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
// Add to queue
|
||||||
|
if state.playlists.focus == 1 {
|
||||||
|
if let Some(idx) = state.playlists.selected_song {
|
||||||
|
if let Some(song) = state.playlists.songs.get(idx).cloned() {
|
||||||
|
let title = song.title.clone();
|
||||||
|
state.queue.push(song);
|
||||||
|
state.notify(format!("Added to queue: {}", title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add whole playlist
|
||||||
|
if !state.playlists.songs.is_empty() {
|
||||||
|
let count = state.playlists.songs.len();
|
||||||
|
let songs = state.playlists.songs.clone();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
state.notify(format!("Added {} songs to queue", count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
// Add next
|
||||||
|
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
|
||||||
|
if state.playlists.focus == 1 {
|
||||||
|
if let Some(idx) = state.playlists.selected_song {
|
||||||
|
if let Some(song) = state.playlists.songs.get(idx).cloned() {
|
||||||
|
let title = song.title.clone();
|
||||||
|
state.queue.insert(insert_pos, song);
|
||||||
|
state.notify(format!("Playing next: {}", title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
// Shuffle play playlist
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
if !state.playlists.songs.is_empty() {
|
||||||
|
let mut songs = state.playlists.songs.clone();
|
||||||
|
songs.shuffle(&mut rand::thread_rng());
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
drop(state);
|
||||||
|
return self.play_queue_position(0).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/app/input_queue.rs
Normal file
144
src/app/input_queue.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
use crossterm::event::{self, KeyCode};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle queue page keys
|
||||||
|
pub(super) async fn handle_queue_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if let Some(sel) = state.queue_state.selected {
|
||||||
|
if sel > 0 {
|
||||||
|
state.queue_state.selected = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !state.queue.is_empty() {
|
||||||
|
state.queue_state.selected = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
let max = state.queue.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.queue_state.selected {
|
||||||
|
if sel < max {
|
||||||
|
state.queue_state.selected = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.queue.is_empty() {
|
||||||
|
state.queue_state.selected = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Play selected song
|
||||||
|
if let Some(idx) = state.queue_state.selected {
|
||||||
|
if idx < state.queue.len() {
|
||||||
|
drop(state);
|
||||||
|
return self.play_queue_position(idx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
// Remove selected song
|
||||||
|
if let Some(idx) = state.queue_state.selected {
|
||||||
|
if idx < state.queue.len() {
|
||||||
|
let song = state.queue.remove(idx);
|
||||||
|
state.notify(format!("Removed: {}", song.title));
|
||||||
|
// Adjust selection
|
||||||
|
if state.queue.is_empty() {
|
||||||
|
state.queue_state.selected = None;
|
||||||
|
} else if idx >= state.queue.len() {
|
||||||
|
state.queue_state.selected = Some(state.queue.len() - 1);
|
||||||
|
}
|
||||||
|
// Adjust queue position
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
if idx < pos {
|
||||||
|
state.queue_position = Some(pos - 1);
|
||||||
|
} else if idx == pos {
|
||||||
|
state.queue_position = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('J') => {
|
||||||
|
// Move down
|
||||||
|
if let Some(idx) = state.queue_state.selected {
|
||||||
|
if idx < state.queue.len() - 1 {
|
||||||
|
state.queue.swap(idx, idx + 1);
|
||||||
|
state.queue_state.selected = Some(idx + 1);
|
||||||
|
// Adjust queue position if needed
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
if pos == idx {
|
||||||
|
state.queue_position = Some(idx + 1);
|
||||||
|
} else if pos == idx + 1 {
|
||||||
|
state.queue_position = Some(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('K') => {
|
||||||
|
// Move up
|
||||||
|
if let Some(idx) = state.queue_state.selected {
|
||||||
|
if idx > 0 {
|
||||||
|
state.queue.swap(idx, idx - 1);
|
||||||
|
state.queue_state.selected = Some(idx - 1);
|
||||||
|
// Adjust queue position if needed
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
if pos == idx {
|
||||||
|
state.queue_position = Some(idx - 1);
|
||||||
|
} else if pos == idx - 1 {
|
||||||
|
state.queue_position = Some(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
// Shuffle queue
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
// Keep current song in place, shuffle the rest
|
||||||
|
if pos < state.queue.len() {
|
||||||
|
let current = state.queue.remove(pos);
|
||||||
|
state.queue.shuffle(&mut rng);
|
||||||
|
state.queue.insert(0, current);
|
||||||
|
state.queue_position = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.queue.shuffle(&mut rng);
|
||||||
|
}
|
||||||
|
state.notify("Queue shuffled");
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
// Clear history (remove all songs before current position)
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
if pos > 0 {
|
||||||
|
let removed = pos;
|
||||||
|
state.queue.drain(0..pos);
|
||||||
|
state.queue_position = Some(0);
|
||||||
|
// Adjust selection
|
||||||
|
if let Some(sel) = state.queue_state.selected {
|
||||||
|
if sel < pos {
|
||||||
|
state.queue_state.selected = Some(0);
|
||||||
|
} else {
|
||||||
|
state.queue_state.selected = Some(sel - pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.notify(format!("Cleared {} played songs", removed));
|
||||||
|
} else {
|
||||||
|
state.notify("No history to clear");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.notify("No history to clear");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/app/input_server.rs
Normal file
136
src/app/input_server.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use crossterm::event::{self, KeyCode};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::subsonic::SubsonicClient;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle server page keys
|
||||||
|
pub(super) async fn handle_server_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
|
||||||
|
let field = state.server_state.selected_field;
|
||||||
|
let is_text_field = field <= 2;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
// Navigation - always works
|
||||||
|
KeyCode::Up => {
|
||||||
|
if field > 0 {
|
||||||
|
state.server_state.selected_field -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if field < 4 {
|
||||||
|
state.server_state.selected_field += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
// Tab moves to next field, wrapping around
|
||||||
|
state.server_state.selected_field = (field + 1) % 5;
|
||||||
|
}
|
||||||
|
// Text input for text fields (0=URL, 1=Username, 2=Password)
|
||||||
|
KeyCode::Char(c) if is_text_field => match field {
|
||||||
|
0 => state.server_state.base_url.push(c),
|
||||||
|
1 => state.server_state.username.push(c),
|
||||||
|
2 => state.server_state.password.push(c),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Backspace if is_text_field => match field {
|
||||||
|
0 => {
|
||||||
|
state.server_state.base_url.pop();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
state.server_state.username.pop();
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
state.server_state.password.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// Enter activates buttons, ignored on text fields
|
||||||
|
KeyCode::Enter => {
|
||||||
|
match field {
|
||||||
|
3 => {
|
||||||
|
// Test connection
|
||||||
|
let url = state.server_state.base_url.clone();
|
||||||
|
let user = state.server_state.username.clone();
|
||||||
|
let pass = state.server_state.password.clone();
|
||||||
|
state.server_state.status = Some("Testing connection...".to_string());
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
match SubsonicClient::new(&url, &user, &pass) {
|
||||||
|
Ok(client) => match client.ping().await {
|
||||||
|
Ok(_) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.server_state.status =
|
||||||
|
Some("Connection successful!".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.server_state.status =
|
||||||
|
Some(format!("Connection failed: {}", e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.server_state.status = Some(format!("Invalid URL: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
// Save config and reconnect
|
||||||
|
info!(
|
||||||
|
"Saving config: url='{}', user='{}'",
|
||||||
|
state.server_state.base_url, state.server_state.username
|
||||||
|
);
|
||||||
|
state.config.base_url = state.server_state.base_url.clone();
|
||||||
|
state.config.username = state.server_state.username.clone();
|
||||||
|
state.config.password = state.server_state.password.clone();
|
||||||
|
|
||||||
|
let url = state.config.base_url.clone();
|
||||||
|
let user = state.config.username.clone();
|
||||||
|
let pass = state.config.password.clone();
|
||||||
|
|
||||||
|
match state.config.save_default() {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Config saved successfully");
|
||||||
|
state.server_state.status =
|
||||||
|
Some("Saved! Connecting...".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!("Config save failed: {}", e);
|
||||||
|
state.server_state.status = Some(format!("Save failed: {}", e));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
// Create new client and load data
|
||||||
|
match SubsonicClient::new(&url, &user, &pass) {
|
||||||
|
Ok(client) => {
|
||||||
|
self.subsonic = Some(client);
|
||||||
|
self.load_initial_data().await;
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.server_state.status =
|
||||||
|
Some("Connected and loaded data!".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.server_state.status =
|
||||||
|
Some(format!("Saved but connection failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {} // Ignore Enter on text fields (handles paste with newlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/input_settings.rs
Normal file
127
src/app/input_settings.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use crossterm::event::{self, KeyCode};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle settings page keys
|
||||||
|
pub(super) async fn handle_settings_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||||
|
let mut config_changed = false;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let field = state.settings_state.selected_field;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
// Navigate between fields
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if field > 0 {
|
||||||
|
state.settings_state.selected_field = field - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if field < 2 {
|
||||||
|
state.settings_state.selected_field = field + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left
|
||||||
|
KeyCode::Left | KeyCode::Char('h') => match field {
|
||||||
|
0 => {
|
||||||
|
state.settings_state.prev_theme();
|
||||||
|
state.config.theme = state.settings_state.theme_name().to_string();
|
||||||
|
let label = state.settings_state.theme_name().to_string();
|
||||||
|
state.notify(format!("Theme: {}", label));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
1 if state.cava_available => {
|
||||||
|
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
|
||||||
|
state.config.cava = state.settings_state.cava_enabled;
|
||||||
|
let status = if state.settings_state.cava_enabled {
|
||||||
|
"On"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
};
|
||||||
|
state.notify(format!("Cava: {}", status));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
2 if state.cava_available => {
|
||||||
|
let cur = state.settings_state.cava_size;
|
||||||
|
if cur > 10 {
|
||||||
|
let new_size = cur - 5;
|
||||||
|
state.settings_state.cava_size = new_size;
|
||||||
|
state.config.cava_size = new_size;
|
||||||
|
state.notify(format!("Cava Size: {}%", new_size));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// Right / Enter / Space
|
||||||
|
KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter | KeyCode::Char(' ') => {
|
||||||
|
match field {
|
||||||
|
0 => {
|
||||||
|
state.settings_state.next_theme();
|
||||||
|
state.config.theme = state.settings_state.theme_name().to_string();
|
||||||
|
let label = state.settings_state.theme_name().to_string();
|
||||||
|
state.notify(format!("Theme: {}", label));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
1 if state.cava_available => {
|
||||||
|
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
|
||||||
|
state.config.cava = state.settings_state.cava_enabled;
|
||||||
|
let status = if state.settings_state.cava_enabled {
|
||||||
|
"On"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
};
|
||||||
|
state.notify(format!("Cava: {}", status));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
2 if state.cava_available => {
|
||||||
|
let cur = state.settings_state.cava_size;
|
||||||
|
if cur < 80 {
|
||||||
|
let new_size = cur + 5;
|
||||||
|
state.settings_state.cava_size = new_size;
|
||||||
|
state.config.cava_size = new_size;
|
||||||
|
state.notify(format!("Cava Size: {}%", new_size));
|
||||||
|
config_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config_changed {
|
||||||
|
// Save config
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if let Err(e) = state.config.save_default() {
|
||||||
|
drop(state);
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to save: {}", e));
|
||||||
|
} else {
|
||||||
|
// Start/stop cava based on new setting, or restart on theme change
|
||||||
|
let cava_enabled = state.settings_state.cava_enabled;
|
||||||
|
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;
|
||||||
|
let cava_running = self.cava_parser.is_some();
|
||||||
|
drop(state);
|
||||||
|
if cava_enabled {
|
||||||
|
// (Re)start cava — picks up new theme colors or toggle-on
|
||||||
|
self.start_cava(&g, &h, cs);
|
||||||
|
} else if cava_running {
|
||||||
|
self.stop_cava();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.cava_screen.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
2312
src/app/mod.rs
2312
src/app/mod.rs
File diff suppressed because it is too large
Load Diff
533
src/app/mouse.rs
Normal file
533
src/app/mouse.rs
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
use crossterm::event::{self, MouseButton, MouseEventKind};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle mouse input
|
||||||
|
pub(super) async fn handle_mouse(&mut self, mouse: event::MouseEvent) -> Result<(), Error> {
|
||||||
|
let x = mouse.column;
|
||||||
|
let y = mouse.row;
|
||||||
|
|
||||||
|
match mouse.kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
self.handle_mouse_click(x, y).await
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
self.handle_mouse_scroll_up().await
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
self.handle_mouse_scroll_down().await
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle left mouse click
|
||||||
|
async fn handle_mouse_click(&mut self, x: u16, y: u16) -> Result<(), Error> {
|
||||||
|
use crate::ui::header::{Header, HeaderRegion};
|
||||||
|
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let layout = state.layout.clone();
|
||||||
|
let page = state.page;
|
||||||
|
let duration = state.now_playing.duration;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
// Check header area
|
||||||
|
if y >= layout.header.y && y < layout.header.y + layout.header.height {
|
||||||
|
if let Some(region) = Header::region_at(layout.header, x, y) {
|
||||||
|
match region {
|
||||||
|
HeaderRegion::Tab(tab_page) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.page = tab_page;
|
||||||
|
}
|
||||||
|
HeaderRegion::PrevButton => {
|
||||||
|
return self.prev_track().await;
|
||||||
|
}
|
||||||
|
HeaderRegion::PlayButton => {
|
||||||
|
return self.toggle_pause().await;
|
||||||
|
}
|
||||||
|
HeaderRegion::PauseButton => {
|
||||||
|
return self.toggle_pause().await;
|
||||||
|
}
|
||||||
|
HeaderRegion::StopButton => {
|
||||||
|
return self.stop_playback().await;
|
||||||
|
}
|
||||||
|
HeaderRegion::NextButton => {
|
||||||
|
return self.next_track().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check now playing area (progress bar seeking)
|
||||||
|
if y >= layout.now_playing.y && y < layout.now_playing.y + layout.now_playing.height {
|
||||||
|
// The progress bar is on the last content line of the now_playing block.
|
||||||
|
// The block has a 1-cell border, so inner area starts at y+1.
|
||||||
|
// Progress bar row depends on layout height, but it's always the last inner row.
|
||||||
|
let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2; // -1 for border, -1 for 0-index
|
||||||
|
if y == inner_bottom && duration > 0.0 {
|
||||||
|
// Calculate seek position from x coordinate within the now_playing area
|
||||||
|
// The progress bar renders centered: "MM:SS / MM:SS [━━━━━────]"
|
||||||
|
// We approximate: the bar occupies roughly the right portion of the inner area
|
||||||
|
let inner_x_start = layout.now_playing.x + 1; // border
|
||||||
|
let inner_width = layout.now_playing.width.saturating_sub(2);
|
||||||
|
if inner_width > 15 && x >= inner_x_start {
|
||||||
|
let rel_x = x - inner_x_start;
|
||||||
|
// Time text is roughly "MM:SS / MM:SS " = ~15 chars, bar fills the rest
|
||||||
|
let time_width = 15u16;
|
||||||
|
let bar_width = inner_width.saturating_sub(time_width + 2);
|
||||||
|
let bar_start = (inner_width.saturating_sub(time_width + 2 + bar_width)) / 2 + time_width + 2;
|
||||||
|
if bar_width > 0 && rel_x >= bar_start && rel_x < bar_start + bar_width {
|
||||||
|
let fraction = (rel_x - bar_start) as f64 / bar_width as f64;
|
||||||
|
let seek_pos = fraction * duration;
|
||||||
|
let _ = self.mpv.seek(seek_pos);
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.position = seek_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content area
|
||||||
|
if y >= layout.content.y && y < layout.content.y + layout.content.height {
|
||||||
|
return self.handle_content_click(x, y, page, &layout).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle click within the content area
|
||||||
|
async fn handle_content_click(
|
||||||
|
&mut self,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
page: Page,
|
||||||
|
layout: &LayoutAreas,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match page {
|
||||||
|
Page::Artists => self.handle_artists_click(x, y, layout).await,
|
||||||
|
Page::Queue => self.handle_queue_click(y, layout).await,
|
||||||
|
Page::Playlists => self.handle_playlists_click(x, y, layout).await,
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle click on artists page
|
||||||
|
async fn handle_artists_click(
|
||||||
|
&mut self,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
layout: &LayoutAreas,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
use crate::ui::pages::artists::{build_tree_items, TreeItem};
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let left = layout.content_left.unwrap_or(layout.content);
|
||||||
|
let right = layout.content_right.unwrap_or(layout.content);
|
||||||
|
|
||||||
|
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
|
||||||
|
// Tree pane click — account for border (1 row top)
|
||||||
|
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
|
||||||
|
let item_index = state.artists.tree_scroll_offset + row_in_viewport;
|
||||||
|
let tree_items = build_tree_items(&state);
|
||||||
|
|
||||||
|
if item_index < tree_items.len() {
|
||||||
|
let was_selected = state.artists.selected_index == Some(item_index);
|
||||||
|
state.artists.focus = 0;
|
||||||
|
state.artists.selected_index = Some(item_index);
|
||||||
|
|
||||||
|
// Second click = activate (same as Enter)
|
||||||
|
let is_second_click = was_selected
|
||||||
|
&& self.last_click.map_or(false, |(lx, ly, t)| {
|
||||||
|
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_second_click {
|
||||||
|
// Activate: expand/collapse artist, or play album
|
||||||
|
match &tree_items[item_index] {
|
||||||
|
TreeItem::Artist { artist, expanded } => {
|
||||||
|
let artist_id = artist.id.clone();
|
||||||
|
let artist_name = artist.name.clone();
|
||||||
|
let was_expanded = *expanded;
|
||||||
|
|
||||||
|
if was_expanded {
|
||||||
|
state.artists.expanded.remove(&artist_id);
|
||||||
|
} else {
|
||||||
|
if !state.artists.albums_cache.contains_key(&artist_id) {
|
||||||
|
drop(state);
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_artist(&artist_id).await {
|
||||||
|
Ok((_artist, albums)) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = albums.len();
|
||||||
|
state.artists.albums_cache.insert(artist_id.clone(), albums);
|
||||||
|
state.artists.expanded.insert(artist_id);
|
||||||
|
tracing::info!("Loaded {} albums for {}", count, artist_name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to load: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
state.artists.expanded.insert(artist_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TreeItem::Album { album } => {
|
||||||
|
let album_id = album.id.clone();
|
||||||
|
let album_name = album.name.clone();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_album(&album_id).await {
|
||||||
|
Ok((_album, songs)) => {
|
||||||
|
if songs.is_empty() {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error("Album has no songs");
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_song = songs[0].clone();
|
||||||
|
let stream_url = client.get_stream_url(&first_song.id);
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = songs.len();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs.clone());
|
||||||
|
state.queue_position = Some(0);
|
||||||
|
state.artists.songs = songs;
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
state.artists.focus = 1;
|
||||||
|
state.now_playing.song = Some(first_song.clone());
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Ok(url) = stream_url {
|
||||||
|
if self.mpv.is_paused().unwrap_or(false) {
|
||||||
|
let _ = self.mpv.resume();
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.loadfile(&url) {
|
||||||
|
error!("Failed to play: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to load album: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First click on album: preview songs in right pane
|
||||||
|
if let TreeItem::Album { album } = &tree_items[item_index] {
|
||||||
|
let album_id = album.id.clone();
|
||||||
|
drop(state);
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.artists.songs = songs;
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
|
||||||
|
// Songs pane click
|
||||||
|
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
|
||||||
|
let item_index = state.artists.song_scroll_offset + row_in_viewport;
|
||||||
|
|
||||||
|
if item_index < state.artists.songs.len() {
|
||||||
|
let was_selected = state.artists.selected_song == Some(item_index);
|
||||||
|
state.artists.focus = 1;
|
||||||
|
state.artists.selected_song = Some(item_index);
|
||||||
|
|
||||||
|
let is_second_click = was_selected
|
||||||
|
&& self.last_click.map_or(false, |(lx, ly, t)| {
|
||||||
|
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_second_click {
|
||||||
|
// Play selected song
|
||||||
|
let song = state.artists.songs[item_index].clone();
|
||||||
|
let songs = state.artists.songs.clone();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
state.queue_position = Some(item_index);
|
||||||
|
state.now_playing.song = Some(song.clone());
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
state.notify(format!("Playing: {}", song.title));
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
if let Ok(url) = client.get_stream_url(&song.id) {
|
||||||
|
if self.mpv.is_paused().unwrap_or(false) {
|
||||||
|
let _ = self.mpv.resume();
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.loadfile(&url) {
|
||||||
|
error!("Failed to play: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle click on queue page
|
||||||
|
async fn handle_queue_click(&mut self, y: u16, layout: &LayoutAreas) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let content = layout.content;
|
||||||
|
|
||||||
|
// Account for border (1 row top)
|
||||||
|
let row_in_viewport = y.saturating_sub(content.y + 1) as usize;
|
||||||
|
let item_index = state.queue_state.scroll_offset + row_in_viewport;
|
||||||
|
|
||||||
|
if item_index < state.queue.len() {
|
||||||
|
let was_selected = state.queue_state.selected == Some(item_index);
|
||||||
|
state.queue_state.selected = Some(item_index);
|
||||||
|
|
||||||
|
let is_second_click = was_selected
|
||||||
|
&& self.last_click.map_or(false, |(_, ly, t)| {
|
||||||
|
ly == y && t.elapsed().as_millis() < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_second_click {
|
||||||
|
drop(state);
|
||||||
|
self.last_click = Some((0, y, std::time::Instant::now()));
|
||||||
|
return self.play_queue_position(item_index).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_click = Some((0, y, std::time::Instant::now()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle click on playlists page
|
||||||
|
async fn handle_playlists_click(
|
||||||
|
&mut self,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
layout: &LayoutAreas,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let left = layout.content_left.unwrap_or(layout.content);
|
||||||
|
let right = layout.content_right.unwrap_or(layout.content);
|
||||||
|
|
||||||
|
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
|
||||||
|
// Playlists pane
|
||||||
|
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
|
||||||
|
let item_index = state.playlists.playlist_scroll_offset + row_in_viewport;
|
||||||
|
|
||||||
|
if item_index < state.playlists.playlists.len() {
|
||||||
|
let was_selected = state.playlists.selected_playlist == Some(item_index);
|
||||||
|
state.playlists.focus = 0;
|
||||||
|
state.playlists.selected_playlist = Some(item_index);
|
||||||
|
|
||||||
|
let is_second_click = was_selected
|
||||||
|
&& self.last_click.map_or(false, |(lx, ly, t)| {
|
||||||
|
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_second_click {
|
||||||
|
// Load playlist songs (same as Enter)
|
||||||
|
let playlist = state.playlists.playlists[item_index].clone();
|
||||||
|
let playlist_id = playlist.id.clone();
|
||||||
|
let playlist_name = playlist.name.clone();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_playlist(&playlist_id).await {
|
||||||
|
Ok((_playlist, songs)) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
let count = songs.len();
|
||||||
|
state.playlists.songs = songs;
|
||||||
|
state.playlists.selected_song = if count > 0 { Some(0) } else { None };
|
||||||
|
state.playlists.focus = 1;
|
||||||
|
state.notify(format!("Loaded playlist: {} ({} songs)", playlist_name, count));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to load playlist: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
|
||||||
|
// Songs pane
|
||||||
|
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
|
||||||
|
let item_index = state.playlists.song_scroll_offset + row_in_viewport;
|
||||||
|
|
||||||
|
if item_index < state.playlists.songs.len() {
|
||||||
|
let was_selected = state.playlists.selected_song == Some(item_index);
|
||||||
|
state.playlists.focus = 1;
|
||||||
|
state.playlists.selected_song = Some(item_index);
|
||||||
|
|
||||||
|
let is_second_click = was_selected
|
||||||
|
&& self.last_click.map_or(false, |(lx, ly, t)| {
|
||||||
|
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_second_click {
|
||||||
|
// Play selected song from playlist
|
||||||
|
let songs = state.playlists.songs.clone();
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue.extend(songs);
|
||||||
|
drop(state);
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
return self.play_queue_position(item_index).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle mouse scroll up (move selection up in current list)
|
||||||
|
async fn handle_mouse_scroll_up(&mut self) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
match state.page {
|
||||||
|
Page::Artists => {
|
||||||
|
if state.artists.focus == 0 {
|
||||||
|
if let Some(sel) = state.artists.selected_index {
|
||||||
|
if sel > 0 {
|
||||||
|
state.artists.selected_index = Some(sel - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(sel) = state.artists.selected_song {
|
||||||
|
if sel > 0 {
|
||||||
|
state.artists.selected_song = Some(sel - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Page::Queue => {
|
||||||
|
if let Some(sel) = state.queue_state.selected {
|
||||||
|
if sel > 0 {
|
||||||
|
state.queue_state.selected = Some(sel - 1);
|
||||||
|
}
|
||||||
|
} else if !state.queue.is_empty() {
|
||||||
|
state.queue_state.selected = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Page::Playlists => {
|
||||||
|
if state.playlists.focus == 0 {
|
||||||
|
if let Some(sel) = state.playlists.selected_playlist {
|
||||||
|
if sel > 0 {
|
||||||
|
state.playlists.selected_playlist = Some(sel - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(sel) = state.playlists.selected_song {
|
||||||
|
if sel > 0 {
|
||||||
|
state.playlists.selected_song = Some(sel - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle mouse scroll down (move selection down in current list)
|
||||||
|
async fn handle_mouse_scroll_down(&mut self) -> Result<(), Error> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
match state.page {
|
||||||
|
Page::Artists => {
|
||||||
|
if state.artists.focus == 0 {
|
||||||
|
let tree_items = crate::ui::pages::artists::build_tree_items(&state);
|
||||||
|
let max = tree_items.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.artists.selected_index {
|
||||||
|
if sel < max {
|
||||||
|
state.artists.selected_index = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !tree_items.is_empty() {
|
||||||
|
state.artists.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let max = state.artists.songs.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.artists.selected_song {
|
||||||
|
if sel < max {
|
||||||
|
state.artists.selected_song = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.artists.songs.is_empty() {
|
||||||
|
state.artists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Page::Queue => {
|
||||||
|
let max = state.queue.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.queue_state.selected {
|
||||||
|
if sel < max {
|
||||||
|
state.queue_state.selected = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.queue.is_empty() {
|
||||||
|
state.queue_state.selected = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Page::Playlists => {
|
||||||
|
if state.playlists.focus == 0 {
|
||||||
|
let max = state.playlists.playlists.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.playlists.selected_playlist {
|
||||||
|
if sel < max {
|
||||||
|
state.playlists.selected_playlist = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.playlists.is_empty() {
|
||||||
|
state.playlists.selected_playlist = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let max = state.playlists.songs.len().saturating_sub(1);
|
||||||
|
if let Some(sel) = state.playlists.selected_song {
|
||||||
|
if sel < max {
|
||||||
|
state.playlists.selected_song = Some(sel + 1);
|
||||||
|
}
|
||||||
|
} else if !state.playlists.songs.is_empty() {
|
||||||
|
state.playlists.selected_song = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
411
src/app/playback.rs
Normal file
411
src/app/playback.rs
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Update playback position and audio info from MPV
|
||||||
|
pub(super) async fn update_playback_info(&mut self) {
|
||||||
|
// Only update if something should be playing
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let is_playing = state.now_playing.state == PlaybackState::Playing;
|
||||||
|
let is_active = is_playing || state.now_playing.state == PlaybackState::Paused;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if !is_active || !self.mpv.is_running() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for track advancement
|
||||||
|
if is_playing {
|
||||||
|
// Early transition: if near end of track and no preloaded next track,
|
||||||
|
// advance immediately instead of waiting for idle detection
|
||||||
|
{
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let time_remaining = state.now_playing.duration - state.now_playing.position;
|
||||||
|
let has_next = state
|
||||||
|
.queue_position
|
||||||
|
.map(|p| p + 1 < state.queue.len())
|
||||||
|
.unwrap_or(false);
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if has_next && time_remaining > 0.0 && time_remaining < 2.0 {
|
||||||
|
if let Ok(count) = self.mpv.get_playlist_count() {
|
||||||
|
if count < 2 {
|
||||||
|
info!("Near end of track with no preloaded next — advancing early");
|
||||||
|
let _ = self.next_track().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-preload if the appended track was lost
|
||||||
|
if let Ok(count) = self.mpv.get_playlist_count() {
|
||||||
|
if count == 1 {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if let Some(pos) = state.queue_position {
|
||||||
|
if pos + 1 < state.queue.len() {
|
||||||
|
drop(state);
|
||||||
|
debug!("Playlist count is 1, re-preloading next track");
|
||||||
|
self.preload_next_track(pos).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MPV advanced to next track in playlist (gapless transition)
|
||||||
|
if let Ok(Some(mpv_pos)) = self.mpv.get_playlist_pos() {
|
||||||
|
if mpv_pos == 1 {
|
||||||
|
// Gapless advance happened - update our state to match
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if let Some(current_pos) = state.queue_position {
|
||||||
|
let next_pos = current_pos + 1;
|
||||||
|
if next_pos < state.queue.len() {
|
||||||
|
drop(state);
|
||||||
|
info!("Gapless advancement to track {}", next_pos);
|
||||||
|
|
||||||
|
// Update state - keep audio properties since they'll be similar
|
||||||
|
// for gapless transitions (same album, same format)
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.queue_position = Some(next_pos);
|
||||||
|
if let Some(song) = state.queue.get(next_pos).cloned() {
|
||||||
|
state.now_playing.song = Some(song.clone());
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||||
|
// Don't reset audio properties - let them update naturally
|
||||||
|
// This avoids triggering PipeWire rate changes unnecessarily
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
// Remove the finished track (index 0) from MPV's playlist
|
||||||
|
// This is less disruptive than playlist_clear during playback
|
||||||
|
let _ = self.mpv.playlist_remove(0);
|
||||||
|
|
||||||
|
// Preload the next track for continued gapless playback
|
||||||
|
self.preload_next_track(next_pos).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MPV went idle (track ended, no preloaded track)
|
||||||
|
if let Ok(idle) = self.mpv.is_idle() {
|
||||||
|
if idle {
|
||||||
|
info!("Track ended, advancing to next");
|
||||||
|
let _ = self.next_track().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get position from MPV
|
||||||
|
if let Ok(position) = self.mpv.get_time_pos() {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get duration if not set
|
||||||
|
{
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if state.now_playing.duration <= 0.0 {
|
||||||
|
drop(state);
|
||||||
|
if let Ok(duration) = self.mpv.get_duration() {
|
||||||
|
if duration > 0.0 {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get audio properties - keep polling until we get valid values
|
||||||
|
// MPV may not have them ready immediately when playback starts
|
||||||
|
{
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let need_sample_rate = state.now_playing.sample_rate.is_none();
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if need_sample_rate {
|
||||||
|
// Try to get audio properties from MPV
|
||||||
|
let sample_rate = self.mpv.get_sample_rate().ok().flatten();
|
||||||
|
let bit_depth = self.mpv.get_bit_depth().ok().flatten();
|
||||||
|
let format = self.mpv.get_audio_format().ok().flatten();
|
||||||
|
let channels = self.mpv.get_channels().ok().flatten();
|
||||||
|
|
||||||
|
// Only update if we got a valid sample rate (indicates audio is ready)
|
||||||
|
if let Some(rate) = sample_rate {
|
||||||
|
// Only switch PipeWire sample rate if it's actually different
|
||||||
|
// This avoids unnecessary rate switches during gapless playback
|
||||||
|
// of albums with the same sample rate
|
||||||
|
let current_pw_rate = self.pipewire.get_current_rate();
|
||||||
|
if current_pw_rate != Some(rate) {
|
||||||
|
info!("Sample rate change: {:?} -> {} Hz", current_pw_rate, rate);
|
||||||
|
if let Err(e) = self.pipewire.set_rate(rate) {
|
||||||
|
warn!("Failed to set PipeWire sample rate: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Sample rate unchanged at {} Hz, skipping PipeWire switch",
|
||||||
|
rate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.sample_rate = Some(rate);
|
||||||
|
state.now_playing.bit_depth = bit_depth;
|
||||||
|
state.now_playing.format = format;
|
||||||
|
state.now_playing.channels = channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update MPRIS properties to keep external clients in sync
|
||||||
|
if let Some(ref server) = self.mpris_server {
|
||||||
|
if let Err(e) = update_mpris_properties(server, &self.state).await {
|
||||||
|
debug!("Failed to update MPRIS properties: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle play/pause
|
||||||
|
pub(super) async fn toggle_pause(&mut self) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let is_playing = state.now_playing.state == PlaybackState::Playing;
|
||||||
|
let is_paused = state.now_playing.state == PlaybackState::Paused;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if !is_playing && !is_paused {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.mpv.toggle_pause() {
|
||||||
|
Ok(now_paused) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
if now_paused {
|
||||||
|
state.now_playing.state = PlaybackState::Paused;
|
||||||
|
debug!("Paused playback");
|
||||||
|
} else {
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
debug!("Resumed playback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to toggle pause: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause playback (only if currently playing)
|
||||||
|
pub(super) async fn pause_playback(&mut self) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if state.now_playing.state != PlaybackState::Playing {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
match self.mpv.pause() {
|
||||||
|
Ok(()) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.state = PlaybackState::Paused;
|
||||||
|
debug!("Paused playback");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to pause: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume playback (only if currently paused)
|
||||||
|
pub(super) async fn resume_playback(&mut self) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if state.now_playing.state != PlaybackState::Paused {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
match self.mpv.resume() {
|
||||||
|
Ok(()) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
debug!("Resumed playback");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to resume: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play next track in queue
|
||||||
|
pub(super) async fn next_track(&mut self) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let queue_len = state.queue.len();
|
||||||
|
let current_pos = state.queue_position;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if queue_len == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_pos = match current_pos {
|
||||||
|
Some(pos) if pos + 1 < queue_len => pos + 1,
|
||||||
|
_ => {
|
||||||
|
info!("Reached end of queue");
|
||||||
|
let _ = self.mpv.stop();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.state = PlaybackState::Stopped;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.play_queue_position(next_pos).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play previous track in queue (or restart current if < 3 seconds in)
|
||||||
|
pub(super) async fn prev_track(&mut self) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let queue_len = state.queue.len();
|
||||||
|
let current_pos = state.queue_position;
|
||||||
|
let position = state.now_playing.position;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if queue_len == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if position < 3.0 {
|
||||||
|
if let Some(pos) = current_pos {
|
||||||
|
if pos > 0 {
|
||||||
|
return self.play_queue_position(pos - 1).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.seek(0.0) {
|
||||||
|
error!("Failed to restart track: {}", e);
|
||||||
|
} else {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Restarting current track (position: {:.1}s)", position);
|
||||||
|
if let Err(e) = self.mpv.seek(0.0) {
|
||||||
|
error!("Failed to restart track: {}", e);
|
||||||
|
} else {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a specific position in the queue
|
||||||
|
pub(super) async fn play_queue_position(&mut self, pos: usize) -> Result<(), Error> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let song = match state.queue.get(pos) {
|
||||||
|
Some(s) => s.clone(),
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
let stream_url = if let Some(ref client) = self.subsonic {
|
||||||
|
match client.get_stream_url(&song.id) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get stream URL: {}", e);
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("Failed to get stream URL: {}", e));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.queue_position = Some(pos);
|
||||||
|
state.now_playing.song = Some(song.clone());
|
||||||
|
state.now_playing.state = PlaybackState::Playing;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Playing: {} (queue pos {})", song.title, pos);
|
||||||
|
if self.mpv.is_paused().unwrap_or(false) {
|
||||||
|
let _ = self.mpv.resume();
|
||||||
|
}
|
||||||
|
if let Err(e) = self.mpv.loadfile(&stream_url) {
|
||||||
|
error!("Failed to play: {}", e);
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.notify_error(format!("MPV error: {}", e));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.preload_next_track(pos).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-load the next track into MPV's playlist for gapless playback
|
||||||
|
pub(super) async fn preload_next_track(&mut self, current_pos: usize) {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let next_pos = current_pos + 1;
|
||||||
|
|
||||||
|
if next_pos >= state.queue.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_song = match state.queue.get(next_pos) {
|
||||||
|
Some(s) => s.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(ref client) = self.subsonic {
|
||||||
|
if let Ok(url) = client.get_stream_url(&next_song.id) {
|
||||||
|
debug!("Pre-loading next track for gapless: {}", next_song.title);
|
||||||
|
if let Err(e) = self.mpv.loadfile_append(&url) {
|
||||||
|
debug!("Failed to pre-load next track: {}", e);
|
||||||
|
} else if let Ok(count) = self.mpv.get_playlist_count() {
|
||||||
|
if count < 2 {
|
||||||
|
warn!(
|
||||||
|
"Preload may have failed: playlist count is {} (expected 2)",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!("Preload confirmed: playlist count is {}", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and clear the queue
|
||||||
|
pub(super) async fn stop_playback(&mut self) -> Result<(), Error> {
|
||||||
|
if let Err(e) = self.mpv.stop() {
|
||||||
|
error!("Failed to stop: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.now_playing.state = PlaybackState::Stopped;
|
||||||
|
state.now_playing.song = None;
|
||||||
|
state.now_playing.position = 0.0;
|
||||||
|
state.now_playing.duration = 0.0;
|
||||||
|
state.now_playing.sample_rate = None;
|
||||||
|
state.now_playing.bit_depth = None;
|
||||||
|
state.now_playing.format = None;
|
||||||
|
state.now_playing.channels = None;
|
||||||
|
state.queue.clear();
|
||||||
|
state.queue_position = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user