13 Commits

Author SHA1 Message Date
4e8616dd97 Update install script and README to use precompiled release binary
Install script now downloads the latest GitHub release binary instead of
cloning and building from source. Build dependencies are no longer needed
for the quick install path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:31:43 +00:00
9a54f5c6bd Bump version to 0.2.2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:27:40 +00:00
732001771e Fix artist list scrolling to bottom on click
Restore saved scroll offset into ListState before select() so ratatui
preserves the current viewport position instead of scrolling from zero.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:25:53 +00:00
0823168167 Bump version to 0.2.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:45:26 +00:00
cdf4f611fc Add OpenSSL dev headers to build dependencies in README
reqwest pulls in openssl-sys via native-tls, which requires
OpenSSL development headers to compile from source.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:43:04 +00:00
Jamie Hewitt
ee10c9fa55 Update README.md with correct curl 2026-01-28 11:05:40 +00:00
d17ea748f6 Bump version to 0.2.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 00:15:01 +00:00
fb0786122e Extract built-in theme data from theme.rs into theme_builtins.rs
Moves ~265 lines of TOML theme constant data to a dedicated file,
reducing theme.rs from 553 to ~290 lines of logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 00:07:29 +00:00
763e9bc8db Split mouse.rs into page-specific handler files
Extract handle_artists_click and handle_playlists_click into
mouse_artists.rs and mouse_playlists.rs respectively, reducing
mouse.rs from ~530 to ~247 lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 00:03:41 +00:00
112f18582a Fix all 16 clippy warnings
- Collapse else { if } blocks into else if (6 instances)
- Replace map_or(false, ...) with is_some_and(...) (6 instances)
- Replace iter().cloned().collect() with .to_vec() (2 instances)
- Replace manual char comparison with array pattern (1 instance)
- Replace useless vec! with array literal (1 instance)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:58:18 +00:00
766614f5e9 Remove dead code and #![allow(dead_code)] blanket suppressions
- Delete src/audio/queue.rs (321 lines, PlayQueue never used)
- Remove #![allow(dead_code)] from audio, subsonic, and mpris module roots
- Remove unused MpvEvent2 enum, playlist_clear, get_volume, get_path,
  is_eof, observe_property from mpv.rs
- Remove unused DEFAULT_DEVICE_ID, is_available, get_effective_rate
  from pipewire.rs (and associated dead test)
- Remove unused search(), get_cover_art_url() from subsonic client
- Remove unused SearchResult3Data, SearchResult3 model structs
- Move parse_song_id_from_url into #[cfg(test)] block (only used by tests)
- Add targeted #[allow(dead_code)] on deserialization-only fields
  (MpvEvent, SubsonicResponseInner.version, ArtistIndex.name)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:56:52 +00:00
7582937439 Add missing tempfile dev-dependency for config tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:44:01 +00:00
b94c12a301 Refactor app/mod.rs into focused submodules
Split the 2495-line mod.rs into 10 files by concern:
- playback.rs: playback controls and track management
- cava.rs: cava process management and VT100 parsing
- input.rs: event dispatch and global keybindings
- input_artists.rs: artists page keyboard handling
- input_queue.rs: queue page keyboard handling
- input_playlists.rs: playlists page keyboard handling
- input_server.rs: server page keyboard handling
- input_settings.rs: settings page keyboard handling
- mouse.rs: all mouse click and scroll handling
- mod.rs: App struct, new(), run(), event_loop(), load_initial_data()

Pure code reorganization — no behavioral changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:41:43 +00:00
34 changed files with 2651 additions and 3186 deletions

3
Cargo.lock generated
View File

@@ -632,7 +632,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ferrosonic"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"anyhow",
"clap",
@@ -647,6 +647,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"toml",

View File

@@ -1,6 +1,6 @@
[package]
name = "ferrosonic"
version = "0.1.0"
version = "0.2.2"
edition = "2021"
description = "A terminal-based Subsonic music client with bit-perfect audio playback"
license = "MIT"
@@ -60,3 +60,6 @@ vt100 = "0.15"
lto = true
codegen-units = 1
strip = true
[dev-dependencies]
tempfile = "3.24.0"

