Compare commits
7 Commits
bd8f8e6302
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d17ea748f6 | |||
| fb0786122e | |||
| 763e9bc8db | |||
| 112f18582a | |||
| 766614f5e9 | |||
| 7582937439 | |||
| b94c12a301 |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -632,7 +632,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferrosonic"
|
name = "ferrosonic"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -647,6 +647,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ferrosonic"
|
name = "ferrosonic"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "A terminal-based Subsonic music client with bit-perfect audio playback"
|
description = "A terminal-based Subsonic music client with bit-perfect audio playback"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -60,3 +60,6 @@ vt100 = "0.15"
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.24.0"
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
//! Application actions and message passing
|
//! Application actions and message passing
|
||||||
|
|
||||||
use crate::subsonic::models::{Album, Artist, Child, Playlist};
|
|
||||||
|
|
||||||
/// Actions that can be sent to the audio backend
|
/// Actions that can be sent to the audio backend
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AudioAction {
|
pub enum AudioAction {
|
||||||
/// Play a specific song by URL
|
|
||||||
Play { url: String, song: Child },
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
Pause,
|
Pause,
|
||||||
/// Resume playback
|
/// Resume playback
|
||||||
@@ -26,86 +22,3 @@ pub enum AudioAction {
|
|||||||
/// Set volume (0-100)
|
/// Set volume (0-100)
|
||||||
SetVolume(i32),
|
SetVolume(i32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actions that can be sent to update the UI
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum UiAction {
|
|
||||||
/// Update playback position
|
|
||||||
UpdatePosition { position: f64, duration: f64 },
|
|
||||||
/// Update playback state
|
|
||||||
UpdatePlaybackState(PlaybackStateUpdate),
|
|
||||||
/// Update audio properties
|
|
||||||
UpdateAudioProperties {
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
bit_depth: Option<u32>,
|
|
||||||
format: Option<String>,
|
|
||||||
},
|
|
||||||
/// Track ended (EOF from MPV)
|
|
||||||
TrackEnded,
|
|
||||||
/// Show notification
|
|
||||||
Notify { message: String, is_error: bool },
|
|
||||||
/// Artists loaded from server
|
|
||||||
ArtistsLoaded(Vec<Artist>),
|
|
||||||
/// Albums loaded for an artist
|
|
||||||
AlbumsLoaded {
|
|
||||||
artist_id: String,
|
|
||||||
albums: Vec<Album>,
|
|
||||||
},
|
|
||||||
/// Songs loaded for an album
|
|
||||||
SongsLoaded { album_id: String, songs: Vec<Child> },
|
|
||||||
/// Playlists loaded from server
|
|
||||||
PlaylistsLoaded(Vec<Playlist>),
|
|
||||||
/// Playlist songs loaded
|
|
||||||
PlaylistSongsLoaded {
|
|
||||||
playlist_id: String,
|
|
||||||
songs: Vec<Child>,
|
|
||||||
},
|
|
||||||
/// Server connection test result
|
|
||||||
ConnectionTestResult { success: bool, message: String },
|
|
||||||
/// Force redraw
|
|
||||||
Redraw,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Playback state update
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub enum PlaybackStateUpdate {
|
|
||||||
Playing,
|
|
||||||
Paused,
|
|
||||||
Stopped,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Actions for the Subsonic client
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SubsonicAction {
|
|
||||||
/// Fetch all artists
|
|
||||||
FetchArtists,
|
|
||||||
/// Fetch albums for an artist
|
|
||||||
FetchAlbums { artist_id: String },
|
|
||||||
/// Fetch songs for an album
|
|
||||||
FetchAlbum { album_id: String },
|
|
||||||
/// Fetch all playlists
|
|
||||||
FetchPlaylists,
|
|
||||||
/// Fetch songs in a playlist
|
|
||||||
FetchPlaylist { playlist_id: String },
|
|
||||||
/// Test server connection
|
|
||||||
TestConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Queue manipulation actions
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum QueueAction {
|
|
||||||
/// Append songs to queue
|
|
||||||
Append(Vec<Child>),
|
|
||||||
/// Insert songs after current position
|
|
||||||
InsertNext(Vec<Child>),
|
|
||||||
/// Clear the queue
|
|
||||||
Clear,
|
|
||||||
/// Remove song at index
|
|
||||||
Remove(usize),
|
|
||||||
/// Move song from one index to another
|
|
||||||
Move { from: usize, to: usize },
|
|
||||||
/// Shuffle the queue (keeping current song in place)
|
|
||||||
Shuffle,
|
|
||||||
/// Play song at queue index
|
|
||||||
PlayIndex(usize),
|
|
||||||
}
|
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/app/input_artists.rs
Normal file
328
src/app/input_artists.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
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.to_vec();
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
2314
src/app/mod.rs
2314
src/app/mod.rs
File diff suppressed because it is too large
Load Diff
246
src/app/mouse.rs
Normal file
246
src/app/mouse.rs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
use crossterm::event::{self, MouseButton, MouseEventKind};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2;
|
||||||
|
if y == inner_bottom && duration > 0.0 {
|
||||||
|
let inner_x_start = layout.now_playing.x + 1;
|
||||||
|
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;
|
||||||
|
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 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.is_some_and(|(_, 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 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/app/mouse_artists.rs
Normal file
196
src/app/mouse_artists.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle click on artists page
|
||||||
|
pub(super) 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.is_some_and(|(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.is_some_and(|(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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/app/mouse_playlists.rs
Normal file
89
src/app/mouse_playlists.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle click on playlists page
|
||||||
|
pub(super) 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.is_some_and(|(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.is_some_and(|(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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,16 +32,6 @@ impl Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_index(index: usize) -> Self {
|
|
||||||
match index {
|
|
||||||
0 => Page::Artists,
|
|
||||||
1 => Page::Queue,
|
|
||||||
2 => Page::Playlists,
|
|
||||||
3 => Page::Server,
|
|
||||||
4 => Page::Settings,
|
|
||||||
_ => Page::Artists,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(&self) -> &'static str {
|
pub fn label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -205,6 +195,8 @@ pub struct SettingsState {
|
|||||||
pub theme_index: usize,
|
pub theme_index: usize,
|
||||||
/// Cava visualizer enabled
|
/// Cava visualizer enabled
|
||||||
pub cava_enabled: bool,
|
pub cava_enabled: bool,
|
||||||
|
/// Cava visualizer height percentage (10-80, step 5)
|
||||||
|
pub cava_size: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SettingsState {
|
impl Default for SettingsState {
|
||||||
@@ -214,6 +206,7 @@ impl Default for SettingsState {
|
|||||||
themes: vec![ThemeData::default_theme()],
|
themes: vec![ThemeData::default_theme()],
|
||||||
theme_index: 0,
|
theme_index: 0,
|
||||||
cava_enabled: false,
|
cava_enabled: false,
|
||||||
|
cava_size: 40,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,10 +262,8 @@ pub struct Notification {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct LayoutAreas {
|
pub struct LayoutAreas {
|
||||||
pub header: Rect,
|
pub header: Rect,
|
||||||
pub cava: Option<Rect>,
|
|
||||||
pub content: Rect,
|
pub content: Rect,
|
||||||
pub now_playing: Rect,
|
pub now_playing: Rect,
|
||||||
pub footer: Rect,
|
|
||||||
/// Left pane for dual-pane pages (Artists tree, Playlists list)
|
/// Left pane for dual-pane pages (Artists tree, Playlists list)
|
||||||
pub content_left: Option<Rect>,
|
pub content_left: Option<Rect>,
|
||||||
/// Right pane for dual-pane pages (Songs list)
|
/// Right pane for dual-pane pages (Songs list)
|
||||||
@@ -349,6 +340,7 @@ impl AppState {
|
|||||||
state.server_state.password = config.password.clone();
|
state.server_state.password = config.password.clone();
|
||||||
// Initialize cava from config
|
// Initialize cava from config
|
||||||
state.settings_state.cava_enabled = config.cava;
|
state.settings_state.cava_enabled = config.cava;
|
||||||
|
state.settings_state.cava_size = config.cava_size.clamp(10, 80);
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
//! Audio playback module
|
//! Audio playback module
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
pub mod mpv;
|
pub mod mpv;
|
||||||
pub mod pipewire;
|
pub mod pipewire;
|
||||||
pub mod queue;
|
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ struct MpvResponse {
|
|||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPV event
|
/// MPV event (used for deserialization and debug tracing)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)] // Fields populated by deserialization, read via Debug
|
||||||
struct MpvEvent {
|
struct MpvEvent {
|
||||||
event: String,
|
event: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -42,27 +43,6 @@ struct MpvEvent {
|
|||||||
data: Option<Value>,
|
data: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emitted by MPV
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum MpvEvent2 {
|
|
||||||
/// Track reached end of file
|
|
||||||
EndFile,
|
|
||||||
/// Playback paused
|
|
||||||
Pause,
|
|
||||||
/// Playback resumed
|
|
||||||
Unpause,
|
|
||||||
/// Position changed (time in seconds)
|
|
||||||
TimePos(f64),
|
|
||||||
/// Audio properties changed
|
|
||||||
AudioProperties {
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
bit_depth: Option<u32>,
|
|
||||||
format: Option<String>,
|
|
||||||
},
|
|
||||||
/// MPV shut down
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MPV controller
|
/// MPV controller
|
||||||
pub struct MpvController {
|
pub struct MpvController {
|
||||||
/// Path to the IPC socket
|
/// Path to the IPC socket
|
||||||
@@ -217,13 +197,6 @@ impl MpvController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the playlist except current track
|
|
||||||
pub fn playlist_clear(&mut self) -> Result<(), AudioError> {
|
|
||||||
debug!("Clearing playlist");
|
|
||||||
self.send_command(vec![json!("playlist-clear")])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a specific entry from the playlist by index
|
/// Remove a specific entry from the playlist by index
|
||||||
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
|
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
|
||||||
debug!("Removing playlist entry {}", index);
|
debug!("Removing playlist entry {}", index);
|
||||||
@@ -307,15 +280,6 @@ impl MpvController {
|
|||||||
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
|
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get volume (0-100)
|
|
||||||
pub fn get_volume(&mut self) -> Result<i32, AudioError> {
|
|
||||||
let data = self.send_command(vec![json!("get_property"), json!("volume")])?;
|
|
||||||
Ok(data
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.map(|v| v as i32)
|
|
||||||
.unwrap_or(100))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set volume (0-100)
|
/// Set volume (0-100)
|
||||||
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
|
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
|
||||||
debug!("Setting volume to {}", volume);
|
debug!("Setting volume to {}", volume);
|
||||||
@@ -378,24 +342,12 @@ impl MpvController {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current filename/URL
|
|
||||||
pub fn get_path(&mut self) -> Result<Option<String>, AudioError> {
|
|
||||||
let data = self.send_command(vec![json!("get_property"), json!("path")])?;
|
|
||||||
Ok(data.and_then(|v| v.as_str().map(String::from)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if anything is loaded
|
/// Check if anything is loaded
|
||||||
pub fn is_idle(&mut self) -> Result<bool, AudioError> {
|
pub fn is_idle(&mut self) -> Result<bool, AudioError> {
|
||||||
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
|
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
|
||||||
Ok(data.and_then(|v| v.as_bool()).unwrap_or(true))
|
Ok(data.and_then(|v| v.as_bool()).unwrap_or(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if current file has reached EOF
|
|
||||||
pub fn is_eof(&mut self) -> Result<bool, AudioError> {
|
|
||||||
let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?;
|
|
||||||
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quit MPV
|
/// Quit MPV
|
||||||
pub fn quit(&mut self) -> Result<(), AudioError> {
|
pub fn quit(&mut self) -> Result<(), AudioError> {
|
||||||
if self.socket.is_some() {
|
if self.socket.is_some() {
|
||||||
@@ -414,11 +366,6 @@ impl MpvController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observe a property for changes
|
|
||||||
pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> {
|
|
||||||
self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for MpvController {
|
impl Drop for MpvController {
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ use tracing::{debug, error, info};
|
|||||||
|
|
||||||
use crate::error::AudioError;
|
use crate::error::AudioError;
|
||||||
|
|
||||||
/// Default audio device ID for PipeWire
|
|
||||||
const DEFAULT_DEVICE_ID: u32 = 0;
|
|
||||||
|
|
||||||
/// PipeWire sample rate controller
|
/// PipeWire sample rate controller
|
||||||
pub struct PipeWireController {
|
pub struct PipeWireController {
|
||||||
/// Original sample rate before ferrosonic started
|
/// Original sample rate before ferrosonic started
|
||||||
@@ -133,47 +130,6 @@ impl PipeWireController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if PipeWire is available
|
|
||||||
pub fn is_available() -> bool {
|
|
||||||
Command::new("pw-metadata")
|
|
||||||
.arg("--version")
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the effective sample rate (from pw-metadata or system default)
|
|
||||||
pub fn get_effective_rate() -> Option<u32> {
|
|
||||||
// Try to get from PipeWire
|
|
||||||
let output = Command::new("pw-metadata")
|
|
||||||
.arg("-n")
|
|
||||||
.arg("settings")
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
|
|
||||||
// Look for clock.rate or clock.force-rate
|
|
||||||
for line in stdout.lines() {
|
|
||||||
if (line.contains("clock.rate") || line.contains("clock.force-rate"))
|
|
||||||
&& line.contains("value:")
|
|
||||||
{
|
|
||||||
if let Some(start) = line.find("value:'") {
|
|
||||||
let rest = &line[start + 7..];
|
|
||||||
if let Some(end) = rest.find('\'') {
|
|
||||||
let rate_str = &rest[..end];
|
|
||||||
if let Ok(rate) = rate_str.parse::<u32>() {
|
|
||||||
if rate > 0 {
|
|
||||||
return Some(rate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PipeWireController {
|
impl Default for PipeWireController {
|
||||||
@@ -190,13 +146,3 @@ impl Drop for PipeWireController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_available() {
|
|
||||||
// This test just checks the function doesn't panic
|
|
||||||
let _ = PipeWireController::is_available();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
//! Play queue management
|
|
||||||
|
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
use crate::subsonic::models::Child;
|
|
||||||
use crate::subsonic::SubsonicClient;
|
|
||||||
|
|
||||||
/// Play queue
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PlayQueue {
|
|
||||||
/// Songs in the queue
|
|
||||||
songs: Vec<Child>,
|
|
||||||
/// Current position in the queue (None = stopped)
|
|
||||||
position: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlayQueue {
|
|
||||||
/// Create a new empty queue
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the songs in the queue
|
|
||||||
pub fn songs(&self) -> &[Child] {
|
|
||||||
&self.songs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current position
|
|
||||||
pub fn position(&self) -> Option<usize> {
|
|
||||||
self.position
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current song
|
|
||||||
pub fn current(&self) -> Option<&Child> {
|
|
||||||
self.position.and_then(|pos| self.songs.get(pos))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get number of songs in queue
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.songs.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if queue is empty
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.songs.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add songs to the end of the queue
|
|
||||||
pub fn append(&mut self, songs: impl IntoIterator<Item = Child>) {
|
|
||||||
self.songs.extend(songs);
|
|
||||||
debug!("Queue now has {} songs", self.songs.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert songs after the current position
|
|
||||||
pub fn insert_next(&mut self, songs: impl IntoIterator<Item = Child>) {
|
|
||||||
let insert_pos = self.position.map(|p| p + 1).unwrap_or(0);
|
|
||||||
let new_songs: Vec<_> = songs.into_iter().collect();
|
|
||||||
let count = new_songs.len();
|
|
||||||
|
|
||||||
for (i, song) in new_songs.into_iter().enumerate() {
|
|
||||||
self.songs.insert(insert_pos + i, song);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Inserted {} songs at position {}", count, insert_pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the queue
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.songs.clear();
|
|
||||||
self.position = None;
|
|
||||||
debug!("Queue cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove song at index
|
|
||||||
pub fn remove(&mut self, index: usize) -> Option<Child> {
|
|
||||||
if index >= self.songs.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let song = self.songs.remove(index);
|
|
||||||
|
|
||||||
// Adjust position if needed
|
|
||||||
if let Some(pos) = self.position {
|
|
||||||
if index < pos {
|
|
||||||
self.position = Some(pos - 1);
|
|
||||||
} else if index == pos {
|
|
||||||
// Removed current song
|
|
||||||
if self.songs.is_empty() {
|
|
||||||
self.position = None;
|
|
||||||
} else if pos >= self.songs.len() {
|
|
||||||
self.position = Some(self.songs.len() - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Removed song at index {}", index);
|
|
||||||
Some(song)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move song from one position to another
|
|
||||||
pub fn move_song(&mut self, from: usize, to: usize) {
|
|
||||||
if from >= self.songs.len() || to >= self.songs.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let song = self.songs.remove(from);
|
|
||||||
self.songs.insert(to, song);
|
|
||||||
|
|
||||||
// Adjust position if needed
|
|
||||||
if let Some(pos) = self.position {
|
|
||||||
if from == pos {
|
|
||||||
self.position = Some(to);
|
|
||||||
} else if from < pos && to >= pos {
|
|
||||||
self.position = Some(pos - 1);
|
|
||||||
} else if from > pos && to <= pos {
|
|
||||||
self.position = Some(pos + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Moved song from {} to {}", from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shuffle the queue, keeping current song in place
|
|
||||||
pub fn shuffle(&mut self) {
|
|
||||||
if self.songs.len() <= 1 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
|
|
||||||
if let Some(pos) = self.position {
|
|
||||||
// Keep current song, shuffle the rest
|
|
||||||
let current = self.songs.remove(pos);
|
|
||||||
|
|
||||||
// Shuffle remaining songs
|
|
||||||
self.songs.shuffle(&mut rng);
|
|
||||||
|
|
||||||
// Put current song at the front
|
|
||||||
self.songs.insert(0, current);
|
|
||||||
self.position = Some(0);
|
|
||||||
} else {
|
|
||||||
// No current song, shuffle everything
|
|
||||||
self.songs.shuffle(&mut rng);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Queue shuffled");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set current position
|
|
||||||
pub fn set_position(&mut self, position: Option<usize>) {
|
|
||||||
if let Some(pos) = position {
|
|
||||||
if pos < self.songs.len() {
|
|
||||||
self.position = Some(pos);
|
|
||||||
debug!("Position set to {}", pos);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.position = None;
|
|
||||||
debug!("Position cleared");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advance to next song
|
|
||||||
/// Returns true if there was a next song
|
|
||||||
pub fn next(&mut self) -> bool {
|
|
||||||
match self.position {
|
|
||||||
Some(pos) if pos + 1 < self.songs.len() => {
|
|
||||||
self.position = Some(pos + 1);
|
|
||||||
debug!("Advanced to position {}", pos + 1);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.position = None;
|
|
||||||
debug!("Reached end of queue");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Go to previous song
|
|
||||||
/// Returns true if there was a previous song
|
|
||||||
pub fn previous(&mut self) -> bool {
|
|
||||||
match self.position {
|
|
||||||
Some(pos) if pos > 0 => {
|
|
||||||
self.position = Some(pos - 1);
|
|
||||||
debug!("Went back to position {}", pos - 1);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !self.songs.is_empty() {
|
|
||||||
self.position = Some(0);
|
|
||||||
}
|
|
||||||
debug!("At start of queue");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get stream URL for current song
|
|
||||||
pub fn current_stream_url(&self, client: &SubsonicClient) -> Option<String> {
|
|
||||||
self.current()
|
|
||||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get stream URL for song at index
|
|
||||||
pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option<String> {
|
|
||||||
self.songs
|
|
||||||
.get(index)
|
|
||||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_song(id: &str, title: &str) -> Child {
|
|
||||||
Child {
|
|
||||||
id: id.to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
parent: None,
|
|
||||||
is_dir: false,
|
|
||||||
album: None,
|
|
||||||
artist: None,
|
|
||||||
track: None,
|
|
||||||
year: None,
|
|
||||||
genre: None,
|
|
||||||
cover_art: None,
|
|
||||||
size: None,
|
|
||||||
content_type: None,
|
|
||||||
suffix: None,
|
|
||||||
duration: None,
|
|
||||||
bit_rate: None,
|
|
||||||
path: None,
|
|
||||||
disc_number: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_append_and_len() {
|
|
||||||
let mut queue = PlayQueue::new();
|
|
||||||
assert!(queue.is_empty());
|
|
||||||
|
|
||||||
queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]);
|
|
||||||
assert_eq!(queue.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_position_and_navigation() {
|
|
||||||
let mut queue = PlayQueue::new();
|
|
||||||
queue.append(vec![
|
|
||||||
make_song("1", "Song 1"),
|
|
||||||
make_song("2", "Song 2"),
|
|
||||||
make_song("3", "Song 3"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert!(queue.current().is_none());
|
|
||||||
|
|
||||||
queue.set_position(Some(0));
|
|
||||||
assert_eq!(queue.current().unwrap().id, "1");
|
|
||||||
|
|
||||||
assert!(queue.next());
|
|
||||||
assert_eq!(queue.current().unwrap().id, "2");
|
|
||||||
|
|
||||||
assert!(queue.next());
|
|
||||||
assert_eq!(queue.current().unwrap().id, "3");
|
|
||||||
|
|
||||||
assert!(!queue.next());
|
|
||||||
assert!(queue.current().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove() {
|
|
||||||
let mut queue = PlayQueue::new();
|
|
||||||
queue.append(vec![
|
|
||||||
make_song("1", "Song 1"),
|
|
||||||
make_song("2", "Song 2"),
|
|
||||||
make_song("3", "Song 3"),
|
|
||||||
]);
|
|
||||||
queue.set_position(Some(1));
|
|
||||||
|
|
||||||
// Remove song before current
|
|
||||||
queue.remove(0);
|
|
||||||
assert_eq!(queue.position(), Some(0));
|
|
||||||
assert_eq!(queue.current().unwrap().id, "2");
|
|
||||||
|
|
||||||
// Remove current song
|
|
||||||
queue.remove(0);
|
|
||||||
assert_eq!(queue.current().unwrap().id, "3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_insert_next() {
|
|
||||||
let mut queue = PlayQueue::new();
|
|
||||||
queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]);
|
|
||||||
queue.set_position(Some(0));
|
|
||||||
|
|
||||||
queue.insert_next(vec![make_song("2", "Song 2")]);
|
|
||||||
|
|
||||||
assert_eq!(queue.songs[0].id, "1");
|
|
||||||
assert_eq!(queue.songs[1].id, "2");
|
|
||||||
assert_eq!(queue.songs[2].id, "3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_move_song() {
|
|
||||||
let mut queue = PlayQueue::new();
|
|
||||||
queue.append(vec![
|
|
||||||
make_song("1", "Song 1"),
|
|
||||||
make_song("2", "Song 2"),
|
|
||||||
make_song("3", "Song 3"),
|
|
||||||
]);
|
|
||||||
queue.set_position(Some(0));
|
|
||||||
|
|
||||||
queue.move_song(0, 2);
|
|
||||||
assert_eq!(queue.songs[0].id, "2");
|
|
||||||
assert_eq!(queue.songs[1].id, "3");
|
|
||||||
assert_eq!(queue.songs[2].id, "1");
|
|
||||||
assert_eq!(queue.position(), Some(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,9 +30,17 @@ pub struct Config {
|
|||||||
/// Enable cava audio visualizer
|
/// Enable cava audio visualizer
|
||||||
#[serde(rename = "Cava", default)]
|
#[serde(rename = "Cava", default)]
|
||||||
pub cava: bool,
|
pub cava: bool,
|
||||||
|
|
||||||
|
/// Cava visualizer height percentage (10-80, step 5)
|
||||||
|
#[serde(rename = "CavaSize", default = "Config::default_cava_size")]
|
||||||
|
pub cava_size: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
fn default_cava_size() -> u8 {
|
||||||
|
40
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new empty config
|
/// Create a new empty config
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! MPRIS2 D-Bus integration module
|
//! MPRIS2 D-Bus integration module
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
@@ -297,77 +297,23 @@ impl SubsonicClient {
|
|||||||
Ok(url.to_string())
|
Ok(url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse song ID from a stream URL
|
|
||||||
///
|
|
||||||
/// Useful for session restoration
|
|
||||||
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
|
|
||||||
let parsed = Url::parse(url).ok()?;
|
|
||||||
parsed
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(k, _)| k == "id")
|
|
||||||
.map(|(_, v)| v.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get cover art URL for a given cover art ID
|
|
||||||
pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result<String, SubsonicError> {
|
|
||||||
let (salt, token) = generate_auth_params(&self.password);
|
|
||||||
let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?;
|
|
||||||
|
|
||||||
url.query_pairs_mut()
|
|
||||||
.append_pair("id", cover_art_id)
|
|
||||||
.append_pair("u", &self.username)
|
|
||||||
.append_pair("t", &token)
|
|
||||||
.append_pair("s", &salt)
|
|
||||||
.append_pair("v", API_VERSION)
|
|
||||||
.append_pair("c", CLIENT_NAME);
|
|
||||||
|
|
||||||
Ok(url.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search for artists, albums, and songs
|
|
||||||
pub async fn search(
|
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
) -> Result<(Vec<Artist>, Vec<Album>, Vec<Child>), SubsonicError> {
|
|
||||||
let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?;
|
|
||||||
debug!("Searching: {}", query);
|
|
||||||
|
|
||||||
let response = self.http.get(url).send().await?;
|
|
||||||
let text = response.text().await?;
|
|
||||||
|
|
||||||
let parsed: SubsonicResponse<SearchResult3Data> = serde_json::from_str(&text)
|
|
||||||
.map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?;
|
|
||||||
|
|
||||||
if parsed.subsonic_response.status != "ok" {
|
|
||||||
if let Some(error) = parsed.subsonic_response.error {
|
|
||||||
return Err(SubsonicError::Api {
|
|
||||||
code: error.code,
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = parsed
|
|
||||||
.subsonic_response
|
|
||||||
.data
|
|
||||||
.ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))?
|
|
||||||
.search_result3;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Search found {} artists, {} albums, {} songs",
|
|
||||||
result.artist.len(),
|
|
||||||
result.album.len(),
|
|
||||||
result.song.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((result.artist, result.album, result.song))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
impl SubsonicClient {
|
||||||
|
/// Parse song ID from a stream URL
|
||||||
|
fn parse_song_id_from_url(url: &str) -> Option<String> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "id")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_song_id() {
|
fn test_parse_song_id() {
|
||||||
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";
|
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Subsonic API client module
|
//! Subsonic API client module
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct SubsonicResponse<T> {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SubsonicResponseInner<T> {
|
pub struct SubsonicResponseInner<T> {
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
#[allow(dead_code)] // Present in API response, needed for deserialization
|
||||||
pub version: String,
|
pub version: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub error: Option<ApiError>,
|
pub error: Option<ApiError>,
|
||||||
@@ -40,6 +41,7 @@ pub struct ArtistsIndex {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ArtistIndex {
|
pub struct ArtistIndex {
|
||||||
|
#[allow(dead_code)] // Present in API response, needed for deserialization
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub artist: Vec<Artist>,
|
pub artist: Vec<Artist>,
|
||||||
@@ -217,19 +219,3 @@ pub struct PlaylistDetail {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PingData {}
|
pub struct PingData {}
|
||||||
|
|
||||||
/// Search result
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SearchResult3Data {
|
|
||||||
#[serde(rename = "searchResult3")]
|
|
||||||
pub search_result3: SearchResult3,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SearchResult3 {
|
|
||||||
#[serde(default)]
|
|
||||||
pub artist: Vec<Artist>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub album: Vec<Album>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub song: Vec<Child>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,13 +38,11 @@ impl Widget for Header {
|
|||||||
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||||
|
|
||||||
// Page tabs
|
// Page tabs
|
||||||
let titles: Vec<Line> = vec![
|
let titles: Vec<Line> = [Page::Artists,
|
||||||
Page::Artists,
|
|
||||||
Page::Queue,
|
Page::Queue,
|
||||||
Page::Playlists,
|
Page::Playlists,
|
||||||
Page::Server,
|
Page::Server,
|
||||||
Page::Settings,
|
Page::Settings]
|
||||||
]
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
|
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
|||||||
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
|
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Length(1), // Header
|
Constraint::Length(1), // Header
|
||||||
Constraint::Percentage(40), // Cava visualizer — top half-ish
|
Constraint::Percentage(state.settings_state.cava_size as u16), // Cava visualizer
|
||||||
Constraint::Min(10), // Page content
|
Constraint::Min(10), // Page content
|
||||||
Constraint::Length(7), // Now playing
|
Constraint::Length(7), // Now playing
|
||||||
Constraint::Length(1), // Footer
|
Constraint::Length(1), // Footer
|
||||||
@@ -62,10 +62,8 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
|||||||
// Store layout areas for mouse hit-testing
|
// Store layout areas for mouse hit-testing
|
||||||
state.layout = LayoutAreas {
|
state.layout = LayoutAreas {
|
||||||
header: header_area,
|
header: header_area,
|
||||||
cava: cava_area,
|
|
||||||
content: content_area,
|
content: content_area,
|
||||||
now_playing: now_playing_area,
|
now_playing: now_playing_area,
|
||||||
footer: footer_area,
|
|
||||||
content_left,
|
content_left,
|
||||||
content_right,
|
content_right,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod header;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
mod theme_builtins;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|
||||||
pub use layout::draw;
|
pub use layout::draw;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
|
|||||||
// If expanded, add albums sorted by year (oldest first)
|
// If expanded, add albums sorted by year (oldest first)
|
||||||
if is_expanded {
|
if is_expanded {
|
||||||
if let Some(albums) = artists.albums_cache.get(&artist.id) {
|
if let Some(albums) = artists.albums_cache.get(&artist.id) {
|
||||||
let mut sorted_albums: Vec<Album> = albums.iter().cloned().collect();
|
let mut sorted_albums: Vec<Album> = albums.to_vec();
|
||||||
sorted_albums.sort_by(|a, b| {
|
sorted_albums.sort_by(|a, b| {
|
||||||
// Albums with no year go last
|
// Albums with no year go last
|
||||||
match (a.year, b.year) {
|
match (a.year, b.year) {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Constraint::Length(2), // Theme selector
|
Constraint::Length(2), // Theme selector
|
||||||
Constraint::Length(1), // Spacing
|
Constraint::Length(1), // Spacing
|
||||||
Constraint::Length(2), // Cava toggle
|
Constraint::Length(2), // Cava toggle
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Length(2), // Cava size
|
||||||
Constraint::Min(1), // Remaining space
|
Constraint::Min(1), // Remaining space
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
@@ -66,11 +68,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
&colors,
|
&colors,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cava size (field 2)
|
||||||
|
let cava_size_value = if !state.cava_available {
|
||||||
|
"N/A (cava not found)".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}%", settings.cava_size)
|
||||||
|
};
|
||||||
|
|
||||||
|
render_option(
|
||||||
|
frame,
|
||||||
|
chunks[5],
|
||||||
|
"Cava Size",
|
||||||
|
&cava_size_value,
|
||||||
|
settings.selected_field == 2,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
// Help text at bottom
|
// Help text at bottom
|
||||||
let help_text = match settings.selected_field {
|
let help_text = match settings.selected_field {
|
||||||
0 => "← → or Enter to change theme (auto-saves)",
|
0 => "← → or Enter to change theme (auto-saves)",
|
||||||
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
|
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
|
||||||
1 => "cava is not installed on this system",
|
1 => "cava is not installed on this system",
|
||||||
|
2 if state.cava_available => "← → to adjust cava size (10%-80%, auto-saves)",
|
||||||
|
2 => "cava is not installed on this system",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
|
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
|
||||||
|
|||||||
270
src/ui/theme.rs
270
src/ui/theme.rs
@@ -215,7 +215,7 @@ pub fn load_themes() -> Vec<ThemeData> {
|
|||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
e.path()
|
e.path()
|
||||||
.extension()
|
.extension()
|
||||||
.map_or(false, |ext| ext == "toml")
|
.is_some_and(|ext| ext == "toml")
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by_key(|e| e.file_name());
|
entries.sort_by_key(|e| e.file_name());
|
||||||
@@ -248,7 +248,7 @@ pub fn load_themes() -> Vec<ThemeData> {
|
|||||||
|
|
||||||
/// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine"
|
/// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine"
|
||||||
fn titlecase_filename(s: &str) -> String {
|
fn titlecase_filename(s: &str) -> String {
|
||||||
s.split(|c: char| c == '-' || c == '_')
|
s.split(['-', '_'])
|
||||||
.filter(|w| !w.is_empty())
|
.filter(|w| !w.is_empty())
|
||||||
.map(|word| {
|
.map(|word| {
|
||||||
let mut chars = word.chars();
|
let mut chars = word.chars();
|
||||||
@@ -286,268 +286,4 @@ pub fn seed_default_themes(dir: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILTIN_THEMES: &[(&str, &str)] = &[
|
use super::theme_builtins::BUILTIN_THEMES;
|
||||||
("monokai.toml", r##"[colors]
|
|
||||||
primary = "#a6e22e"
|
|
||||||
secondary = "#75715e"
|
|
||||||
accent = "#fd971f"
|
|
||||||
artist = "#a6e22e"
|
|
||||||
album = "#f92672"
|
|
||||||
song = "#e6db74"
|
|
||||||
muted = "#75715e"
|
|
||||||
highlight_bg = "#49483e"
|
|
||||||
highlight_fg = "#f8f8f2"
|
|
||||||
success = "#a6e22e"
|
|
||||||
error = "#f92672"
|
|
||||||
playing = "#fd971f"
|
|
||||||
played = "#75715e"
|
|
||||||
border_focused = "#a6e22e"
|
|
||||||
border_unfocused = "#49483e"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
|
|
||||||
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("dracula.toml", r##"[colors]
|
|
||||||
primary = "#bd93f9"
|
|
||||||
secondary = "#6272a4"
|
|
||||||
accent = "#ffb86c"
|
|
||||||
artist = "#50fa7b"
|
|
||||||
album = "#ff79c6"
|
|
||||||
song = "#8be9fd"
|
|
||||||
muted = "#6272a4"
|
|
||||||
highlight_bg = "#44475a"
|
|
||||||
highlight_fg = "#f8f8f2"
|
|
||||||
success = "#50fa7b"
|
|
||||||
error = "#ff5555"
|
|
||||||
playing = "#ffb86c"
|
|
||||||
played = "#6272a4"
|
|
||||||
border_focused = "#bd93f9"
|
|
||||||
border_unfocused = "#44475a"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
|
|
||||||
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("nord.toml", r##"[colors]
|
|
||||||
primary = "#88c0d0"
|
|
||||||
secondary = "#4c566a"
|
|
||||||
accent = "#ebcb8b"
|
|
||||||
artist = "#a3be8c"
|
|
||||||
album = "#b48ead"
|
|
||||||
song = "#88c0d0"
|
|
||||||
muted = "#4c566a"
|
|
||||||
highlight_bg = "#434c5e"
|
|
||||||
highlight_fg = "#eceff4"
|
|
||||||
success = "#a3be8c"
|
|
||||||
error = "#bf616a"
|
|
||||||
playing = "#ebcb8b"
|
|
||||||
played = "#4c566a"
|
|
||||||
border_focused = "#88c0d0"
|
|
||||||
border_unfocused = "#3b4252"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
|
|
||||||
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("gruvbox.toml", r##"[colors]
|
|
||||||
primary = "#d79921"
|
|
||||||
secondary = "#928374"
|
|
||||||
accent = "#fe8019"
|
|
||||||
artist = "#b8bb26"
|
|
||||||
album = "#d3869b"
|
|
||||||
song = "#83a598"
|
|
||||||
muted = "#928374"
|
|
||||||
highlight_bg = "#504945"
|
|
||||||
highlight_fg = "#ebdbb2"
|
|
||||||
success = "#b8bb26"
|
|
||||||
error = "#fb4934"
|
|
||||||
playing = "#fe8019"
|
|
||||||
played = "#928374"
|
|
||||||
border_focused = "#d79921"
|
|
||||||
border_unfocused = "#3c3836"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
|
|
||||||
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("catppuccin.toml", r##"[colors]
|
|
||||||
primary = "#89b4fa"
|
|
||||||
secondary = "#585b70"
|
|
||||||
accent = "#f9e2af"
|
|
||||||
artist = "#a6e3a1"
|
|
||||||
album = "#f5c2e7"
|
|
||||||
song = "#94e2d5"
|
|
||||||
muted = "#6c7086"
|
|
||||||
highlight_bg = "#45475a"
|
|
||||||
highlight_fg = "#cdd6f4"
|
|
||||||
success = "#a6e3a1"
|
|
||||||
error = "#f38ba8"
|
|
||||||
playing = "#f9e2af"
|
|
||||||
played = "#6c7086"
|
|
||||||
border_focused = "#89b4fa"
|
|
||||||
border_unfocused = "#45475a"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
|
|
||||||
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("solarized.toml", r##"[colors]
|
|
||||||
primary = "#268bd2"
|
|
||||||
secondary = "#586e75"
|
|
||||||
accent = "#b58900"
|
|
||||||
artist = "#859900"
|
|
||||||
album = "#d33682"
|
|
||||||
song = "#2aa198"
|
|
||||||
muted = "#586e75"
|
|
||||||
highlight_bg = "#073642"
|
|
||||||
highlight_fg = "#eee8d5"
|
|
||||||
success = "#859900"
|
|
||||||
error = "#dc322f"
|
|
||||||
playing = "#b58900"
|
|
||||||
played = "#586e75"
|
|
||||||
border_focused = "#268bd2"
|
|
||||||
border_unfocused = "#073642"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
|
|
||||||
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("tokyo-night.toml", r##"[colors]
|
|
||||||
primary = "#7aa2f7"
|
|
||||||
secondary = "#3d59a1"
|
|
||||||
accent = "#e0af68"
|
|
||||||
artist = "#9ece6a"
|
|
||||||
album = "#bb9af7"
|
|
||||||
song = "#7dcfff"
|
|
||||||
muted = "#565f89"
|
|
||||||
highlight_bg = "#292e42"
|
|
||||||
highlight_fg = "#c0caf5"
|
|
||||||
success = "#9ece6a"
|
|
||||||
error = "#f7768e"
|
|
||||||
playing = "#e0af68"
|
|
||||||
played = "#565f89"
|
|
||||||
border_focused = "#7aa2f7"
|
|
||||||
border_unfocused = "#292e42"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
|
|
||||||
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("rose-pine.toml", r##"[colors]
|
|
||||||
primary = "#c4a7e7"
|
|
||||||
secondary = "#6e6a86"
|
|
||||||
accent = "#f6c177"
|
|
||||||
artist = "#9ccfd8"
|
|
||||||
album = "#ebbcba"
|
|
||||||
song = "#31748f"
|
|
||||||
muted = "#6e6a86"
|
|
||||||
highlight_bg = "#393552"
|
|
||||||
highlight_fg = "#e0def4"
|
|
||||||
success = "#9ccfd8"
|
|
||||||
error = "#eb6f92"
|
|
||||||
playing = "#f6c177"
|
|
||||||
played = "#6e6a86"
|
|
||||||
border_focused = "#c4a7e7"
|
|
||||||
border_unfocused = "#393552"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
|
|
||||||
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("everforest.toml", r##"[colors]
|
|
||||||
primary = "#a7c080"
|
|
||||||
secondary = "#859289"
|
|
||||||
accent = "#dbbc7f"
|
|
||||||
artist = "#83c092"
|
|
||||||
album = "#d699b6"
|
|
||||||
song = "#7fbbb3"
|
|
||||||
muted = "#859289"
|
|
||||||
highlight_bg = "#505851"
|
|
||||||
highlight_fg = "#d3c6aa"
|
|
||||||
success = "#a7c080"
|
|
||||||
error = "#e67e80"
|
|
||||||
playing = "#dbbc7f"
|
|
||||||
played = "#859289"
|
|
||||||
border_focused = "#a7c080"
|
|
||||||
border_unfocused = "#505851"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
|
|
||||||
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("kanagawa.toml", r##"[colors]
|
|
||||||
primary = "#7e9cd8"
|
|
||||||
secondary = "#54546d"
|
|
||||||
accent = "#e6c384"
|
|
||||||
artist = "#98bb6c"
|
|
||||||
album = "#957fb8"
|
|
||||||
song = "#7fb4ca"
|
|
||||||
muted = "#727169"
|
|
||||||
highlight_bg = "#363646"
|
|
||||||
highlight_fg = "#dcd7ba"
|
|
||||||
success = "#98bb6c"
|
|
||||||
error = "#ff5d62"
|
|
||||||
playing = "#e6c384"
|
|
||||||
played = "#727169"
|
|
||||||
border_focused = "#7e9cd8"
|
|
||||||
border_unfocused = "#363646"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
|
|
||||||
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("one-dark.toml", r##"[colors]
|
|
||||||
primary = "#61afef"
|
|
||||||
secondary = "#5c6370"
|
|
||||||
accent = "#e5c07b"
|
|
||||||
artist = "#98c379"
|
|
||||||
album = "#c678dd"
|
|
||||||
song = "#56b6c2"
|
|
||||||
muted = "#5c6370"
|
|
||||||
highlight_bg = "#3e4451"
|
|
||||||
highlight_fg = "#abb2bf"
|
|
||||||
success = "#98c379"
|
|
||||||
error = "#e06c75"
|
|
||||||
playing = "#e5c07b"
|
|
||||||
played = "#5c6370"
|
|
||||||
border_focused = "#61afef"
|
|
||||||
border_unfocused = "#3e4451"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
|
|
||||||
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
|
|
||||||
"##),
|
|
||||||
|
|
||||||
("ayu-dark.toml", r##"[colors]
|
|
||||||
primary = "#59c2ff"
|
|
||||||
secondary = "#6b788a"
|
|
||||||
accent = "#e6b450"
|
|
||||||
artist = "#aad94c"
|
|
||||||
album = "#d2a6ff"
|
|
||||||
song = "#95e6cb"
|
|
||||||
muted = "#6b788a"
|
|
||||||
highlight_bg = "#2f3846"
|
|
||||||
highlight_fg = "#bfc7d5"
|
|
||||||
success = "#aad94c"
|
|
||||||
error = "#f07178"
|
|
||||||
playing = "#e6b450"
|
|
||||||
played = "#6b788a"
|
|
||||||
border_focused = "#59c2ff"
|
|
||||||
border_unfocused = "#2f3846"
|
|
||||||
|
|
||||||
[cava]
|
|
||||||
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
|
|
||||||
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
|
|
||||||
"##),
|
|
||||||
];
|
|
||||||
|
|||||||
267
src/ui/theme_builtins.rs
Normal file
267
src/ui/theme_builtins.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
//! Built-in theme TOML definitions seeded into ~/.config/ferrosonic/themes/
|
||||||
|
|
||||||
|
pub(super) const BUILTIN_THEMES: &[(&str, &str)] = &[
|
||||||
|
("monokai.toml", r##"[colors]
|
||||||
|
primary = "#a6e22e"
|
||||||
|
secondary = "#75715e"
|
||||||
|
accent = "#fd971f"
|
||||||
|
artist = "#a6e22e"
|
||||||
|
album = "#f92672"
|
||||||
|
song = "#e6db74"
|
||||||
|
muted = "#75715e"
|
||||||
|
highlight_bg = "#49483e"
|
||||||
|
highlight_fg = "#f8f8f2"
|
||||||
|
success = "#a6e22e"
|
||||||
|
error = "#f92672"
|
||||||
|
playing = "#fd971f"
|
||||||
|
played = "#75715e"
|
||||||
|
border_focused = "#a6e22e"
|
||||||
|
border_unfocused = "#49483e"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
|
||||||
|
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("dracula.toml", r##"[colors]
|
||||||
|
primary = "#bd93f9"
|
||||||
|
secondary = "#6272a4"
|
||||||
|
accent = "#ffb86c"
|
||||||
|
artist = "#50fa7b"
|
||||||
|
album = "#ff79c6"
|
||||||
|
song = "#8be9fd"
|
||||||
|
muted = "#6272a4"
|
||||||
|
highlight_bg = "#44475a"
|
||||||
|
highlight_fg = "#f8f8f2"
|
||||||
|
success = "#50fa7b"
|
||||||
|
error = "#ff5555"
|
||||||
|
playing = "#ffb86c"
|
||||||
|
played = "#6272a4"
|
||||||
|
border_focused = "#bd93f9"
|
||||||
|
border_unfocused = "#44475a"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
|
||||||
|
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("nord.toml", r##"[colors]
|
||||||
|
primary = "#88c0d0"
|
||||||
|
secondary = "#4c566a"
|
||||||
|
accent = "#ebcb8b"
|
||||||
|
artist = "#a3be8c"
|
||||||
|
album = "#b48ead"
|
||||||
|
song = "#88c0d0"
|
||||||
|
muted = "#4c566a"
|
||||||
|
highlight_bg = "#434c5e"
|
||||||
|
highlight_fg = "#eceff4"
|
||||||
|
success = "#a3be8c"
|
||||||
|
error = "#bf616a"
|
||||||
|
playing = "#ebcb8b"
|
||||||
|
played = "#4c566a"
|
||||||
|
border_focused = "#88c0d0"
|
||||||
|
border_unfocused = "#3b4252"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
|
||||||
|
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("gruvbox.toml", r##"[colors]
|
||||||
|
primary = "#d79921"
|
||||||
|
secondary = "#928374"
|
||||||
|
accent = "#fe8019"
|
||||||
|
artist = "#b8bb26"
|
||||||
|
album = "#d3869b"
|
||||||
|
song = "#83a598"
|
||||||
|
muted = "#928374"
|
||||||
|
highlight_bg = "#504945"
|
||||||
|
highlight_fg = "#ebdbb2"
|
||||||
|
success = "#b8bb26"
|
||||||
|
error = "#fb4934"
|
||||||
|
playing = "#fe8019"
|
||||||
|
played = "#928374"
|
||||||
|
border_focused = "#d79921"
|
||||||
|
border_unfocused = "#3c3836"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
|
||||||
|
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("catppuccin.toml", r##"[colors]
|
||||||
|
primary = "#89b4fa"
|
||||||
|
secondary = "#585b70"
|
||||||
|
accent = "#f9e2af"
|
||||||
|
artist = "#a6e3a1"
|
||||||
|
album = "#f5c2e7"
|
||||||
|
song = "#94e2d5"
|
||||||
|
muted = "#6c7086"
|
||||||
|
highlight_bg = "#45475a"
|
||||||
|
highlight_fg = "#cdd6f4"
|
||||||
|
success = "#a6e3a1"
|
||||||
|
error = "#f38ba8"
|
||||||
|
playing = "#f9e2af"
|
||||||
|
played = "#6c7086"
|
||||||
|
border_focused = "#89b4fa"
|
||||||
|
border_unfocused = "#45475a"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
|
||||||
|
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("solarized.toml", r##"[colors]
|
||||||
|
primary = "#268bd2"
|
||||||
|
secondary = "#586e75"
|
||||||
|
accent = "#b58900"
|
||||||
|
artist = "#859900"
|
||||||
|
album = "#d33682"
|
||||||
|
song = "#2aa198"
|
||||||
|
muted = "#586e75"
|
||||||
|
highlight_bg = "#073642"
|
||||||
|
highlight_fg = "#eee8d5"
|
||||||
|
success = "#859900"
|
||||||
|
error = "#dc322f"
|
||||||
|
playing = "#b58900"
|
||||||
|
played = "#586e75"
|
||||||
|
border_focused = "#268bd2"
|
||||||
|
border_unfocused = "#073642"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
|
||||||
|
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("tokyo-night.toml", r##"[colors]
|
||||||
|
primary = "#7aa2f7"
|
||||||
|
secondary = "#3d59a1"
|
||||||
|
accent = "#e0af68"
|
||||||
|
artist = "#9ece6a"
|
||||||
|
album = "#bb9af7"
|
||||||
|
song = "#7dcfff"
|
||||||
|
muted = "#565f89"
|
||||||
|
highlight_bg = "#292e42"
|
||||||
|
highlight_fg = "#c0caf5"
|
||||||
|
success = "#9ece6a"
|
||||||
|
error = "#f7768e"
|
||||||
|
playing = "#e0af68"
|
||||||
|
played = "#565f89"
|
||||||
|
border_focused = "#7aa2f7"
|
||||||
|
border_unfocused = "#292e42"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
|
||||||
|
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("rose-pine.toml", r##"[colors]
|
||||||
|
primary = "#c4a7e7"
|
||||||
|
secondary = "#6e6a86"
|
||||||
|
accent = "#f6c177"
|
||||||
|
artist = "#9ccfd8"
|
||||||
|
album = "#ebbcba"
|
||||||
|
song = "#31748f"
|
||||||
|
muted = "#6e6a86"
|
||||||
|
highlight_bg = "#393552"
|
||||||
|
highlight_fg = "#e0def4"
|
||||||
|
success = "#9ccfd8"
|
||||||
|
error = "#eb6f92"
|
||||||
|
playing = "#f6c177"
|
||||||
|
played = "#6e6a86"
|
||||||
|
border_focused = "#c4a7e7"
|
||||||
|
border_unfocused = "#393552"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
|
||||||
|
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("everforest.toml", r##"[colors]
|
||||||
|
primary = "#a7c080"
|
||||||
|
secondary = "#859289"
|
||||||
|
accent = "#dbbc7f"
|
||||||
|
artist = "#83c092"
|
||||||
|
album = "#d699b6"
|
||||||
|
song = "#7fbbb3"
|
||||||
|
muted = "#859289"
|
||||||
|
highlight_bg = "#505851"
|
||||||
|
highlight_fg = "#d3c6aa"
|
||||||
|
success = "#a7c080"
|
||||||
|
error = "#e67e80"
|
||||||
|
playing = "#dbbc7f"
|
||||||
|
played = "#859289"
|
||||||
|
border_focused = "#a7c080"
|
||||||
|
border_unfocused = "#505851"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
|
||||||
|
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("kanagawa.toml", r##"[colors]
|
||||||
|
primary = "#7e9cd8"
|
||||||
|
secondary = "#54546d"
|
||||||
|
accent = "#e6c384"
|
||||||
|
artist = "#98bb6c"
|
||||||
|
album = "#957fb8"
|
||||||
|
song = "#7fb4ca"
|
||||||
|
muted = "#727169"
|
||||||
|
highlight_bg = "#363646"
|
||||||
|
highlight_fg = "#dcd7ba"
|
||||||
|
success = "#98bb6c"
|
||||||
|
error = "#ff5d62"
|
||||||
|
playing = "#e6c384"
|
||||||
|
played = "#727169"
|
||||||
|
border_focused = "#7e9cd8"
|
||||||
|
border_unfocused = "#363646"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
|
||||||
|
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("one-dark.toml", r##"[colors]
|
||||||
|
primary = "#61afef"
|
||||||
|
secondary = "#5c6370"
|
||||||
|
accent = "#e5c07b"
|
||||||
|
artist = "#98c379"
|
||||||
|
album = "#c678dd"
|
||||||
|
song = "#56b6c2"
|
||||||
|
muted = "#5c6370"
|
||||||
|
highlight_bg = "#3e4451"
|
||||||
|
highlight_fg = "#abb2bf"
|
||||||
|
success = "#98c379"
|
||||||
|
error = "#e06c75"
|
||||||
|
playing = "#e5c07b"
|
||||||
|
played = "#5c6370"
|
||||||
|
border_focused = "#61afef"
|
||||||
|
border_unfocused = "#3e4451"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
|
||||||
|
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
|
||||||
|
"##),
|
||||||
|
|
||||||
|
("ayu-dark.toml", r##"[colors]
|
||||||
|
primary = "#59c2ff"
|
||||||
|
secondary = "#6b788a"
|
||||||
|
accent = "#e6b450"
|
||||||
|
artist = "#aad94c"
|
||||||
|
album = "#d2a6ff"
|
||||||
|
song = "#95e6cb"
|
||||||
|
muted = "#6b788a"
|
||||||
|
highlight_bg = "#2f3846"
|
||||||
|
highlight_fg = "#bfc7d5"
|
||||||
|
success = "#aad94c"
|
||||||
|
error = "#f07178"
|
||||||
|
playing = "#e6b450"
|
||||||
|
played = "#6b788a"
|
||||||
|
border_focused = "#59c2ff"
|
||||||
|
border_unfocused = "#2f3846"
|
||||||
|
|
||||||
|
[cava]
|
||||||
|
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
|
||||||
|
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
|
||||||
|
"##),
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user