Compare commits
13 Commits
bd8f8e6302
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e8616dd97 | |||
| 9a54f5c6bd | |||
| 732001771e | |||
| 0823168167 | |||
| cdf4f611fc | |||
|
|
ee10c9fa55 | ||
| d17ea748f6 | |||
| fb0786122e | |||
| 763e9bc8db | |||
| 112f18582a | |||
| 766614f5e9 | |||
| 7582937439 | |||
| b94c12a301 |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
16
README.md
16
README.md
@@ -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
|
||||
|
||||
50
install.sh
50
install.sh
@@ -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."
|
||||
|
||||
@@ -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
243
src/app/cava.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use std::io::Read as _;
|
||||
use std::os::unix::io::FromRawFd;
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Start cava process in noncurses mode via a pty
|
||||
pub(super) fn start_cava(&mut self, cava_gradient: &[String; 8], cava_horizontal_gradient: &[String; 8], cava_size: u32) {
|
||||
self.stop_cava();
|
||||
|
||||
// Compute pty dimensions to match the cava widget area
|
||||
let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
let cava_h = (term_h as u32 * cava_size / 100).max(4) as u16;
|
||||
let cava_w = term_w;
|
||||
|
||||
// Open a pty pair
|
||||
let mut master: libc::c_int = 0;
|
||||
let mut slave: libc::c_int = 0;
|
||||
unsafe {
|
||||
if libc::openpty(
|
||||
&mut master,
|
||||
&mut slave,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
) != 0
|
||||
{
|
||||
error!("openpty failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pty size so cava knows its dimensions
|
||||
let ws = libc::winsize {
|
||||
ws_row: cava_h,
|
||||
ws_col: cava_w,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
libc::ioctl(slave, libc::TIOCSWINSZ, &ws);
|
||||
}
|
||||
|
||||
// Generate themed cava config and write to temp file
|
||||
// Dup slave fd before converting to File (from_raw_fd takes ownership)
|
||||
let slave_stdin_fd = unsafe { libc::dup(slave) };
|
||||
let slave_stderr_fd = unsafe { libc::dup(slave) };
|
||||
let slave_stdout = unsafe { std::fs::File::from_raw_fd(slave) };
|
||||
let slave_stdin = unsafe { std::fs::File::from_raw_fd(slave_stdin_fd) };
|
||||
let slave_stderr = unsafe { std::fs::File::from_raw_fd(slave_stderr_fd) };
|
||||
let config_path = std::env::temp_dir().join("ferrosonic-cava.conf");
|
||||
if let Err(e) = std::fs::write(&config_path, generate_cava_config(cava_gradient, cava_horizontal_gradient)) {
|
||||
error!("Failed to write cava config: {}", e);
|
||||
return;
|
||||
}
|
||||
let mut cmd = std::process::Command::new("cava");
|
||||
cmd.arg("-p").arg(&config_path);
|
||||
cmd.stdout(std::process::Stdio::from(slave_stdout))
|
||||
.stderr(std::process::Stdio::from(slave_stderr))
|
||||
.stdin(std::process::Stdio::from(slave_stdin))
|
||||
.env("TERM", "xterm-256color");
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(child) => {
|
||||
// Set master to non-blocking
|
||||
unsafe {
|
||||
let flags = libc::fcntl(master, libc::F_GETFL);
|
||||
libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||
}
|
||||
|
||||
let master_file = unsafe { std::fs::File::from_raw_fd(master) };
|
||||
let parser = vt100::Parser::new(cava_h, cava_w, 0);
|
||||
|
||||
self.cava_process = Some(child);
|
||||
self.cava_pty_master = Some(master_file);
|
||||
self.cava_parser = Some(parser);
|
||||
info!("Cava started in noncurses mode ({}x{})", cava_w, cava_h);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start cava: {}", e);
|
||||
unsafe {
|
||||
libc::close(master);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop cava process and clean up
|
||||
pub(super) fn stop_cava(&mut self) {
|
||||
if let Some(ref mut child) = self.cava_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
self.cava_process = None;
|
||||
self.cava_pty_master = None;
|
||||
self.cava_parser = None;
|
||||
}
|
||||
|
||||
/// Read cava pty output and snapshot screen to state
|
||||
pub(super) async fn read_cava_output(&mut self) {
|
||||
let (Some(ref mut master), Some(ref mut parser)) =
|
||||
(&mut self.cava_pty_master, &mut self.cava_parser)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Read all available bytes from the pty master
|
||||
let mut buf = [0u8; 16384];
|
||||
let mut got_data = false;
|
||||
loop {
|
||||
match master.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
parser.process(&buf[..n]);
|
||||
got_data = true;
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
|
||||
if !got_data {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the vt100 screen into shared state
|
||||
let screen = parser.screen();
|
||||
let (rows, cols) = screen.size();
|
||||
let mut cava_screen = Vec::with_capacity(rows as usize);
|
||||
|
||||
for row in 0..rows {
|
||||
let mut spans: Vec<CavaSpan> = Vec::new();
|
||||
let mut cur_text = String::new();
|
||||
let mut cur_fg = CavaColor::Default;
|
||||
let mut cur_bg = CavaColor::Default;
|
||||
|
||||
for col in 0..cols {
|
||||
let cell = screen.cell(row, col).unwrap();
|
||||
let fg = vt100_color_to_cava(cell.fgcolor());
|
||||
let bg = vt100_color_to_cava(cell.bgcolor());
|
||||
|
||||
if fg != cur_fg || bg != cur_bg {
|
||||
if !cur_text.is_empty() {
|
||||
spans.push(CavaSpan {
|
||||
text: std::mem::take(&mut cur_text),
|
||||
fg: cur_fg,
|
||||
bg: cur_bg,
|
||||
});
|
||||
}
|
||||
cur_fg = fg;
|
||||
cur_bg = bg;
|
||||
}
|
||||
|
||||
let contents = cell.contents();
|
||||
if contents.is_empty() {
|
||||
cur_text.push(' ');
|
||||
} else {
|
||||
cur_text.push_str(&contents);
|
||||
}
|
||||
}
|
||||
if !cur_text.is_empty() {
|
||||
spans.push(CavaSpan {
|
||||
text: cur_text,
|
||||
fg: cur_fg,
|
||||
bg: cur_bg,
|
||||
});
|
||||
}
|
||||
cava_screen.push(CavaRow { spans });
|
||||
}
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.cava_screen = cava_screen;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert vt100 color to our CavaColor type
|
||||
fn vt100_color_to_cava(color: vt100::Color) -> CavaColor {
|
||||
match color {
|
||||
vt100::Color::Default => CavaColor::Default,
|
||||
vt100::Color::Idx(i) => CavaColor::Indexed(i),
|
||||
vt100::Color::Rgb(r, g, b) => CavaColor::Rgb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cava configuration string with theme-appropriate gradient colors
|
||||
pub(super) fn generate_cava_config(g: &[String; 8], h: &[String; 8]) -> String {
|
||||
|
||||
format!(
|
||||
"\
|
||||
[general]
|
||||
framerate = 60
|
||||
autosens = 1
|
||||
overshoot = 0
|
||||
bars = 0
|
||||
bar_width = 1
|
||||
bar_spacing = 0
|
||||
lower_cutoff_freq = 10
|
||||
higher_cutoff_freq = 18000
|
||||
|
||||
[input]
|
||||
sample_rate = 96000
|
||||
sample_bits = 32
|
||||
remix = 1
|
||||
|
||||
[output]
|
||||
method = noncurses
|
||||
orientation = horizontal
|
||||
channels = mono
|
||||
mono_option = average
|
||||
synchronized_sync = 1
|
||||
disable_blanking = 1
|
||||
|
||||
[color]
|
||||
gradient = 1
|
||||
gradient_color_1 = '{g0}'
|
||||
gradient_color_2 = '{g1}'
|
||||
gradient_color_3 = '{g2}'
|
||||
gradient_color_4 = '{g3}'
|
||||
gradient_color_5 = '{g4}'
|
||||
gradient_color_6 = '{g5}'
|
||||
gradient_color_7 = '{g6}'
|
||||
gradient_color_8 = '{g7}'
|
||||
horizontal_gradient = 1
|
||||
horizontal_gradient_color_1 = '{h0}'
|
||||
horizontal_gradient_color_2 = '{h1}'
|
||||
horizontal_gradient_color_3 = '{h2}'
|
||||
horizontal_gradient_color_4 = '{h3}'
|
||||
horizontal_gradient_color_5 = '{h4}'
|
||||
horizontal_gradient_color_6 = '{h5}'
|
||||
horizontal_gradient_color_7 = '{h6}'
|
||||
horizontal_gradient_color_8 = '{h7}'
|
||||
|
||||
[smoothing]
|
||||
monstercat = 0
|
||||
waves = 0
|
||||
noise_reduction = 11
|
||||
",
|
||||
g0 = g[0], g1 = g[1], g2 = g[2], g3 = g[3],
|
||||
g4 = g[4], g5 = g[5], g6 = g[6], g7 = g[7],
|
||||
h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3],
|
||||
h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7],
|
||||
)
|
||||
}
|
||||
146
src/app/input.rs
Normal file
146
src/app/input.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle terminal events
|
||||
pub(super) async fn handle_event(&mut self, event: Event) -> Result<(), Error> {
|
||||
match event {
|
||||
Event::Key(key) => {
|
||||
// Only handle key press events, ignore release and repeat
|
||||
if key.kind == event::KeyEventKind::Press {
|
||||
self.handle_key(key).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => self.handle_mouse(mouse).await,
|
||||
Event::Resize(_, _) => {
|
||||
// Restart cava so it picks up the new terminal dimensions
|
||||
if self.cava_parser.is_some() {
|
||||
let state = self.state.read().await;
|
||||
let td = state.settings_state.current_theme();
|
||||
let g = td.cava_gradient.clone();
|
||||
let h = td.cava_horizontal_gradient.clone();
|
||||
let cs = state.settings_state.cava_size as u32;
|
||||
drop(state);
|
||||
self.start_cava(&g, &h, cs);
|
||||
let mut state = self.state.write().await;
|
||||
state.cava_screen.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keyboard input
|
||||
pub(super) async fn handle_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
// Clear notification on any keypress
|
||||
state.clear_notification();
|
||||
|
||||
// Bypass global keybindings when typing in server text fields or filtering artists
|
||||
let is_server_text_field =
|
||||
state.page == Page::Server && state.server_state.selected_field <= 2;
|
||||
let is_filtering = state.page == Page::Artists && state.artists.filter_active;
|
||||
|
||||
if is_server_text_field || is_filtering {
|
||||
let page = state.page;
|
||||
drop(state);
|
||||
return match page {
|
||||
Page::Server => self.handle_server_key(key).await,
|
||||
Page::Artists => self.handle_artists_key(key).await,
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
|
||||
// Global keybindings
|
||||
match (key.code, key.modifiers) {
|
||||
// Quit
|
||||
(KeyCode::Char('q'), KeyModifiers::NONE) => {
|
||||
state.should_quit = true;
|
||||
return Ok(());
|
||||
}
|
||||
// Page switching
|
||||
(KeyCode::F(1), _) => {
|
||||
state.page = Page::Artists;
|
||||
return Ok(());
|
||||
}
|
||||
(KeyCode::F(2), _) => {
|
||||
state.page = Page::Queue;
|
||||
return Ok(());
|
||||
}
|
||||
(KeyCode::F(3), _) => {
|
||||
state.page = Page::Playlists;
|
||||
return Ok(());
|
||||
}
|
||||
(KeyCode::F(4), _) => {
|
||||
state.page = Page::Server;
|
||||
return Ok(());
|
||||
}
|
||||
(KeyCode::F(5), _) => {
|
||||
state.page = Page::Settings;
|
||||
return Ok(());
|
||||
}
|
||||
// Playback controls (global)
|
||||
(KeyCode::Char('p'), KeyModifiers::NONE) | (KeyCode::Char(' '), KeyModifiers::NONE) => {
|
||||
// Toggle pause
|
||||
drop(state);
|
||||
return self.toggle_pause().await;
|
||||
}
|
||||
(KeyCode::Char('l'), KeyModifiers::NONE) => {
|
||||
// Next track
|
||||
drop(state);
|
||||
return self.next_track().await;
|
||||
}
|
||||
(KeyCode::Char('h'), KeyModifiers::NONE) => {
|
||||
// Previous track
|
||||
drop(state);
|
||||
return self.prev_track().await;
|
||||
}
|
||||
// Cycle theme (global)
|
||||
(KeyCode::Char('t'), KeyModifiers::NONE) => {
|
||||
state.settings_state.next_theme();
|
||||
state.config.theme = state.settings_state.theme_name().to_string();
|
||||
let label = state.settings_state.theme_name().to_string();
|
||||
state.notify(format!("Theme: {}", label));
|
||||
let _ = state.config.save_default();
|
||||
let cava_enabled = state.settings_state.cava_enabled;
|
||||
let td = state.settings_state.current_theme();
|
||||
let g = td.cava_gradient.clone();
|
||||
let h = td.cava_horizontal_gradient.clone();
|
||||
let cs = state.settings_state.cava_size as u32;
|
||||
drop(state);
|
||||
if cava_enabled {
|
||||
self.start_cava(&g, &h, cs);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Ctrl+R to refresh data from server
|
||||
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
||||
state.notify("Refreshing...");
|
||||
drop(state);
|
||||
self.load_initial_data().await;
|
||||
let mut state = self.state.write().await;
|
||||
state.notify("Data refreshed");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Page-specific keybindings
|
||||
let page = state.page;
|
||||
drop(state);
|
||||
match page {
|
||||
Page::Artists => self.handle_artists_key(key).await,
|
||||
Page::Queue => self.handle_queue_key(key).await,
|
||||
Page::Playlists => self.handle_playlists_key(key).await,
|
||||
Page::Server => self.handle_server_key(key).await,
|
||||
Page::Settings => self.handle_settings_key(key).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
328
src/app/input_artists.rs
Normal file
328
src/app/input_artists.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
use crossterm::event::{self, KeyCode};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle artists page keys
|
||||
pub(super) async fn handle_artists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
use crate::ui::pages::artists::{build_tree_items, TreeItem};
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
// Handle filter input mode
|
||||
if state.artists.filter_active {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.artists.filter_active = false;
|
||||
state.artists.filter.clear();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.artists.filter_active = false;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
state.artists.filter.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.artists.filter.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('/') => {
|
||||
state.artists.filter_active = true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
state.artists.filter.clear();
|
||||
state.artists.expanded.clear();
|
||||
state.artists.selected_index = Some(0);
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
state.artists.focus = (state.artists.focus + 1) % 2;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.artists.focus = 0;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// Move focus to songs (right pane)
|
||||
if !state.artists.songs.is_empty() {
|
||||
state.artists.focus = 1;
|
||||
if state.artists.selected_song.is_none() {
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.artists.focus == 0 {
|
||||
// Tree navigation
|
||||
let tree_items = build_tree_items(&state);
|
||||
if let Some(sel) = state.artists.selected_index {
|
||||
if sel > 0 {
|
||||
state.artists.selected_index = Some(sel - 1);
|
||||
}
|
||||
} else if !tree_items.is_empty() {
|
||||
state.artists.selected_index = Some(0);
|
||||
}
|
||||
// Preview album songs in right pane
|
||||
let album_id = state
|
||||
.artists
|
||||
.selected_index
|
||||
.and_then(|i| tree_items.get(i))
|
||||
.and_then(|item| match item {
|
||||
TreeItem::Album { album } => Some(album.id.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(album_id) = album_id {
|
||||
drop(state);
|
||||
if let Some(ref client) = self.subsonic {
|
||||
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||
let mut state = self.state.write().await;
|
||||
state.artists.songs = songs;
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
// Song list
|
||||
if let Some(sel) = state.artists.selected_song {
|
||||
if sel > 0 {
|
||||
state.artists.selected_song = Some(sel - 1);
|
||||
}
|
||||
} else if !state.artists.songs.is_empty() {
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.artists.focus == 0 {
|
||||
// Tree navigation
|
||||
let tree_items = build_tree_items(&state);
|
||||
let max = tree_items.len().saturating_sub(1);
|
||||
if let Some(sel) = state.artists.selected_index {
|
||||
if sel < max {
|
||||
state.artists.selected_index = Some(sel + 1);
|
||||
}
|
||||
} else if !tree_items.is_empty() {
|
||||
state.artists.selected_index = Some(0);
|
||||
}
|
||||
// Preview album songs in right pane
|
||||
let album_id = state
|
||||
.artists
|
||||
.selected_index
|
||||
.and_then(|i| tree_items.get(i))
|
||||
.and_then(|item| match item {
|
||||
TreeItem::Album { album } => Some(album.id.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(album_id) = album_id {
|
||||
drop(state);
|
||||
if let Some(ref client) = self.subsonic {
|
||||
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||
let mut state = self.state.write().await;
|
||||
state.artists.songs = songs;
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
// Song list
|
||||
let max = state.artists.songs.len().saturating_sub(1);
|
||||
if let Some(sel) = state.artists.selected_song {
|
||||
if sel < max {
|
||||
state.artists.selected_song = Some(sel + 1);
|
||||
}
|
||||
} else if !state.artists.songs.is_empty() {
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.artists.focus == 0 {
|
||||
// Get current tree item
|
||||
let tree_items = build_tree_items(&state);
|
||||
if let Some(idx) = state.artists.selected_index {
|
||||
if let Some(item) = tree_items.get(idx) {
|
||||
match item {
|
||||
TreeItem::Artist { artist, expanded } => {
|
||||
let artist_id = artist.id.clone();
|
||||
let artist_name = artist.name.clone();
|
||||
let was_expanded = *expanded;
|
||||
|
||||
if was_expanded {
|
||||
state.artists.expanded.remove(&artist_id);
|
||||
} else if !state.artists.albums_cache.contains_key(&artist_id) {
|
||||
drop(state);
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_artist(&artist_id).await {
|
||||
Ok((_artist, albums)) => {
|
||||
let mut state = self.state.write().await;
|
||||
let count = albums.len();
|
||||
state.artists.albums_cache.insert(artist_id.clone(), albums);
|
||||
state.artists.expanded.insert(artist_id);
|
||||
info!("Loaded {} albums for {}", count, artist_name);
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to load: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
} else {
|
||||
state.artists.expanded.insert(artist_id);
|
||||
}
|
||||
}
|
||||
TreeItem::Album { album } => {
|
||||
let album_id = album.id.clone();
|
||||
let album_name = album.name.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_album(&album_id).await {
|
||||
Ok((_album, songs)) => {
|
||||
if songs.is_empty() {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error("Album has no songs");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let first_song = songs[0].clone();
|
||||
let stream_url = client.get_stream_url(&first_song.id);
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
let count = songs.len();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs.clone());
|
||||
state.queue_position = Some(0);
|
||||
state.artists.songs = songs;
|
||||
state.artists.selected_song = Some(0);
|
||||
state.artists.focus = 1;
|
||||
state.now_playing.song = Some(first_song.clone());
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
|
||||
drop(state);
|
||||
|
||||
match stream_url {
|
||||
Ok(url) => {
|
||||
if self.mpv.is_paused().unwrap_or(false) {
|
||||
let _ = self.mpv.resume();
|
||||
}
|
||||
if let Err(e) = self.mpv.loadfile(&url) {
|
||||
error!("Failed to play: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get stream URL: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to load album: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Play selected song from current position
|
||||
if let Some(idx) = state.artists.selected_song {
|
||||
if idx < state.artists.songs.len() {
|
||||
let song = state.artists.songs[idx].clone();
|
||||
let songs = state.artists.songs.clone();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs);
|
||||
state.queue_position = Some(idx);
|
||||
state.now_playing.song = Some(song.clone());
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
state.notify(format!("Playing: {}", song.title));
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_stream_url(&song.id) {
|
||||
Ok(url) => {
|
||||
if self.mpv.is_paused().unwrap_or(false) {
|
||||
let _ = self.mpv.resume();
|
||||
}
|
||||
if let Err(e) = self.mpv.loadfile(&url) {
|
||||
error!("Failed to play: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get stream URL: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.artists.focus == 1 {
|
||||
state.artists.focus = 0;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if state.artists.focus == 1 {
|
||||
if let Some(idx) = state.artists.selected_song {
|
||||
if let Some(song) = state.artists.songs.get(idx).cloned() {
|
||||
let title = song.title.clone();
|
||||
state.queue.push(song);
|
||||
state.notify(format!("Added to queue: {}", title));
|
||||
}
|
||||
}
|
||||
} else if !state.artists.songs.is_empty() {
|
||||
let count = state.artists.songs.len();
|
||||
let songs = state.artists.songs.clone();
|
||||
state.queue.extend(songs);
|
||||
state.notify(format!("Added {} songs to queue", count));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
|
||||
if state.artists.focus == 1 {
|
||||
if let Some(idx) = state.artists.selected_song {
|
||||
if let Some(song) = state.artists.songs.get(idx).cloned() {
|
||||
let title = song.title.clone();
|
||||
state.queue.insert(insert_pos, song);
|
||||
state.notify(format!("Playing next: {}", title));
|
||||
}
|
||||
}
|
||||
} else if !state.artists.songs.is_empty() {
|
||||
let count = state.artists.songs.len();
|
||||
let songs: Vec<_> = state.artists.songs.to_vec();
|
||||
for (i, song) in songs.into_iter().enumerate() {
|
||||
state.queue.insert(insert_pos + i, song);
|
||||
}
|
||||
state.notify(format!("Playing {} songs next", count));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
167
src/app/input_playlists.rs
Normal file
167
src/app/input_playlists.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use crossterm::event::{self, KeyCode};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle playlists page keys
|
||||
pub(super) async fn handle_playlists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
state.playlists.focus = (state.playlists.focus + 1) % 2;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.playlists.focus = 0;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if !state.playlists.songs.is_empty() {
|
||||
state.playlists.focus = 1;
|
||||
if state.playlists.selected_song.is_none() {
|
||||
state.playlists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.playlists.focus == 0 {
|
||||
// Playlist list
|
||||
if let Some(sel) = state.playlists.selected_playlist {
|
||||
if sel > 0 {
|
||||
state.playlists.selected_playlist = Some(sel - 1);
|
||||
}
|
||||
} else if !state.playlists.playlists.is_empty() {
|
||||
state.playlists.selected_playlist = Some(0);
|
||||
}
|
||||
} else {
|
||||
// Song list
|
||||
if let Some(sel) = state.playlists.selected_song {
|
||||
if sel > 0 {
|
||||
state.playlists.selected_song = Some(sel - 1);
|
||||
}
|
||||
} else if !state.playlists.songs.is_empty() {
|
||||
state.playlists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.playlists.focus == 0 {
|
||||
let max = state.playlists.playlists.len().saturating_sub(1);
|
||||
if let Some(sel) = state.playlists.selected_playlist {
|
||||
if sel < max {
|
||||
state.playlists.selected_playlist = Some(sel + 1);
|
||||
}
|
||||
} else if !state.playlists.playlists.is_empty() {
|
||||
state.playlists.selected_playlist = Some(0);
|
||||
}
|
||||
} else {
|
||||
let max = state.playlists.songs.len().saturating_sub(1);
|
||||
if let Some(sel) = state.playlists.selected_song {
|
||||
if sel < max {
|
||||
state.playlists.selected_song = Some(sel + 1);
|
||||
}
|
||||
} else if !state.playlists.songs.is_empty() {
|
||||
state.playlists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.playlists.focus == 0 {
|
||||
// Load playlist songs
|
||||
if let Some(idx) = state.playlists.selected_playlist {
|
||||
if let Some(playlist) = state.playlists.playlists.get(idx) {
|
||||
let playlist_id = playlist.id.clone();
|
||||
let playlist_name = playlist.name.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_playlist(&playlist_id).await {
|
||||
Ok((_playlist, songs)) => {
|
||||
let mut state = self.state.write().await;
|
||||
let count = songs.len();
|
||||
state.playlists.songs = songs;
|
||||
state.playlists.selected_song =
|
||||
if count > 0 { Some(0) } else { None };
|
||||
state.playlists.focus = 1;
|
||||
state.notify(format!(
|
||||
"Loaded playlist: {} ({} songs)",
|
||||
playlist_name, count
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!(
|
||||
"Failed to load playlist: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Play selected song from playlist
|
||||
if let Some(idx) = state.playlists.selected_song {
|
||||
if idx < state.playlists.songs.len() {
|
||||
let songs = state.playlists.songs.clone();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs);
|
||||
drop(state);
|
||||
return self.play_queue_position(idx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
// Add to queue
|
||||
if state.playlists.focus == 1 {
|
||||
if let Some(idx) = state.playlists.selected_song {
|
||||
if let Some(song) = state.playlists.songs.get(idx).cloned() {
|
||||
let title = song.title.clone();
|
||||
state.queue.push(song);
|
||||
state.notify(format!("Added to queue: {}", title));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Add whole playlist
|
||||
if !state.playlists.songs.is_empty() {
|
||||
let count = state.playlists.songs.len();
|
||||
let songs = state.playlists.songs.clone();
|
||||
state.queue.extend(songs);
|
||||
state.notify(format!("Added {} songs to queue", count));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
// Add next
|
||||
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
|
||||
if state.playlists.focus == 1 {
|
||||
if let Some(idx) = state.playlists.selected_song {
|
||||
if let Some(song) = state.playlists.songs.get(idx).cloned() {
|
||||
let title = song.title.clone();
|
||||
state.queue.insert(insert_pos, song);
|
||||
state.notify(format!("Playing next: {}", title));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
// Shuffle play playlist
|
||||
use rand::seq::SliceRandom;
|
||||
if !state.playlists.songs.is_empty() {
|
||||
let mut songs = state.playlists.songs.clone();
|
||||
songs.shuffle(&mut rand::thread_rng());
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs);
|
||||
drop(state);
|
||||
return self.play_queue_position(0).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
144
src/app/input_queue.rs
Normal file
144
src/app/input_queue.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crossterm::event::{self, KeyCode};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle queue page keys
|
||||
pub(super) async fn handle_queue_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if let Some(sel) = state.queue_state.selected {
|
||||
if sel > 0 {
|
||||
state.queue_state.selected = Some(sel - 1);
|
||||
}
|
||||
} else if !state.queue.is_empty() {
|
||||
state.queue_state.selected = Some(0);
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let max = state.queue.len().saturating_sub(1);
|
||||
if let Some(sel) = state.queue_state.selected {
|
||||
if sel < max {
|
||||
state.queue_state.selected = Some(sel + 1);
|
||||
}
|
||||
} else if !state.queue.is_empty() {
|
||||
state.queue_state.selected = Some(0);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Play selected song
|
||||
if let Some(idx) = state.queue_state.selected {
|
||||
if idx < state.queue.len() {
|
||||
drop(state);
|
||||
return self.play_queue_position(idx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
// Remove selected song
|
||||
if let Some(idx) = state.queue_state.selected {
|
||||
if idx < state.queue.len() {
|
||||
let song = state.queue.remove(idx);
|
||||
state.notify(format!("Removed: {}", song.title));
|
||||
// Adjust selection
|
||||
if state.queue.is_empty() {
|
||||
state.queue_state.selected = None;
|
||||
} else if idx >= state.queue.len() {
|
||||
state.queue_state.selected = Some(state.queue.len() - 1);
|
||||
}
|
||||
// Adjust queue position
|
||||
if let Some(pos) = state.queue_position {
|
||||
if idx < pos {
|
||||
state.queue_position = Some(pos - 1);
|
||||
} else if idx == pos {
|
||||
state.queue_position = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('J') => {
|
||||
// Move down
|
||||
if let Some(idx) = state.queue_state.selected {
|
||||
if idx < state.queue.len() - 1 {
|
||||
state.queue.swap(idx, idx + 1);
|
||||
state.queue_state.selected = Some(idx + 1);
|
||||
// Adjust queue position if needed
|
||||
if let Some(pos) = state.queue_position {
|
||||
if pos == idx {
|
||||
state.queue_position = Some(idx + 1);
|
||||
} else if pos == idx + 1 {
|
||||
state.queue_position = Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('K') => {
|
||||
// Move up
|
||||
if let Some(idx) = state.queue_state.selected {
|
||||
if idx > 0 {
|
||||
state.queue.swap(idx, idx - 1);
|
||||
state.queue_state.selected = Some(idx - 1);
|
||||
// Adjust queue position if needed
|
||||
if let Some(pos) = state.queue_position {
|
||||
if pos == idx {
|
||||
state.queue_position = Some(idx - 1);
|
||||
} else if pos == idx - 1 {
|
||||
state.queue_position = Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
// Shuffle queue
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
if let Some(pos) = state.queue_position {
|
||||
// Keep current song in place, shuffle the rest
|
||||
if pos < state.queue.len() {
|
||||
let current = state.queue.remove(pos);
|
||||
state.queue.shuffle(&mut rng);
|
||||
state.queue.insert(0, current);
|
||||
state.queue_position = Some(0);
|
||||
}
|
||||
} else {
|
||||
state.queue.shuffle(&mut rng);
|
||||
}
|
||||
state.notify("Queue shuffled");
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
// Clear history (remove all songs before current position)
|
||||
if let Some(pos) = state.queue_position {
|
||||
if pos > 0 {
|
||||
let removed = pos;
|
||||
state.queue.drain(0..pos);
|
||||
state.queue_position = Some(0);
|
||||
// Adjust selection
|
||||
if let Some(sel) = state.queue_state.selected {
|
||||
if sel < pos {
|
||||
state.queue_state.selected = Some(0);
|
||||
} else {
|
||||
state.queue_state.selected = Some(sel - pos);
|
||||
}
|
||||
}
|
||||
state.notify(format!("Cleared {} played songs", removed));
|
||||
} else {
|
||||
state.notify("No history to clear");
|
||||
}
|
||||
} else {
|
||||
state.notify("No history to clear");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
136
src/app/input_server.rs
Normal file
136
src/app/input_server.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use crossterm::event::{self, KeyCode};
|
||||
use tracing::info;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::subsonic::SubsonicClient;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle server page keys
|
||||
pub(super) async fn handle_server_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
let field = state.server_state.selected_field;
|
||||
let is_text_field = field <= 2;
|
||||
|
||||
match key.code {
|
||||
// Navigation - always works
|
||||
KeyCode::Up => {
|
||||
if field > 0 {
|
||||
state.server_state.selected_field -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if field < 4 {
|
||||
state.server_state.selected_field += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
// Tab moves to next field, wrapping around
|
||||
state.server_state.selected_field = (field + 1) % 5;
|
||||
}
|
||||
// Text input for text fields (0=URL, 1=Username, 2=Password)
|
||||
KeyCode::Char(c) if is_text_field => match field {
|
||||
0 => state.server_state.base_url.push(c),
|
||||
1 => state.server_state.username.push(c),
|
||||
2 => state.server_state.password.push(c),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Backspace if is_text_field => match field {
|
||||
0 => {
|
||||
state.server_state.base_url.pop();
|
||||
}
|
||||
1 => {
|
||||
state.server_state.username.pop();
|
||||
}
|
||||
2 => {
|
||||
state.server_state.password.pop();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Enter activates buttons, ignored on text fields
|
||||
KeyCode::Enter => {
|
||||
match field {
|
||||
3 => {
|
||||
// Test connection
|
||||
let url = state.server_state.base_url.clone();
|
||||
let user = state.server_state.username.clone();
|
||||
let pass = state.server_state.password.clone();
|
||||
state.server_state.status = Some("Testing connection...".to_string());
|
||||
drop(state);
|
||||
|
||||
match SubsonicClient::new(&url, &user, &pass) {
|
||||
Ok(client) => match client.ping().await {
|
||||
Ok(_) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.server_state.status =
|
||||
Some("Connection successful!".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.server_state.status =
|
||||
Some(format!("Connection failed: {}", e));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.server_state.status = Some(format!("Invalid URL: {}", e));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
4 => {
|
||||
// Save config and reconnect
|
||||
info!(
|
||||
"Saving config: url='{}', user='{}'",
|
||||
state.server_state.base_url, state.server_state.username
|
||||
);
|
||||
state.config.base_url = state.server_state.base_url.clone();
|
||||
state.config.username = state.server_state.username.clone();
|
||||
state.config.password = state.server_state.password.clone();
|
||||
|
||||
let url = state.config.base_url.clone();
|
||||
let user = state.config.username.clone();
|
||||
let pass = state.config.password.clone();
|
||||
|
||||
match state.config.save_default() {
|
||||
Ok(_) => {
|
||||
info!("Config saved successfully");
|
||||
state.server_state.status =
|
||||
Some("Saved! Connecting...".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Config save failed: {}", e);
|
||||
state.server_state.status = Some(format!("Save failed: {}", e));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
|
||||
// Create new client and load data
|
||||
match SubsonicClient::new(&url, &user, &pass) {
|
||||
Ok(client) => {
|
||||
self.subsonic = Some(client);
|
||||
self.load_initial_data().await;
|
||||
let mut state = self.state.write().await;
|
||||
state.server_state.status =
|
||||
Some("Connected and loaded data!".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.server_state.status =
|
||||
Some(format!("Saved but connection failed: {}", e));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => {} // Ignore Enter on text fields (handles paste with newlines)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
127
src/app/input_settings.rs
Normal file
127
src/app/input_settings.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use crossterm::event::{self, KeyCode};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle settings page keys
|
||||
pub(super) async fn handle_settings_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
|
||||
let mut config_changed = false;
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
let field = state.settings_state.selected_field;
|
||||
|
||||
match key.code {
|
||||
// Navigate between fields
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if field > 0 {
|
||||
state.settings_state.selected_field = field - 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if field < 2 {
|
||||
state.settings_state.selected_field = field + 1;
|
||||
}
|
||||
}
|
||||
// Left
|
||||
KeyCode::Left | KeyCode::Char('h') => match field {
|
||||
0 => {
|
||||
state.settings_state.prev_theme();
|
||||
state.config.theme = state.settings_state.theme_name().to_string();
|
||||
let label = state.settings_state.theme_name().to_string();
|
||||
state.notify(format!("Theme: {}", label));
|
||||
config_changed = true;
|
||||
}
|
||||
1 if state.cava_available => {
|
||||
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
|
||||
state.config.cava = state.settings_state.cava_enabled;
|
||||
let status = if state.settings_state.cava_enabled {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
};
|
||||
state.notify(format!("Cava: {}", status));
|
||||
config_changed = true;
|
||||
}
|
||||
2 if state.cava_available => {
|
||||
let cur = state.settings_state.cava_size;
|
||||
if cur > 10 {
|
||||
let new_size = cur - 5;
|
||||
state.settings_state.cava_size = new_size;
|
||||
state.config.cava_size = new_size;
|
||||
state.notify(format!("Cava Size: {}%", new_size));
|
||||
config_changed = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Right / Enter / Space
|
||||
KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
match field {
|
||||
0 => {
|
||||
state.settings_state.next_theme();
|
||||
state.config.theme = state.settings_state.theme_name().to_string();
|
||||
let label = state.settings_state.theme_name().to_string();
|
||||
state.notify(format!("Theme: {}", label));
|
||||
config_changed = true;
|
||||
}
|
||||
1 if state.cava_available => {
|
||||
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
|
||||
state.config.cava = state.settings_state.cava_enabled;
|
||||
let status = if state.settings_state.cava_enabled {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
};
|
||||
state.notify(format!("Cava: {}", status));
|
||||
config_changed = true;
|
||||
}
|
||||
2 if state.cava_available => {
|
||||
let cur = state.settings_state.cava_size;
|
||||
if cur < 80 {
|
||||
let new_size = cur + 5;
|
||||
state.settings_state.cava_size = new_size;
|
||||
state.config.cava_size = new_size;
|
||||
state.notify(format!("Cava Size: {}%", new_size));
|
||||
config_changed = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if config_changed {
|
||||
// Save config
|
||||
let state = self.state.read().await;
|
||||
if let Err(e) = state.config.save_default() {
|
||||
drop(state);
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to save: {}", e));
|
||||
} else {
|
||||
// Start/stop cava based on new setting, or restart on theme change
|
||||
let cava_enabled = state.settings_state.cava_enabled;
|
||||
let td = state.settings_state.current_theme();
|
||||
let g = td.cava_gradient.clone();
|
||||
let h = td.cava_horizontal_gradient.clone();
|
||||
let cs = state.settings_state.cava_size as u32;
|
||||
let cava_running = self.cava_parser.is_some();
|
||||
drop(state);
|
||||
if cava_enabled {
|
||||
// (Re)start cava — picks up new theme colors or toggle-on
|
||||
self.start_cava(&g, &h, cs);
|
||||
} else if cava_running {
|
||||
self.stop_cava();
|
||||
let mut state = self.state.write().await;
|
||||
state.cava_screen.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
2314
src/app/mod.rs
2314
src/app/mod.rs
File diff suppressed because it is too large
Load Diff
246
src/app/mouse.rs
Normal file
246
src/app/mouse.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use crossterm::event::{self, MouseButton, MouseEventKind};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle mouse input
|
||||
pub(super) async fn handle_mouse(&mut self, mouse: event::MouseEvent) -> Result<(), Error> {
|
||||
let x = mouse.column;
|
||||
let y = mouse.row;
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.handle_mouse_click(x, y).await
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.handle_mouse_scroll_up().await
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
self.handle_mouse_scroll_down().await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle left mouse click
|
||||
async fn handle_mouse_click(&mut self, x: u16, y: u16) -> Result<(), Error> {
|
||||
use crate::ui::header::{Header, HeaderRegion};
|
||||
|
||||
let state = self.state.read().await;
|
||||
let layout = state.layout.clone();
|
||||
let page = state.page;
|
||||
let duration = state.now_playing.duration;
|
||||
drop(state);
|
||||
|
||||
// Check header area
|
||||
if y >= layout.header.y && y < layout.header.y + layout.header.height {
|
||||
if let Some(region) = Header::region_at(layout.header, x, y) {
|
||||
match region {
|
||||
HeaderRegion::Tab(tab_page) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.page = tab_page;
|
||||
}
|
||||
HeaderRegion::PrevButton => {
|
||||
return self.prev_track().await;
|
||||
}
|
||||
HeaderRegion::PlayButton => {
|
||||
return self.toggle_pause().await;
|
||||
}
|
||||
HeaderRegion::PauseButton => {
|
||||
return self.toggle_pause().await;
|
||||
}
|
||||
HeaderRegion::StopButton => {
|
||||
return self.stop_playback().await;
|
||||
}
|
||||
HeaderRegion::NextButton => {
|
||||
return self.next_track().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check now playing area (progress bar seeking)
|
||||
if y >= layout.now_playing.y && y < layout.now_playing.y + layout.now_playing.height {
|
||||
let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2;
|
||||
if y == inner_bottom && duration > 0.0 {
|
||||
let inner_x_start = layout.now_playing.x + 1;
|
||||
let inner_width = layout.now_playing.width.saturating_sub(2);
|
||||
if inner_width > 15 && x >= inner_x_start {
|
||||
let rel_x = x - inner_x_start;
|
||||
let time_width = 15u16;
|
||||
let bar_width = inner_width.saturating_sub(time_width + 2);
|
||||
let bar_start = (inner_width.saturating_sub(time_width + 2 + bar_width)) / 2 + time_width + 2;
|
||||
if bar_width > 0 && rel_x >= bar_start && rel_x < bar_start + bar_width {
|
||||
let fraction = (rel_x - bar_start) as f64 / bar_width as f64;
|
||||
let seek_pos = fraction * duration;
|
||||
let _ = self.mpv.seek(seek_pos);
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.position = seek_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check content area
|
||||
if y >= layout.content.y && y < layout.content.y + layout.content.height {
|
||||
return self.handle_content_click(x, y, page, &layout).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle click within the content area
|
||||
async fn handle_content_click(
|
||||
&mut self,
|
||||
x: u16,
|
||||
y: u16,
|
||||
page: Page,
|
||||
layout: &LayoutAreas,
|
||||
) -> Result<(), Error> {
|
||||
match page {
|
||||
Page::Artists => self.handle_artists_click(x, y, layout).await,
|
||||
Page::Queue => self.handle_queue_click(y, layout).await,
|
||||
Page::Playlists => self.handle_playlists_click(x, y, layout).await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle click on queue page
|
||||
async fn handle_queue_click(&mut self, y: u16, layout: &LayoutAreas) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
let content = layout.content;
|
||||
|
||||
// Account for border (1 row top)
|
||||
let row_in_viewport = y.saturating_sub(content.y + 1) as usize;
|
||||
let item_index = state.queue_state.scroll_offset + row_in_viewport;
|
||||
|
||||
if item_index < state.queue.len() {
|
||||
let was_selected = state.queue_state.selected == Some(item_index);
|
||||
state.queue_state.selected = Some(item_index);
|
||||
|
||||
let is_second_click = was_selected
|
||||
&& self.last_click.is_some_and(|(_, ly, t)| {
|
||||
ly == y && t.elapsed().as_millis() < 500
|
||||
});
|
||||
|
||||
if is_second_click {
|
||||
drop(state);
|
||||
self.last_click = Some((0, y, std::time::Instant::now()));
|
||||
return self.play_queue_position(item_index).await;
|
||||
}
|
||||
}
|
||||
|
||||
self.last_click = Some((0, y, std::time::Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle mouse scroll up (move selection up in current list)
|
||||
async fn handle_mouse_scroll_up(&mut self) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
match state.page {
|
||||
Page::Artists => {
|
||||
if state.artists.focus == 0 {
|
||||
if let Some(sel) = state.artists.selected_index {
|
||||
if sel > 0 {
|
||||
state.artists.selected_index = Some(sel - 1);
|
||||
}
|
||||
}
|
||||
} else if let Some(sel) = state.artists.selected_song {
|
||||
if sel > 0 {
|
||||
state.artists.selected_song = Some(sel - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Page::Queue => {
|
||||
if let Some(sel) = state.queue_state.selected {
|
||||
if sel > 0 {
|
||||
state.queue_state.selected = Some(sel - 1);
|
||||
}
|
||||
} else if !state.queue.is_empty() {
|
||||
state.queue_state.selected = Some(0);
|
||||
}
|
||||
}
|
||||
Page::Playlists => {
|
||||
if state.playlists.focus == 0 {
|
||||
if let Some(sel) = state.playlists.selected_playlist {
|
||||
if sel > 0 {
|
||||
state.playlists.selected_playlist = Some(sel - 1);
|
||||
}
|
||||
}
|
||||
} else if let Some(sel) = state.playlists.selected_song {
|
||||
if sel > 0 {
|
||||
state.playlists.selected_song = Some(sel - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle mouse scroll down (move selection down in current list)
|
||||
async fn handle_mouse_scroll_down(&mut self) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
match state.page {
|
||||
Page::Artists => {
|
||||
if state.artists.focus == 0 {
|
||||
let tree_items = crate::ui::pages::artists::build_tree_items(&state);
|
||||
let max = tree_items.len().saturating_sub(1);
|
||||
if let Some(sel) = state.artists.selected_index {
|
||||
if sel < max {
|
||||
state.artists.selected_index = Some(sel + 1);
|
||||
}
|
||||
} else if !tree_items.is_empty() {
|
||||
state.artists.selected_index = Some(0);
|
||||
}
|
||||
} else {
|
||||
let max = state.artists.songs.len().saturating_sub(1);
|
||||
if let Some(sel) = state.artists.selected_song {
|
||||
if sel < max {
|
||||
state.artists.selected_song = Some(sel + 1);
|
||||
}
|
||||
} else if !state.artists.songs.is_empty() {
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Page::Queue => {
|
||||
let max = state.queue.len().saturating_sub(1);
|
||||
if let Some(sel) = state.queue_state.selected {
|
||||
if sel < max {
|
||||
state.queue_state.selected = Some(sel + 1);
|
||||
}
|
||||
} else if !state.queue.is_empty() {
|
||||
state.queue_state.selected = Some(0);
|
||||
}
|
||||
}
|
||||
Page::Playlists => {
|
||||
if state.playlists.focus == 0 {
|
||||
let max = state.playlists.playlists.len().saturating_sub(1);
|
||||
if let Some(sel) = state.playlists.selected_playlist {
|
||||
if sel < max {
|
||||
state.playlists.selected_playlist = Some(sel + 1);
|
||||
}
|
||||
} else if !state.playlists.playlists.is_empty() {
|
||||
state.playlists.selected_playlist = Some(0);
|
||||
}
|
||||
} else {
|
||||
let max = state.playlists.songs.len().saturating_sub(1);
|
||||
if let Some(sel) = state.playlists.selected_song {
|
||||
if sel < max {
|
||||
state.playlists.selected_song = Some(sel + 1);
|
||||
}
|
||||
} else if !state.playlists.songs.is_empty() {
|
||||
state.playlists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
196
src/app/mouse_artists.rs
Normal file
196
src/app/mouse_artists.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use tracing::error;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle click on artists page
|
||||
pub(super) async fn handle_artists_click(
|
||||
&mut self,
|
||||
x: u16,
|
||||
y: u16,
|
||||
layout: &LayoutAreas,
|
||||
) -> Result<(), Error> {
|
||||
use crate::ui::pages::artists::{build_tree_items, TreeItem};
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
let left = layout.content_left.unwrap_or(layout.content);
|
||||
let right = layout.content_right.unwrap_or(layout.content);
|
||||
|
||||
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
|
||||
// Tree pane click — account for border (1 row top)
|
||||
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
|
||||
let item_index = state.artists.tree_scroll_offset + row_in_viewport;
|
||||
let tree_items = build_tree_items(&state);
|
||||
|
||||
if item_index < tree_items.len() {
|
||||
let was_selected = state.artists.selected_index == Some(item_index);
|
||||
state.artists.focus = 0;
|
||||
state.artists.selected_index = Some(item_index);
|
||||
|
||||
// Second click = activate (same as Enter)
|
||||
let is_second_click = was_selected
|
||||
&& self.last_click.is_some_and(|(lx, ly, t)| {
|
||||
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||
});
|
||||
|
||||
if is_second_click {
|
||||
// Activate: expand/collapse artist, or play album
|
||||
match &tree_items[item_index] {
|
||||
TreeItem::Artist { artist, expanded } => {
|
||||
let artist_id = artist.id.clone();
|
||||
let artist_name = artist.name.clone();
|
||||
let was_expanded = *expanded;
|
||||
|
||||
if was_expanded {
|
||||
state.artists.expanded.remove(&artist_id);
|
||||
} else if !state.artists.albums_cache.contains_key(&artist_id) {
|
||||
drop(state);
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_artist(&artist_id).await {
|
||||
Ok((_artist, albums)) => {
|
||||
let mut state = self.state.write().await;
|
||||
let count = albums.len();
|
||||
state.artists.albums_cache.insert(artist_id.clone(), albums);
|
||||
state.artists.expanded.insert(artist_id);
|
||||
tracing::info!("Loaded {} albums for {}", count, artist_name);
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to load: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
} else {
|
||||
state.artists.expanded.insert(artist_id);
|
||||
}
|
||||
}
|
||||
TreeItem::Album { album } => {
|
||||
let album_id = album.id.clone();
|
||||
let album_name = album.name.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_album(&album_id).await {
|
||||
Ok((_album, songs)) => {
|
||||
if songs.is_empty() {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error("Album has no songs");
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let first_song = songs[0].clone();
|
||||
let stream_url = client.get_stream_url(&first_song.id);
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
let count = songs.len();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs.clone());
|
||||
state.queue_position = Some(0);
|
||||
state.artists.songs = songs;
|
||||
state.artists.selected_song = Some(0);
|
||||
state.artists.focus = 1;
|
||||
state.now_playing.song = Some(first_song.clone());
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
|
||||
drop(state);
|
||||
|
||||
if let Ok(url) = stream_url {
|
||||
if self.mpv.is_paused().unwrap_or(false) {
|
||||
let _ = self.mpv.resume();
|
||||
}
|
||||
if let Err(e) = self.mpv.loadfile(&url) {
|
||||
error!("Failed to play: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to load album: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First click on album: preview songs in right pane
|
||||
if let TreeItem::Album { album } = &tree_items[item_index] {
|
||||
let album_id = album.id.clone();
|
||||
drop(state);
|
||||
if let Some(ref client) = self.subsonic {
|
||||
if let Ok((_album, songs)) = client.get_album(&album_id).await {
|
||||
let mut state = self.state.write().await;
|
||||
state.artists.songs = songs;
|
||||
state.artists.selected_song = Some(0);
|
||||
}
|
||||
}
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
|
||||
// Songs pane click
|
||||
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
|
||||
let item_index = state.artists.song_scroll_offset + row_in_viewport;
|
||||
|
||||
if item_index < state.artists.songs.len() {
|
||||
let was_selected = state.artists.selected_song == Some(item_index);
|
||||
state.artists.focus = 1;
|
||||
state.artists.selected_song = Some(item_index);
|
||||
|
||||
let is_second_click = was_selected
|
||||
&& self.last_click.is_some_and(|(lx, ly, t)| {
|
||||
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||
});
|
||||
|
||||
if is_second_click {
|
||||
// Play selected song
|
||||
let song = state.artists.songs[item_index].clone();
|
||||
let songs = state.artists.songs.clone();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs);
|
||||
state.queue_position = Some(item_index);
|
||||
state.now_playing.song = Some(song.clone());
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
state.notify(format!("Playing: {}", song.title));
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
if let Ok(url) = client.get_stream_url(&song.id) {
|
||||
if self.mpv.is_paused().unwrap_or(false) {
|
||||
let _ = self.mpv.resume();
|
||||
}
|
||||
if let Err(e) = self.mpv.loadfile(&url) {
|
||||
error!("Failed to play: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
89
src/app/mouse_playlists.rs
Normal file
89
src/app/mouse_playlists.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::error::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Handle click on playlists page
|
||||
pub(super) async fn handle_playlists_click(
|
||||
&mut self,
|
||||
x: u16,
|
||||
y: u16,
|
||||
layout: &LayoutAreas,
|
||||
) -> Result<(), Error> {
|
||||
let mut state = self.state.write().await;
|
||||
let left = layout.content_left.unwrap_or(layout.content);
|
||||
let right = layout.content_right.unwrap_or(layout.content);
|
||||
|
||||
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
|
||||
// Playlists pane
|
||||
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
|
||||
let item_index = state.playlists.playlist_scroll_offset + row_in_viewport;
|
||||
|
||||
if item_index < state.playlists.playlists.len() {
|
||||
let was_selected = state.playlists.selected_playlist == Some(item_index);
|
||||
state.playlists.focus = 0;
|
||||
state.playlists.selected_playlist = Some(item_index);
|
||||
|
||||
let is_second_click = was_selected
|
||||
&& self.last_click.is_some_and(|(lx, ly, t)| {
|
||||
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||
});
|
||||
|
||||
if is_second_click {
|
||||
// Load playlist songs (same as Enter)
|
||||
let playlist = state.playlists.playlists[item_index].clone();
|
||||
let playlist_id = playlist.id.clone();
|
||||
let playlist_name = playlist.name.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
match client.get_playlist(&playlist_id).await {
|
||||
Ok((_playlist, songs)) => {
|
||||
let mut state = self.state.write().await;
|
||||
let count = songs.len();
|
||||
state.playlists.songs = songs;
|
||||
state.playlists.selected_song = if count > 0 { Some(0) } else { None };
|
||||
state.playlists.focus = 1;
|
||||
state.notify(format!("Loaded playlist: {} ({} songs)", playlist_name, count));
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to load playlist: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
|
||||
// Songs pane
|
||||
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
|
||||
let item_index = state.playlists.song_scroll_offset + row_in_viewport;
|
||||
|
||||
if item_index < state.playlists.songs.len() {
|
||||
let was_selected = state.playlists.selected_song == Some(item_index);
|
||||
state.playlists.focus = 1;
|
||||
state.playlists.selected_song = Some(item_index);
|
||||
|
||||
let is_second_click = was_selected
|
||||
&& self.last_click.is_some_and(|(lx, ly, t)| {
|
||||
lx == x && ly == y && t.elapsed().as_millis() < 500
|
||||
});
|
||||
|
||||
if is_second_click {
|
||||
// Play selected song from playlist
|
||||
let songs = state.playlists.songs.clone();
|
||||
state.queue.clear();
|
||||
state.queue.extend(songs);
|
||||
drop(state);
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
return self.play_queue_position(item_index).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_click = Some((x, y, std::time::Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
411
src/app/playback.rs
Normal file
411
src/app/playback.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Update playback position and audio info from MPV
|
||||
pub(super) async fn update_playback_info(&mut self) {
|
||||
// Only update if something should be playing
|
||||
let state = self.state.read().await;
|
||||
let is_playing = state.now_playing.state == PlaybackState::Playing;
|
||||
let is_active = is_playing || state.now_playing.state == PlaybackState::Paused;
|
||||
drop(state);
|
||||
|
||||
if !is_active || !self.mpv.is_running() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for track advancement
|
||||
if is_playing {
|
||||
// Early transition: if near end of track and no preloaded next track,
|
||||
// advance immediately instead of waiting for idle detection
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
let time_remaining = state.now_playing.duration - state.now_playing.position;
|
||||
let has_next = state
|
||||
.queue_position
|
||||
.map(|p| p + 1 < state.queue.len())
|
||||
.unwrap_or(false);
|
||||
drop(state);
|
||||
|
||||
if has_next && time_remaining > 0.0 && time_remaining < 2.0 {
|
||||
if let Ok(count) = self.mpv.get_playlist_count() {
|
||||
if count < 2 {
|
||||
info!("Near end of track with no preloaded next — advancing early");
|
||||
let _ = self.next_track().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-preload if the appended track was lost
|
||||
if let Ok(count) = self.mpv.get_playlist_count() {
|
||||
if count == 1 {
|
||||
let state = self.state.read().await;
|
||||
if let Some(pos) = state.queue_position {
|
||||
if pos + 1 < state.queue.len() {
|
||||
drop(state);
|
||||
debug!("Playlist count is 1, re-preloading next track");
|
||||
self.preload_next_track(pos).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if MPV advanced to next track in playlist (gapless transition)
|
||||
if let Ok(Some(mpv_pos)) = self.mpv.get_playlist_pos() {
|
||||
if mpv_pos == 1 {
|
||||
// Gapless advance happened - update our state to match
|
||||
let state = self.state.read().await;
|
||||
if let Some(current_pos) = state.queue_position {
|
||||
let next_pos = current_pos + 1;
|
||||
if next_pos < state.queue.len() {
|
||||
drop(state);
|
||||
info!("Gapless advancement to track {}", next_pos);
|
||||
|
||||
// Update state - keep audio properties since they'll be similar
|
||||
// for gapless transitions (same album, same format)
|
||||
let mut state = self.state.write().await;
|
||||
state.queue_position = Some(next_pos);
|
||||
if let Some(song) = state.queue.get(next_pos).cloned() {
|
||||
state.now_playing.song = Some(song.clone());
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||
// Don't reset audio properties - let them update naturally
|
||||
// This avoids triggering PipeWire rate changes unnecessarily
|
||||
}
|
||||
drop(state);
|
||||
|
||||
// Remove the finished track (index 0) from MPV's playlist
|
||||
// This is less disruptive than playlist_clear during playback
|
||||
let _ = self.mpv.playlist_remove(0);
|
||||
|
||||
// Preload the next track for continued gapless playback
|
||||
self.preload_next_track(next_pos).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if MPV went idle (track ended, no preloaded track)
|
||||
if let Ok(idle) = self.mpv.is_idle() {
|
||||
if idle {
|
||||
info!("Track ended, advancing to next");
|
||||
let _ = self.next_track().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get position from MPV
|
||||
if let Ok(position) = self.mpv.get_time_pos() {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.position = position;
|
||||
}
|
||||
|
||||
// Get duration if not set
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if state.now_playing.duration <= 0.0 {
|
||||
drop(state);
|
||||
if let Ok(duration) = self.mpv.get_duration() {
|
||||
if duration > 0.0 {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.duration = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get audio properties - keep polling until we get valid values
|
||||
// MPV may not have them ready immediately when playback starts
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
let need_sample_rate = state.now_playing.sample_rate.is_none();
|
||||
drop(state);
|
||||
|
||||
if need_sample_rate {
|
||||
// Try to get audio properties from MPV
|
||||
let sample_rate = self.mpv.get_sample_rate().ok().flatten();
|
||||
let bit_depth = self.mpv.get_bit_depth().ok().flatten();
|
||||
let format = self.mpv.get_audio_format().ok().flatten();
|
||||
let channels = self.mpv.get_channels().ok().flatten();
|
||||
|
||||
// Only update if we got a valid sample rate (indicates audio is ready)
|
||||
if let Some(rate) = sample_rate {
|
||||
// Only switch PipeWire sample rate if it's actually different
|
||||
// This avoids unnecessary rate switches during gapless playback
|
||||
// of albums with the same sample rate
|
||||
let current_pw_rate = self.pipewire.get_current_rate();
|
||||
if current_pw_rate != Some(rate) {
|
||||
info!("Sample rate change: {:?} -> {} Hz", current_pw_rate, rate);
|
||||
if let Err(e) = self.pipewire.set_rate(rate) {
|
||||
warn!("Failed to set PipeWire sample rate: {}", e);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Sample rate unchanged at {} Hz, skipping PipeWire switch",
|
||||
rate
|
||||
);
|
||||
}
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.sample_rate = Some(rate);
|
||||
state.now_playing.bit_depth = bit_depth;
|
||||
state.now_playing.format = format;
|
||||
state.now_playing.channels = channels;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update MPRIS properties to keep external clients in sync
|
||||
if let Some(ref server) = self.mpris_server {
|
||||
if let Err(e) = update_mpris_properties(server, &self.state).await {
|
||||
debug!("Failed to update MPRIS properties: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle play/pause
|
||||
pub(super) async fn toggle_pause(&mut self) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
let is_playing = state.now_playing.state == PlaybackState::Playing;
|
||||
let is_paused = state.now_playing.state == PlaybackState::Paused;
|
||||
drop(state);
|
||||
|
||||
if !is_playing && !is_paused {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.mpv.toggle_pause() {
|
||||
Ok(now_paused) => {
|
||||
let mut state = self.state.write().await;
|
||||
if now_paused {
|
||||
state.now_playing.state = PlaybackState::Paused;
|
||||
debug!("Paused playback");
|
||||
} else {
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
debug!("Resumed playback");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to toggle pause: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause playback (only if currently playing)
|
||||
pub(super) async fn pause_playback(&mut self) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
if state.now_playing.state != PlaybackState::Playing {
|
||||
return Ok(());
|
||||
}
|
||||
drop(state);
|
||||
|
||||
match self.mpv.pause() {
|
||||
Ok(()) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.state = PlaybackState::Paused;
|
||||
debug!("Paused playback");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to pause: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume playback (only if currently paused)
|
||||
pub(super) async fn resume_playback(&mut self) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
if state.now_playing.state != PlaybackState::Paused {
|
||||
return Ok(());
|
||||
}
|
||||
drop(state);
|
||||
|
||||
match self.mpv.resume() {
|
||||
Ok(()) => {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
debug!("Resumed playback");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to resume: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play next track in queue
|
||||
pub(super) async fn next_track(&mut self) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
let queue_len = state.queue.len();
|
||||
let current_pos = state.queue_position;
|
||||
drop(state);
|
||||
|
||||
if queue_len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let next_pos = match current_pos {
|
||||
Some(pos) if pos + 1 < queue_len => pos + 1,
|
||||
_ => {
|
||||
info!("Reached end of queue");
|
||||
let _ = self.mpv.stop();
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.state = PlaybackState::Stopped;
|
||||
state.now_playing.position = 0.0;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
self.play_queue_position(next_pos).await
|
||||
}
|
||||
|
||||
/// Play previous track in queue (or restart current if < 3 seconds in)
|
||||
pub(super) async fn prev_track(&mut self) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
let queue_len = state.queue.len();
|
||||
let current_pos = state.queue_position;
|
||||
let position = state.now_playing.position;
|
||||
drop(state);
|
||||
|
||||
if queue_len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if position < 3.0 {
|
||||
if let Some(pos) = current_pos {
|
||||
if pos > 0 {
|
||||
return self.play_queue_position(pos - 1).await;
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.mpv.seek(0.0) {
|
||||
error!("Failed to restart track: {}", e);
|
||||
} else {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.position = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Restarting current track (position: {:.1}s)", position);
|
||||
if let Err(e) = self.mpv.seek(0.0) {
|
||||
error!("Failed to restart track: {}", e);
|
||||
} else {
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.position = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play a specific position in the queue
|
||||
pub(super) async fn play_queue_position(&mut self, pos: usize) -> Result<(), Error> {
|
||||
let state = self.state.read().await;
|
||||
let song = match state.queue.get(pos) {
|
||||
Some(s) => s.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
drop(state);
|
||||
|
||||
let stream_url = if let Some(ref client) = self.subsonic {
|
||||
match client.get_stream_url(&song.id) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
error!("Failed to get stream URL: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("Failed to get stream URL: {}", e));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.queue_position = Some(pos);
|
||||
state.now_playing.song = Some(song.clone());
|
||||
state.now_playing.state = PlaybackState::Playing;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
}
|
||||
|
||||
info!("Playing: {} (queue pos {})", song.title, pos);
|
||||
if self.mpv.is_paused().unwrap_or(false) {
|
||||
let _ = self.mpv.resume();
|
||||
}
|
||||
if let Err(e) = self.mpv.loadfile(&stream_url) {
|
||||
error!("Failed to play: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.notify_error(format!("MPV error: {}", e));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.preload_next_track(pos).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-load the next track into MPV's playlist for gapless playback
|
||||
pub(super) async fn preload_next_track(&mut self, current_pos: usize) {
|
||||
let state = self.state.read().await;
|
||||
let next_pos = current_pos + 1;
|
||||
|
||||
if next_pos >= state.queue.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let next_song = match state.queue.get(next_pos) {
|
||||
Some(s) => s.clone(),
|
||||
None => return,
|
||||
};
|
||||
drop(state);
|
||||
|
||||
if let Some(ref client) = self.subsonic {
|
||||
if let Ok(url) = client.get_stream_url(&next_song.id) {
|
||||
debug!("Pre-loading next track for gapless: {}", next_song.title);
|
||||
if let Err(e) = self.mpv.loadfile_append(&url) {
|
||||
debug!("Failed to pre-load next track: {}", e);
|
||||
} else if let Ok(count) = self.mpv.get_playlist_count() {
|
||||
if count < 2 {
|
||||
warn!(
|
||||
"Preload may have failed: playlist count is {} (expected 2)",
|
||||
count
|
||||
);
|
||||
} else {
|
||||
debug!("Preload confirmed: playlist count is {}", count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop playback and clear the queue
|
||||
pub(super) async fn stop_playback(&mut self) -> Result<(), Error> {
|
||||
if let Err(e) = self.mpv.stop() {
|
||||
error!("Failed to stop: {}", e);
|
||||
}
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.now_playing.state = PlaybackState::Stopped;
|
||||
state.now_playing.song = None;
|
||||
state.now_playing.position = 0.0;
|
||||
state.now_playing.duration = 0.0;
|
||||
state.now_playing.sample_rate = None;
|
||||
state.now_playing.bit_depth = None;
|
||||
state.now_playing.format = None;
|
||||
state.now_playing.channels = None;
|
||||
state.queue.clear();
|
||||
state.queue_position = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,6 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_index(index: usize) -> Self {
|
||||
match index {
|
||||
0 => Page::Artists,
|
||||
1 => Page::Queue,
|
||||
2 => Page::Playlists,
|
||||
3 => Page::Server,
|
||||
4 => Page::Settings,
|
||||
_ => Page::Artists,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Audio playback module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod mpv;
|
||||
pub mod pipewire;
|
||||
pub mod queue;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Play queue management
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::subsonic::models::Child;
|
||||
use crate::subsonic::SubsonicClient;
|
||||
|
||||
/// Play queue
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PlayQueue {
|
||||
/// Songs in the queue
|
||||
songs: Vec<Child>,
|
||||
/// Current position in the queue (None = stopped)
|
||||
position: Option<usize>,
|
||||
}
|
||||
|
||||
impl PlayQueue {
|
||||
/// Create a new empty queue
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get the songs in the queue
|
||||
pub fn songs(&self) -> &[Child] {
|
||||
&self.songs
|
||||
}
|
||||
|
||||
/// Get the current position
|
||||
pub fn position(&self) -> Option<usize> {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// Get the current song
|
||||
pub fn current(&self) -> Option<&Child> {
|
||||
self.position.and_then(|pos| self.songs.get(pos))
|
||||
}
|
||||
|
||||
/// Get number of songs in queue
|
||||
pub fn len(&self) -> usize {
|
||||
self.songs.len()
|
||||
}
|
||||
|
||||
/// Check if queue is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.songs.is_empty()
|
||||
}
|
||||
|
||||
/// Add songs to the end of the queue
|
||||
pub fn append(&mut self, songs: impl IntoIterator<Item = Child>) {
|
||||
self.songs.extend(songs);
|
||||
debug!("Queue now has {} songs", self.songs.len());
|
||||
}
|
||||
|
||||
/// Insert songs after the current position
|
||||
pub fn insert_next(&mut self, songs: impl IntoIterator<Item = Child>) {
|
||||
let insert_pos = self.position.map(|p| p + 1).unwrap_or(0);
|
||||
let new_songs: Vec<_> = songs.into_iter().collect();
|
||||
let count = new_songs.len();
|
||||
|
||||
for (i, song) in new_songs.into_iter().enumerate() {
|
||||
self.songs.insert(insert_pos + i, song);
|
||||
}
|
||||
|
||||
debug!("Inserted {} songs at position {}", count, insert_pos);
|
||||
}
|
||||
|
||||
/// Clear the queue
|
||||
pub fn clear(&mut self) {
|
||||
self.songs.clear();
|
||||
self.position = None;
|
||||
debug!("Queue cleared");
|
||||
}
|
||||
|
||||
/// Remove song at index
|
||||
pub fn remove(&mut self, index: usize) -> Option<Child> {
|
||||
if index >= self.songs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let song = self.songs.remove(index);
|
||||
|
||||
// Adjust position if needed
|
||||
if let Some(pos) = self.position {
|
||||
if index < pos {
|
||||
self.position = Some(pos - 1);
|
||||
} else if index == pos {
|
||||
// Removed current song
|
||||
if self.songs.is_empty() {
|
||||
self.position = None;
|
||||
} else if pos >= self.songs.len() {
|
||||
self.position = Some(self.songs.len() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Removed song at index {}", index);
|
||||
Some(song)
|
||||
}
|
||||
|
||||
/// Move song from one position to another
|
||||
pub fn move_song(&mut self, from: usize, to: usize) {
|
||||
if from >= self.songs.len() || to >= self.songs.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let song = self.songs.remove(from);
|
||||
self.songs.insert(to, song);
|
||||
|
||||
// Adjust position if needed
|
||||
if let Some(pos) = self.position {
|
||||
if from == pos {
|
||||
self.position = Some(to);
|
||||
} else if from < pos && to >= pos {
|
||||
self.position = Some(pos - 1);
|
||||
} else if from > pos && to <= pos {
|
||||
self.position = Some(pos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Moved song from {} to {}", from, to);
|
||||
}
|
||||
|
||||
/// Shuffle the queue, keeping current song in place
|
||||
pub fn shuffle(&mut self) {
|
||||
if self.songs.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
if let Some(pos) = self.position {
|
||||
// Keep current song, shuffle the rest
|
||||
let current = self.songs.remove(pos);
|
||||
|
||||
// Shuffle remaining songs
|
||||
self.songs.shuffle(&mut rng);
|
||||
|
||||
// Put current song at the front
|
||||
self.songs.insert(0, current);
|
||||
self.position = Some(0);
|
||||
} else {
|
||||
// No current song, shuffle everything
|
||||
self.songs.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
debug!("Queue shuffled");
|
||||
}
|
||||
|
||||
/// Set current position
|
||||
pub fn set_position(&mut self, position: Option<usize>) {
|
||||
if let Some(pos) = position {
|
||||
if pos < self.songs.len() {
|
||||
self.position = Some(pos);
|
||||
debug!("Position set to {}", pos);
|
||||
}
|
||||
} else {
|
||||
self.position = None;
|
||||
debug!("Position cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to next song
|
||||
/// Returns true if there was a next song
|
||||
pub fn next(&mut self) -> bool {
|
||||
match self.position {
|
||||
Some(pos) if pos + 1 < self.songs.len() => {
|
||||
self.position = Some(pos + 1);
|
||||
debug!("Advanced to position {}", pos + 1);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.position = None;
|
||||
debug!("Reached end of queue");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to previous song
|
||||
/// Returns true if there was a previous song
|
||||
pub fn previous(&mut self) -> bool {
|
||||
match self.position {
|
||||
Some(pos) if pos > 0 => {
|
||||
self.position = Some(pos - 1);
|
||||
debug!("Went back to position {}", pos - 1);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if !self.songs.is_empty() {
|
||||
self.position = Some(0);
|
||||
}
|
||||
debug!("At start of queue");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream URL for current song
|
||||
pub fn current_stream_url(&self, client: &SubsonicClient) -> Option<String> {
|
||||
self.current()
|
||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
||||
}
|
||||
|
||||
/// Get stream URL for song at index
|
||||
pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option<String> {
|
||||
self.songs
|
||||
.get(index)
|
||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_song(id: &str, title: &str) -> Child {
|
||||
Child {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
parent: None,
|
||||
is_dir: false,
|
||||
album: None,
|
||||
artist: None,
|
||||
track: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
cover_art: None,
|
||||
size: None,
|
||||
content_type: None,
|
||||
suffix: None,
|
||||
duration: None,
|
||||
bit_rate: None,
|
||||
path: None,
|
||||
disc_number: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_and_len() {
|
||||
let mut queue = PlayQueue::new();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]);
|
||||
assert_eq!(queue.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_and_navigation() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
|
||||
assert!(queue.current().is_none());
|
||||
|
||||
queue.set_position(Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "1");
|
||||
|
||||
assert!(queue.next());
|
||||
assert_eq!(queue.current().unwrap().id, "2");
|
||||
|
||||
assert!(queue.next());
|
||||
assert_eq!(queue.current().unwrap().id, "3");
|
||||
|
||||
assert!(!queue.next());
|
||||
assert!(queue.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
queue.set_position(Some(1));
|
||||
|
||||
// Remove song before current
|
||||
queue.remove(0);
|
||||
assert_eq!(queue.position(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "2");
|
||||
|
||||
// Remove current song
|
||||
queue.remove(0);
|
||||
assert_eq!(queue.current().unwrap().id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_next() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]);
|
||||
queue.set_position(Some(0));
|
||||
|
||||
queue.insert_next(vec![make_song("2", "Song 2")]);
|
||||
|
||||
assert_eq!(queue.songs[0].id, "1");
|
||||
assert_eq!(queue.songs[1].id, "2");
|
||||
assert_eq!(queue.songs[2].id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_song() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
queue.set_position(Some(0));
|
||||
|
||||
queue.move_song(0, 2);
|
||||
assert_eq!(queue.songs[0].id, "2");
|
||||
assert_eq!(queue.songs[1].id, "3");
|
||||
assert_eq!(queue.songs[2].id, "1");
|
||||
assert_eq!(queue.position(), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,17 @@ pub struct Config {
|
||||
/// Enable cava audio visualizer
|
||||
#[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()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! MPRIS2 D-Bus integration module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod server;
|
||||
|
||||
@@ -297,77 +297,23 @@ impl SubsonicClient {
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Parse song ID from a stream URL
|
||||
///
|
||||
/// Useful for session restoration
|
||||
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
parsed
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "id")
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
/// Get cover art URL for a given cover art ID
|
||||
pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result<String, SubsonicError> {
|
||||
let (salt, token) = generate_auth_params(&self.password);
|
||||
let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("id", cover_art_id)
|
||||
.append_pair("u", &self.username)
|
||||
.append_pair("t", &token)
|
||||
.append_pair("s", &salt)
|
||||
.append_pair("v", API_VERSION)
|
||||
.append_pair("c", CLIENT_NAME);
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Search for artists, albums, and songs
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<(Vec<Artist>, Vec<Album>, Vec<Child>), SubsonicError> {
|
||||
let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?;
|
||||
debug!("Searching: {}", query);
|
||||
|
||||
let response = self.http.get(url).send().await?;
|
||||
let text = response.text().await?;
|
||||
|
||||
let parsed: SubsonicResponse<SearchResult3Data> = serde_json::from_str(&text)
|
||||
.map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?;
|
||||
|
||||
if parsed.subsonic_response.status != "ok" {
|
||||
if let Some(error) = parsed.subsonic_response.error {
|
||||
return Err(SubsonicError::Api {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result = parsed
|
||||
.subsonic_response
|
||||
.data
|
||||
.ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))?
|
||||
.search_result3;
|
||||
|
||||
debug!(
|
||||
"Search found {} artists, {} albums, {} songs",
|
||||
result.artist.len(),
|
||||
result.album.len(),
|
||||
result.song.len()
|
||||
);
|
||||
|
||||
Ok((result.artist, result.album, result.song))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl SubsonicClient {
|
||||
/// Parse song ID from a stream URL
|
||||
fn parse_song_id_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
parsed
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "id")
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Subsonic API client module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
270
src/ui/theme.rs
270
src/ui/theme.rs
@@ -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
267
src/ui/theme_builtins.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
//! Built-in theme TOML definitions seeded into ~/.config/ferrosonic/themes/
|
||||
|
||||
pub(super) const BUILTIN_THEMES: &[(&str, &str)] = &[
|
||||
("monokai.toml", r##"[colors]
|
||||
primary = "#a6e22e"
|
||||
secondary = "#75715e"
|
||||
accent = "#fd971f"
|
||||
artist = "#a6e22e"
|
||||
album = "#f92672"
|
||||
song = "#e6db74"
|
||||
muted = "#75715e"
|
||||
highlight_bg = "#49483e"
|
||||
highlight_fg = "#f8f8f2"
|
||||
success = "#a6e22e"
|
||||
error = "#f92672"
|
||||
playing = "#fd971f"
|
||||
played = "#75715e"
|
||||
border_focused = "#a6e22e"
|
||||
border_unfocused = "#49483e"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
|
||||
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
|
||||
"##),
|
||||
|
||||
("dracula.toml", r##"[colors]
|
||||
primary = "#bd93f9"
|
||||
secondary = "#6272a4"
|
||||
accent = "#ffb86c"
|
||||
artist = "#50fa7b"
|
||||
album = "#ff79c6"
|
||||
song = "#8be9fd"
|
||||
muted = "#6272a4"
|
||||
highlight_bg = "#44475a"
|
||||
highlight_fg = "#f8f8f2"
|
||||
success = "#50fa7b"
|
||||
error = "#ff5555"
|
||||
playing = "#ffb86c"
|
||||
played = "#6272a4"
|
||||
border_focused = "#bd93f9"
|
||||
border_unfocused = "#44475a"
|
||||
|
||||
[cava]
|
||||
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
|
||||
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
|
||||
"##),
|
||||
|
||||
("nord.toml", r##"[colors]
|
||||
primary = "#88c0d0"
|
||||
secondary = "#4c566a"
|
||||
accent = "#ebcb8b"
|
||||
artist = "#a3be8c"
|
||||
album = "#b48ead"
|
||||
song = "#88c0d0"
|
||||
muted = "#4c566a"
|
||||
highlight_bg = "#434c5e"
|
||||
highlight_fg = "#eceff4"
|
||||
success = "#a3be8c"
|
||||
error = "#bf616a"
|
||||
playing = "#ebcb8b"
|
||||
played = "#4c566a"
|
||||
border_focused = "#88c0d0"
|
||||
border_unfocused = "#3b4252"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
|
||||
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
|
||||
"##),
|
||||
|
||||
("gruvbox.toml", r##"[colors]
|
||||
primary = "#d79921"
|
||||
secondary = "#928374"
|
||||
accent = "#fe8019"
|
||||
artist = "#b8bb26"
|
||||
album = "#d3869b"
|
||||
song = "#83a598"
|
||||
muted = "#928374"
|
||||
highlight_bg = "#504945"
|
||||
highlight_fg = "#ebdbb2"
|
||||
success = "#b8bb26"
|
||||
error = "#fb4934"
|
||||
playing = "#fe8019"
|
||||
played = "#928374"
|
||||
border_focused = "#d79921"
|
||||
border_unfocused = "#3c3836"
|
||||
|
||||
[cava]
|
||||
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
|
||||
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
|
||||
"##),
|
||||
|
||||
("catppuccin.toml", r##"[colors]
|
||||
primary = "#89b4fa"
|
||||
secondary = "#585b70"
|
||||
accent = "#f9e2af"
|
||||
artist = "#a6e3a1"
|
||||
album = "#f5c2e7"
|
||||
song = "#94e2d5"
|
||||
muted = "#6c7086"
|
||||
highlight_bg = "#45475a"
|
||||
highlight_fg = "#cdd6f4"
|
||||
success = "#a6e3a1"
|
||||
error = "#f38ba8"
|
||||
playing = "#f9e2af"
|
||||
played = "#6c7086"
|
||||
border_focused = "#89b4fa"
|
||||
border_unfocused = "#45475a"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
|
||||
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
|
||||
"##),
|
||||
|
||||
("solarized.toml", r##"[colors]
|
||||
primary = "#268bd2"
|
||||
secondary = "#586e75"
|
||||
accent = "#b58900"
|
||||
artist = "#859900"
|
||||
album = "#d33682"
|
||||
song = "#2aa198"
|
||||
muted = "#586e75"
|
||||
highlight_bg = "#073642"
|
||||
highlight_fg = "#eee8d5"
|
||||
success = "#859900"
|
||||
error = "#dc322f"
|
||||
playing = "#b58900"
|
||||
played = "#586e75"
|
||||
border_focused = "#268bd2"
|
||||
border_unfocused = "#073642"
|
||||
|
||||
[cava]
|
||||
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
|
||||
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
|
||||
"##),
|
||||
|
||||
("tokyo-night.toml", r##"[colors]
|
||||
primary = "#7aa2f7"
|
||||
secondary = "#3d59a1"
|
||||
accent = "#e0af68"
|
||||
artist = "#9ece6a"
|
||||
album = "#bb9af7"
|
||||
song = "#7dcfff"
|
||||
muted = "#565f89"
|
||||
highlight_bg = "#292e42"
|
||||
highlight_fg = "#c0caf5"
|
||||
success = "#9ece6a"
|
||||
error = "#f7768e"
|
||||
playing = "#e0af68"
|
||||
played = "#565f89"
|
||||
border_focused = "#7aa2f7"
|
||||
border_unfocused = "#292e42"
|
||||
|
||||
[cava]
|
||||
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
|
||||
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
|
||||
"##),
|
||||
|
||||
("rose-pine.toml", r##"[colors]
|
||||
primary = "#c4a7e7"
|
||||
secondary = "#6e6a86"
|
||||
accent = "#f6c177"
|
||||
artist = "#9ccfd8"
|
||||
album = "#ebbcba"
|
||||
song = "#31748f"
|
||||
muted = "#6e6a86"
|
||||
highlight_bg = "#393552"
|
||||
highlight_fg = "#e0def4"
|
||||
success = "#9ccfd8"
|
||||
error = "#eb6f92"
|
||||
playing = "#f6c177"
|
||||
played = "#6e6a86"
|
||||
border_focused = "#c4a7e7"
|
||||
border_unfocused = "#393552"
|
||||
|
||||
[cava]
|
||||
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
|
||||
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
|
||||
"##),
|
||||
|
||||
("everforest.toml", r##"[colors]
|
||||
primary = "#a7c080"
|
||||
secondary = "#859289"
|
||||
accent = "#dbbc7f"
|
||||
artist = "#83c092"
|
||||
album = "#d699b6"
|
||||
song = "#7fbbb3"
|
||||
muted = "#859289"
|
||||
highlight_bg = "#505851"
|
||||
highlight_fg = "#d3c6aa"
|
||||
success = "#a7c080"
|
||||
error = "#e67e80"
|
||||
playing = "#dbbc7f"
|
||||
played = "#859289"
|
||||
border_focused = "#a7c080"
|
||||
border_unfocused = "#505851"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
|
||||
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
|
||||
"##),
|
||||
|
||||
("kanagawa.toml", r##"[colors]
|
||||
primary = "#7e9cd8"
|
||||
secondary = "#54546d"
|
||||
accent = "#e6c384"
|
||||
artist = "#98bb6c"
|
||||
album = "#957fb8"
|
||||
song = "#7fb4ca"
|
||||
muted = "#727169"
|
||||
highlight_bg = "#363646"
|
||||
highlight_fg = "#dcd7ba"
|
||||
success = "#98bb6c"
|
||||
error = "#ff5d62"
|
||||
playing = "#e6c384"
|
||||
played = "#727169"
|
||||
border_focused = "#7e9cd8"
|
||||
border_unfocused = "#363646"
|
||||
|
||||
[cava]
|
||||
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
|
||||
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
|
||||
"##),
|
||||
|
||||
("one-dark.toml", r##"[colors]
|
||||
primary = "#61afef"
|
||||
secondary = "#5c6370"
|
||||
accent = "#e5c07b"
|
||||
artist = "#98c379"
|
||||
album = "#c678dd"
|
||||
song = "#56b6c2"
|
||||
muted = "#5c6370"
|
||||
highlight_bg = "#3e4451"
|
||||
highlight_fg = "#abb2bf"
|
||||
success = "#98c379"
|
||||
error = "#e06c75"
|
||||
playing = "#e5c07b"
|
||||
played = "#5c6370"
|
||||
border_focused = "#61afef"
|
||||
border_unfocused = "#3e4451"
|
||||
|
||||
[cava]
|
||||
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
|
||||
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
|
||||
"##),
|
||||
|
||||
("ayu-dark.toml", r##"[colors]
|
||||
primary = "#59c2ff"
|
||||
secondary = "#6b788a"
|
||||
accent = "#e6b450"
|
||||
artist = "#aad94c"
|
||||
album = "#d2a6ff"
|
||||
song = "#95e6cb"
|
||||
muted = "#6b788a"
|
||||
highlight_bg = "#2f3846"
|
||||
highlight_fg = "#bfc7d5"
|
||||
success = "#aad94c"
|
||||
error = "#f07178"
|
||||
playing = "#e6b450"
|
||||
played = "#6b788a"
|
||||
border_focused = "#59c2ff"
|
||||
border_unfocused = "#2f3846"
|
||||
|
||||
[cava]
|
||||
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
|
||||
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
|
||||
"##),
|
||||
];
|
||||
Reference in New Issue
Block a user