View File

@@ -39,25 +39,17 @@ Ferrosonic requires the following at runtime:
| **D-Bus** | MPRIS2 desktop media controls | Recommended |
| **cava** | Audio visualizer | Optional |
Build dependencies (needed to compile from source):
| Dependency | Package (Arch) | Package (Fedora) | Package (Debian/Ubuntu) |
|---|---|---|---|
| **Rust toolchain** | `rustup` | via rustup.rs | via rustup.rs |
| **pkg-config** | `pkgconf` | `pkgconf-pkg-config` | `pkg-config` |
| **D-Bus dev headers** | `dbus` | `dbus-devel` | `libdbus-1-dev` |
### Quick Install
Supports Arch, Fedora, and Debian/Ubuntu. Installs dependencies, Rust (if needed), builds from source, and installs to `/usr/local/bin/`:
Supports Arch, Fedora, and Debian/Ubuntu. Installs runtime dependencies, downloads the latest precompiled binary, and installs to `/usr/local/bin/`:
```bash
curl -sSf https://github.com/jaidaken/ferrosonic/raw/branch/master/install.sh | sh
curl -sSf https://raw.githubusercontent.com/jaidaken/ferrosonic/master/install.sh | sh
```
### Manual Build
### Build from Source
If you prefer to build manually, install the dependencies listed above, then:
If you prefer to build from source, you'll also need: Rust toolchain, pkg-config, OpenSSL dev headers, and D-Bus dev headers. Then:
```bash
git clone https://github.com/jaidaken/ferrosonic.git

View File

@@ -1,25 +1,36 @@
#!/bin/sh
set -e
REPO="https://github.com/jaidaken/ferrosonic.git"
REPO="https://github.com/jaidaken/ferrosonic"
INSTALL_DIR="/usr/local/bin"
echo "Ferrosonic installer"
echo "===================="
# Detect package manager and install dependencies
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ASSET="ferrosonic-linux-x86_64" ;;
*)
echo "No precompiled binary for $ARCH. Please build from source."
echo "See: $REPO#manual-build"
exit 1
;;
esac
# Detect package manager and install runtime dependencies
if command -v pacman >/dev/null 2>&1; then
echo "Detected Arch Linux"
sudo pacman -S --needed --noconfirm mpv pipewire wireplumber base-devel pkgconf dbus
sudo pacman -S --needed --noconfirm mpv pipewire wireplumber dbus
elif command -v dnf >/dev/null 2>&1; then
echo "Detected Fedora"
sudo dnf install -y mpv pipewire wireplumber gcc pkgconf-pkg-config dbus-devel
sudo dnf install -y mpv pipewire wireplumber dbus
elif command -v apt >/dev/null 2>&1; then
echo "Detected Debian/Ubuntu"
sudo apt update
sudo apt install -y mpv pipewire wireplumber build-essential pkg-config libdbus-1-dev
sudo apt install -y mpv pipewire wireplumber libdbus-1-3
else
echo "Unknown package manager. Please install manually: mpv, pipewire, wireplumber, pkg-config, dbus dev headers"
echo "Unknown package manager. Please install manually: mpv, pipewire, wireplumber, dbus"
echo "Then re-run this script."
exit 1
fi
@@ -47,26 +58,17 @@ else
echo "Skipping cava. You can install it later and enable it in Settings (F5)."
fi
# Install Rust if not present
if ! command -v cargo >/dev/null 2>&1; then
echo "Installing Rust toolchain..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
fi
# Clone and build
TMPDIR=$(mktemp -d)
echo "Building ferrosonic..."
git clone "$REPO" "$TMPDIR/ferrosonic"
cd "$TMPDIR/ferrosonic"
cargo build --release
# Download latest release binary
echo "Downloading ferrosonic..."
LATEST=$(curl -sI "$REPO/releases/latest" | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r')
DOWNLOAD_URL="$REPO/releases/download/$LATEST/$ASSET"
TMPFILE=$(mktemp)
curl -sL "$DOWNLOAD_URL" -o "$TMPFILE"
chmod +x "$TMPFILE"
# Install
sudo cp target/release/ferrosonic "$INSTALL_DIR/ferrosonic"
# Cleanup
rm -rf "$TMPDIR"
sudo mv "$TMPFILE" "$INSTALL_DIR/ferrosonic"
echo ""
echo "Ferrosonic installed to $INSTALL_DIR/ferrosonic"
echo "Ferrosonic $LATEST installed to $INSTALL_DIR/ferrosonic"
echo "Run 'ferrosonic' to start."

View File

@@ -1,12 +1,8 @@
//! Application actions and message passing
use crate::subsonic::models::{Album, Artist, Child, Playlist};
/// Actions that can be sent to the audio backend
#[derive(Debug, Clone)]
pub enum AudioAction {
/// Play a specific song by URL
Play { url: String, song: Child },
/// Pause playback
Pause,
/// Resume playback
@@ -26,86 +22,3 @@ pub enum AudioAction {
/// Set volume (0-100)
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}
}

File diff suppressed because it is too large Load Diff

246
src/app/mouse.rs Normal file
View 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
View 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(())
}
}

View 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
View 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(())
}
}

View File

@@ -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 {
match self {
@@ -205,6 +195,8 @@ pub struct SettingsState {
pub theme_index: usize,
/// Cava visualizer enabled
pub cava_enabled: bool,
/// Cava visualizer height percentage (10-80, step 5)
pub cava_size: u8,
}
impl Default for SettingsState {
@@ -214,6 +206,7 @@ impl Default for SettingsState {
themes: vec![ThemeData::default_theme()],
theme_index: 0,
cava_enabled: false,
cava_size: 40,
}
}
}
@@ -269,10 +262,8 @@ pub struct Notification {
#[derive(Debug, Clone, Default)]
pub struct LayoutAreas {
pub header: Rect,
pub cava: Option<Rect>,
pub content: Rect,
pub now_playing: Rect,
pub footer: Rect,
/// Left pane for dual-pane pages (Artists tree, Playlists list)
pub content_left: Option<Rect>,
/// Right pane for dual-pane pages (Songs list)
@@ -349,6 +340,7 @@ impl AppState {
state.server_state.password = config.password.clone();
// Initialize cava from config
state.settings_state.cava_enabled = config.cava;
state.settings_state.cava_size = config.cava_size.clamp(10, 80);
state
}

View File

@@ -1,7 +1,4 @@
//! Audio playback module
#![allow(dead_code)]
pub mod mpv;
pub mod pipewire;
pub mod queue;

View File

@@ -32,8 +32,9 @@ struct MpvResponse {
error: String,
}
/// MPV event
/// MPV event (used for deserialization and debug tracing)
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields populated by deserialization, read via Debug
struct MpvEvent {
event: String,
#[serde(default)]
@@ -42,27 +43,6 @@ struct MpvEvent {
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
pub struct MpvController {
/// Path to the IPC socket
@@ -217,13 +197,6 @@ impl MpvController {
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
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
debug!("Removing playlist entry {}", index);
@@ -307,15 +280,6 @@ impl MpvController {
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)
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
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
pub fn is_idle(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
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
pub fn quit(&mut self) -> Result<(), AudioError> {
if self.socket.is_some() {
@@ -414,11 +366,6 @@ impl MpvController {
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 {

View File

@@ -5,9 +5,6 @@ use tracing::{debug, error, info};
use crate::error::AudioError;
/// Default audio device ID for PipeWire
const DEFAULT_DEVICE_ID: u32 = 0;
/// PipeWire sample rate controller
pub struct PipeWireController {
/// Original sample rate before ferrosonic started
@@ -133,47 +130,6 @@ impl PipeWireController {
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 {
@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -30,9 +30,17 @@ pub struct Config {
/// Enable cava audio visualizer
#[serde(rename = "Cava", default)]
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 {
fn default_cava_size() -> u8 {
40
}
/// Create a new empty config
pub fn new() -> Self {
Self::default()

View File

@@ -1,5 +1,3 @@
//! MPRIS2 D-Bus integration module
#![allow(dead_code)]
pub mod server;

View File

@@ -297,77 +297,23 @@ impl SubsonicClient {
Ok(url.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
impl SubsonicClient {
/// Parse song ID from a stream URL
///
/// Useful for session restoration
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
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)]
mod tests {
use super::*;
#[test]
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";

View File

@@ -1,7 +1,5 @@
//! Subsonic API client module
#![allow(dead_code)]
pub mod auth;
pub mod client;
pub mod models;

View File

@@ -12,6 +12,7 @@ pub struct SubsonicResponse<T> {
#[derive(Debug, Deserialize)]
pub struct SubsonicResponseInner<T> {
pub status: String,
#[allow(dead_code)] // Present in API response, needed for deserialization
pub version: String,
#[serde(default)]
pub error: Option<ApiError>,
@@ -40,6 +41,7 @@ pub struct ArtistsIndex {
#[derive(Debug, Deserialize)]
pub struct ArtistIndex {
#[allow(dead_code)] // Present in API response, needed for deserialization
pub name: String,
#[serde(default)]
pub artist: Vec<Artist>,
@@ -217,19 +219,3 @@ pub struct PlaylistDetail {
#[derive(Debug, Deserialize)]
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>,
}

View File

@@ -38,13 +38,11 @@ impl Widget for Header {
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
// Page tabs
let titles: Vec<Line> = vec![
Page::Artists,
let titles: Vec<Line> = [Page::Artists,
Page::Queue,
Page::Playlists,
Page::Server,
Page::Settings,
]
Page::Settings]
.iter()
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
.collect();

View File

@@ -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 chunks = Layout::vertical([
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::Length(7), // Now playing
Constraint::Length(1), // Footer
@@ -62,10 +62,8 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
// Store layout areas for mouse hit-testing
state.layout = LayoutAreas {
header: header_area,
cava: cava_area,
content: content_area,
now_playing: now_playing_area,
footer: footer_area,
content_left,
content_right,
};

View File

@@ -5,6 +5,7 @@ pub mod header;
pub mod layout;
pub mod pages;
pub mod theme;
mod theme_builtins;
pub mod widgets;
pub use layout::draw;

View File

@@ -47,7 +47,7 @@ pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
// If expanded, add albums sorted by year (oldest first)
if is_expanded {
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| {
// Albums with no year go last
match (a.year, b.year) {
@@ -156,6 +156,7 @@ fn render_tree(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &The
}
let mut list_state = ListState::default();
*list_state.offset_mut() = state.artists.tree_scroll_offset;
if focused {
list_state.select(state.artists.selected_index);
}
@@ -267,6 +268,7 @@ fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &Th
}
let mut list_state = ListState::default();
*list_state.offset_mut() = state.artists.song_scroll_offset;
if focused {
list_state.select(artists.selected_song);
}

View File

@@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
Constraint::Length(2), // Theme selector
Constraint::Length(1), // Spacing
Constraint::Length(2), // Cava toggle
Constraint::Length(1), // Spacing
Constraint::Length(2), // Cava size
Constraint::Min(1), // Remaining space
])
.split(inner);
@@ -66,11 +68,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
&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
let help_text = match settings.selected_field {
0 => "← → or Enter to change theme (auto-saves)",
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
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));

View File

@@ -215,7 +215,7 @@ pub fn load_themes() -> Vec<ThemeData> {
.filter(|e| {
e.path()
.extension()
.map_or(false, |ext| ext == "toml")
.is_some_and(|ext| ext == "toml")
})
.collect();
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"
fn titlecase_filename(s: &str) -> String {
s.split(|c: char| c == '-' || c == '_')
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|word| {
let mut chars = word.chars();
@@ -286,268 +286,4 @@ pub fn seed_default_themes(dir: &Path) {
}
}
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"]
"##),
];
use super::theme_builtins::BUILTIN_THEMES;

267
src/ui/theme_builtins.rs Normal file
View 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"]
"##),
];