Initial commit — ferrosonic terminal Subsonic client
Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE.md2
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Config (may contain credentials)
|
||||||
|
config.toml
|
||||||
3270
Cargo.lock
generated
Normal file
3270
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
[package]
|
||||||
|
name = "ferrosonic"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A terminal-based Subsonic music client with bit-perfect audio playback"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["ferrosonic contributors"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full", "sync", "process", "signal"] }
|
||||||
|
|
||||||
|
# Terminal UI
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tui-tree-widget = "0.22"
|
||||||
|
|
||||||
|
# HTTP client for Subsonic API
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# D-Bus / MPRIS
|
||||||
|
zbus = "5"
|
||||||
|
mpris-server = "0.8"
|
||||||
|
|
||||||
|
# Unix utilities
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
dirs = "6"
|
||||||
|
url = "2"
|
||||||
|
urlencoding = "2"
|
||||||
|
md5 = "0.7"
|
||||||
|
rand = "0.8"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async channels
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# Terminal emulator for cava integration
|
||||||
|
vt100 = "0.15"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
322
README.md
Normal file
322
README.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Ferrosonic
|
||||||
|
|
||||||
|
A terminal-based Subsonic music client written in Rust, featuring bit-perfect audio playback, gapless transitions, and full desktop integration.
|
||||||
|
|
||||||
|
Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/), the original terminal Subsonic client written in Go by [SixFoisNeuf](https://www.sixfoisneuf.fr/posts/termsonic-a-terminal-client-for-subsonic/). Ferrosonic is a ground-up rewrite in Rust with additional features including PipeWire sample rate switching for bit-perfect audio, MPRIS2 media controls, multiple color themes, and mouse support.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Bit-perfect audio** - Automatic PipeWire sample rate switching to match the source material (44.1kHz, 48kHz, 96kHz, 192kHz, etc.)
|
||||||
|
- **Gapless playback** - Seamless transitions between tracks with pre-buffered next track
|
||||||
|
- **MPRIS2 integration** - Full desktop media control support (play, pause, stop, next, previous, seek)
|
||||||
|
- **Artist/album browser** - Tree-based navigation with expandable artists and album listings
|
||||||
|
- **Playlist support** - Browse and play server playlists with shuffle capability
|
||||||
|
- **Play queue management** - Add, remove, reorder, shuffle, and clear queue history
|
||||||
|
- **Audio quality display** - Real-time display of sample rate, bit depth, codec format, and channel layout
|
||||||
|
- **Audio visualizer** - Integrated cava audio visualizer with theme-matched gradient colors
|
||||||
|
- **13 built-in themes** - Default, Monokai, Dracula, Nord, Gruvbox, Catppuccin, Solarized, Tokyo Night, Rosé Pine, Everforest, Kanagawa, One Dark, and Ayu Dark
|
||||||
|
- **Custom themes** - Create your own themes as TOML files in `~/.config/ferrosonic/themes/`
|
||||||
|
- **Mouse support** - Clickable buttons, tabs, lists, and progress bar seeking
|
||||||
|
- **Artist filtering** - Real-time search/filter on the artist list
|
||||||
|
- **Multi-disc album support** - Proper disc and track number display
|
||||||
|
- **Keyboard-driven** - Vim-style navigation (j/k) alongside arrow keys
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<!-- Add screenshots here -->
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Ferrosonic requires the following at runtime:
|
||||||
|
|
||||||
|
| Dependency | Purpose | Required |
|
||||||
|
|---|---|---|
|
||||||
|
| **mpv** | Audio playback engine (via JSON IPC) | Yes |
|
||||||
|
| **PipeWire** | Automatic sample rate switching for bit-perfect audio | Recommended |
|
||||||
|
| **WirePlumber** | PipeWire session manager | Recommended |
|
||||||
|
| **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` |
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install runtime dependencies
|
||||||
|
sudo pacman -S mpv pipewire wireplumber
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
sudo pacman -S base-devel pkgconf dbus
|
||||||
|
|
||||||
|
# Build from source
|
||||||
|
git clone https://github.com/jaidaken/ferrosonic.git
|
||||||
|
cd ferrosonic
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Binary is at target/release/ferrosonic
|
||||||
|
sudo cp target/release/ferrosonic /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install runtime dependencies
|
||||||
|
sudo dnf install mpv pipewire wireplumber
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
sudo dnf install gcc pkgconf-pkg-config dbus-devel
|
||||||
|
|
||||||
|
# Install Rust toolchain if not already installed
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Build from source
|
||||||
|
git clone https://github.com/jaidaken/ferrosonic.git
|
||||||
|
cd ferrosonic
|
||||||
|
cargo build --release
|
||||||
|
sudo cp target/release/ferrosonic /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ubuntu / Debian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install runtime dependencies
|
||||||
|
sudo apt install mpv pipewire wireplumber
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
sudo apt install build-essential pkg-config libdbus-1-dev
|
||||||
|
|
||||||
|
# Install Rust toolchain if not already installed
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Build from source
|
||||||
|
git clone https://github.com/jaidaken/ferrosonic.git
|
||||||
|
cd ferrosonic
|
||||||
|
cargo build --release
|
||||||
|
sudo cp target/release/ferrosonic /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic (any Linux distribution)
|
||||||
|
|
||||||
|
Ensure `mpv` is installed and available in your `PATH`. PipeWire is optional but required for automatic sample rate switching.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Rust toolchain
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Build
|
||||||
|
git clone https://github.com/jaidaken/ferrosonic.git
|
||||||
|
cd ferrosonic
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo cp target/release/ferrosonic /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with default config (~/.config/ferrosonic/config.toml)
|
||||||
|
ferrosonic
|
||||||
|
|
||||||
|
# Run with a custom config file
|
||||||
|
ferrosonic -c /path/to/config.toml
|
||||||
|
|
||||||
|
# Enable verbose/debug logging
|
||||||
|
ferrosonic -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is stored at `~/.config/ferrosonic/config.toml`. You can edit it manually or configure the server connection through the application's Server page (F4).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
BaseURL = "https://your-subsonic-server.com"
|
||||||
|
Username = "your-username"
|
||||||
|
Password = "your-password"
|
||||||
|
Theme = "Default"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `BaseURL` | URL of your Subsonic-compatible server (Navidrome, Airsonic, Gonic, etc.) |
|
||||||
|
| `Username` | Your server username |
|
||||||
|
| `Password` | Your server password |
|
||||||
|
| `Theme` | Color theme name (e.g. `Default`, `Catppuccin`, `Tokyo Night`) |
|
||||||
|
|
||||||
|
Logs are written to `~/.config/ferrosonic/ferrosonic.log`.
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
### Global
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `q` | Quit |
|
||||||
|
| `p` / `Space` | Toggle play/pause |
|
||||||
|
| `l` | Next track |
|
||||||
|
| `h` | Previous track |
|
||||||
|
| `Ctrl+R` | Refresh data from server |
|
||||||
|
| `t` | Cycle to next theme |
|
||||||
|
| `F1` | Artists page |
|
||||||
|
| `F2` | Queue page |
|
||||||
|
| `F3` | Playlists page |
|
||||||
|
| `F4` | Server configuration page |
|
||||||
|
| `F5` | Settings page |
|
||||||
|
|
||||||
|
### Artists Page (F1)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Filter artists by name |
|
||||||
|
| `Esc` | Clear filter |
|
||||||
|
| `Up` / `k` | Move selection up |
|
||||||
|
| `Down` / `j` | Move selection down |
|
||||||
|
| `Left` / `Right` | Switch focus between tree and song list |
|
||||||
|
| `Enter` | Expand/collapse artist, or play album/song |
|
||||||
|
| `Backspace` | Return to tree from song list |
|
||||||
|
| `e` | Add selected item to end of queue |
|
||||||
|
| `n` | Add selected item as next in queue |
|
||||||
|
|
||||||
|
### Queue Page (F2)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Up` / `k` | Move selection up |
|
||||||
|
| `Down` / `j` | Move selection down |
|
||||||
|
| `Enter` | Play selected song |
|
||||||
|
| `d` | Remove selected song from queue |
|
||||||
|
| `J` (Shift+J) | Move selected song down |
|
||||||
|
| `K` (Shift+K) | Move selected song up |
|
||||||
|
| `r` | Shuffle queue (current song stays in place) |
|
||||||
|
| `c` | Clear played history (remove songs before current) |
|
||||||
|
|
||||||
|
### Playlists Page (F3)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Tab` / `Left` / `Right` | Switch focus between playlists and songs |
|
||||||
|
| `Up` / `k` | Move selection up |
|
||||||
|
| `Down` / `j` | Move selection down |
|
||||||
|
| `Enter` | Load playlist songs or play selected song |
|
||||||
|
| `e` | Add selected item to end of queue |
|
||||||
|
| `n` | Add selected song as next in queue |
|
||||||
|
| `r` | Shuffle play all songs in selected playlist |
|
||||||
|
|
||||||
|
### Server Page (F4)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Tab` | Move between fields |
|
||||||
|
| `Enter` | Test connection or Save configuration |
|
||||||
|
| `Backspace` | Delete character in text field |
|
||||||
|
|
||||||
|
### Settings Page (F5)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Up` / `Down` | Move between settings |
|
||||||
|
| `Left` | Previous option |
|
||||||
|
| `Right` / `Enter` | Next option |
|
||||||
|
|
||||||
|
Settings include theme selection and cava visualizer toggle. Changes are saved automatically.
|
||||||
|
|
||||||
|
## Mouse Support
|
||||||
|
|
||||||
|
- Click page tabs in the header to switch pages
|
||||||
|
- Click playback control buttons (Previous, Play, Pause, Stop, Next) in the header
|
||||||
|
- Click items in lists to select them
|
||||||
|
- Click the progress bar in the Now Playing widget to seek
|
||||||
|
|
||||||
|
## Audio Features
|
||||||
|
|
||||||
|
### Bit-Perfect Playback
|
||||||
|
|
||||||
|
Ferrosonic uses PipeWire's `pw-metadata` to automatically switch the system sample rate to match the source material. When a track at 96kHz starts playing, PipeWire is instructed to output at 96kHz, avoiding unnecessary resampling. The original sample rate is restored when the application exits.
|
||||||
|
|
||||||
|
### Gapless Playback
|
||||||
|
|
||||||
|
The next track in the queue is pre-loaded into MPV's internal playlist before the current track finishes, allowing seamless transitions with no gap or click between songs.
|
||||||
|
|
||||||
|
### Now Playing Display
|
||||||
|
|
||||||
|
The Now Playing widget shows:
|
||||||
|
- Artist, album, and track title
|
||||||
|
- Audio quality: format/codec, bit depth, sample rate, and channel layout
|
||||||
|
- Visual progress bar with elapsed/total time
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
Ferrosonic ships with 13 themes. On first run, the built-in themes are written as TOML files to `~/.config/ferrosonic/themes/`.
|
||||||
|
|
||||||
|
| Theme | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Default** | Cyan/yellow on dark background (hardcoded) |
|
||||||
|
| **Monokai** | Classic Monokai syntax highlighting palette |
|
||||||
|
| **Dracula** | Purple/pink Dracula color scheme |
|
||||||
|
| **Nord** | Arctic blue Nord palette |
|
||||||
|
| **Gruvbox** | Warm retro Gruvbox colors |
|
||||||
|
| **Catppuccin** | Soothing pastel Catppuccin Mocha palette |
|
||||||
|
| **Solarized** | Ethan Schoonover's Solarized Dark |
|
||||||
|
| **Tokyo Night** | Dark Tokyo Night color scheme |
|
||||||
|
| **Rosé Pine** | Soho vibes Rosé Pine palette |
|
||||||
|
| **Everforest** | Comfortable green Everforest Dark |
|
||||||
|
| **Kanagawa** | Dark Kanagawa wave palette |
|
||||||
|
| **One Dark** | Atom One Dark color scheme |
|
||||||
|
| **Ayu Dark** | Ayu Dark color scheme |
|
||||||
|
|
||||||
|
Change themes with `t` from any page, from the Settings page (F5), or by editing the `Theme` field in `config.toml`.
|
||||||
|
|
||||||
|
### Custom Themes
|
||||||
|
|
||||||
|
Create a `.toml` file in `~/.config/ferrosonic/themes/` and it will appear in the theme list. The filename becomes the display name (e.g. `my-theme.toml` becomes "My Theme").
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also edit the built-in theme files to customize them. They will not be overwritten unless deleted.
|
||||||
|
|
||||||
|
## Compatible Servers
|
||||||
|
|
||||||
|
Ferrosonic works with any server implementing the Subsonic API, including:
|
||||||
|
|
||||||
|
- [Navidrome](https://www.navidrome.org/)
|
||||||
|
- [Airsonic](https://airsonic.github.io/)
|
||||||
|
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||||
|
- [Gonic](https://github.com/sentriz/gonic)
|
||||||
|
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/) by SixFoisNeuf, a terminal Subsonic client written in Go. Ferrosonic builds on that concept with a Rust implementation, bit-perfect audio via PipeWire, and additional features.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
111
src/app/actions.rs
Normal file
111
src/app/actions.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//! 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
|
||||||
|
Resume,
|
||||||
|
/// Toggle pause state
|
||||||
|
TogglePause,
|
||||||
|
/// Stop playback
|
||||||
|
Stop,
|
||||||
|
/// Seek to position (seconds)
|
||||||
|
Seek(f64),
|
||||||
|
/// Seek relative to current position
|
||||||
|
SeekRelative(f64),
|
||||||
|
/// Skip to next track
|
||||||
|
Next,
|
||||||
|
/// Skip to previous track
|
||||||
|
Previous,
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
2526
src/app/mod.rs
Normal file
2526
src/app/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
399
src/app/state.rs
Normal file
399
src/app/state.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
//! Shared application state
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::subsonic::models::{Album, Artist, Child, Playlist};
|
||||||
|
use crate::ui::theme::{ThemeColors, ThemeData};
|
||||||
|
|
||||||
|
/// Current page in the application
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Page {
|
||||||
|
#[default]
|
||||||
|
Artists,
|
||||||
|
Queue,
|
||||||
|
Playlists,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
pub fn index(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Page::Artists => 0,
|
||||||
|
Page::Queue => 1,
|
||||||
|
Page::Playlists => 2,
|
||||||
|
Page::Server => 3,
|
||||||
|
Page::Settings => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Page::Artists => "Artists",
|
||||||
|
Page::Queue => "Queue",
|
||||||
|
Page::Playlists => "Playlists",
|
||||||
|
Page::Server => "Server",
|
||||||
|
Page::Settings => "Settings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shortcut(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Page::Artists => "F1",
|
||||||
|
Page::Queue => "F2",
|
||||||
|
Page::Playlists => "F3",
|
||||||
|
Page::Server => "F4",
|
||||||
|
Page::Settings => "F5",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback state
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum PlaybackState {
|
||||||
|
#[default]
|
||||||
|
Stopped,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Now playing information
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct NowPlaying {
|
||||||
|
/// Currently playing song
|
||||||
|
pub song: Option<Child>,
|
||||||
|
/// Playback state
|
||||||
|
pub state: PlaybackState,
|
||||||
|
/// Current position in seconds
|
||||||
|
pub position: f64,
|
||||||
|
/// Total duration in seconds
|
||||||
|
pub duration: f64,
|
||||||
|
/// Audio sample rate (Hz)
|
||||||
|
pub sample_rate: Option<u32>,
|
||||||
|
/// Audio bit depth
|
||||||
|
pub bit_depth: Option<u32>,
|
||||||
|
/// Audio format/codec
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// Audio channel layout (e.g., "Stereo", "Mono", "5.1ch")
|
||||||
|
pub channels: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NowPlaying {
|
||||||
|
pub fn progress_percent(&self) -> f64 {
|
||||||
|
if self.duration > 0.0 {
|
||||||
|
(self.position / self.duration).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_position(&self) -> String {
|
||||||
|
format_duration(self.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_duration(&self) -> String {
|
||||||
|
format_duration(self.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format duration in MM:SS or HH:MM:SS format
|
||||||
|
pub fn format_duration(seconds: f64) -> String {
|
||||||
|
let total_secs = seconds as u64;
|
||||||
|
let hours = total_secs / 3600;
|
||||||
|
let mins = (total_secs % 3600) / 60;
|
||||||
|
let secs = total_secs % 60;
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, mins, secs)
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", mins, secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artists page state
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ArtistsState {
|
||||||
|
/// List of all artists
|
||||||
|
pub artists: Vec<Artist>,
|
||||||
|
/// Currently selected index in the tree (artists + expanded albums)
|
||||||
|
pub selected_index: Option<usize>,
|
||||||
|
/// Set of expanded artist IDs
|
||||||
|
pub expanded: std::collections::HashSet<String>,
|
||||||
|
/// Albums cached per artist ID
|
||||||
|
pub albums_cache: std::collections::HashMap<String, Vec<Album>>,
|
||||||
|
/// Songs in the selected album (shown in right pane)
|
||||||
|
pub songs: Vec<Child>,
|
||||||
|
/// Currently selected song index
|
||||||
|
pub selected_song: Option<usize>,
|
||||||
|
/// Artist filter text
|
||||||
|
pub filter: String,
|
||||||
|
/// Whether filter input is active
|
||||||
|
pub filter_active: bool,
|
||||||
|
/// Focus: 0 = tree, 1 = songs
|
||||||
|
pub focus: usize,
|
||||||
|
/// Scroll offset for the tree list (set after render)
|
||||||
|
pub tree_scroll_offset: usize,
|
||||||
|
/// Scroll offset for the songs list (set after render)
|
||||||
|
pub song_scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue page state
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct QueueState {
|
||||||
|
/// Currently selected index in the queue
|
||||||
|
pub selected: Option<usize>,
|
||||||
|
/// Scroll offset for the queue list (set after render)
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlists page state
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PlaylistsState {
|
||||||
|
/// List of all playlists
|
||||||
|
pub playlists: Vec<Playlist>,
|
||||||
|
/// Currently selected playlist index
|
||||||
|
pub selected_playlist: Option<usize>,
|
||||||
|
/// Songs in the selected playlist
|
||||||
|
pub songs: Vec<Child>,
|
||||||
|
/// Currently selected song index
|
||||||
|
pub selected_song: Option<usize>,
|
||||||
|
/// Focus: 0 = playlists, 1 = songs
|
||||||
|
pub focus: usize,
|
||||||
|
/// Scroll offset for the playlists list (set after render)
|
||||||
|
pub playlist_scroll_offset: usize,
|
||||||
|
/// Scroll offset for the songs list (set after render)
|
||||||
|
pub song_scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server page state (connection settings)
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ServerState {
|
||||||
|
/// Currently focused field (0-4: URL, Username, Password, Test, Save)
|
||||||
|
pub selected_field: usize,
|
||||||
|
/// Edit values
|
||||||
|
pub base_url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
/// Status message
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings page state
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SettingsState {
|
||||||
|
/// Currently focused field (0=Theme, 1=Cava)
|
||||||
|
pub selected_field: usize,
|
||||||
|
/// Available themes (Default + loaded from files)
|
||||||
|
pub themes: Vec<ThemeData>,
|
||||||
|
/// Index of the currently selected theme in `themes`
|
||||||
|
pub theme_index: usize,
|
||||||
|
/// Cava visualizer enabled
|
||||||
|
pub cava_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SettingsState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected_field: 0,
|
||||||
|
themes: vec![ThemeData::default_theme()],
|
||||||
|
theme_index: 0,
|
||||||
|
cava_enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsState {
|
||||||
|
/// Current theme name
|
||||||
|
pub fn theme_name(&self) -> &str {
|
||||||
|
&self.themes[self.theme_index].name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current theme colors
|
||||||
|
pub fn theme_colors(&self) -> &ThemeColors {
|
||||||
|
&self.themes[self.theme_index].colors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current theme data
|
||||||
|
pub fn current_theme(&self) -> &ThemeData {
|
||||||
|
&self.themes[self.theme_index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle to next theme
|
||||||
|
pub fn next_theme(&mut self) {
|
||||||
|
self.theme_index = (self.theme_index + 1) % self.themes.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle to previous theme
|
||||||
|
pub fn prev_theme(&mut self) {
|
||||||
|
self.theme_index = (self.theme_index + self.themes.len() - 1) % self.themes.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set theme by name, returning true if found
|
||||||
|
pub fn set_theme_by_name(&mut self, name: &str) -> bool {
|
||||||
|
if let Some(idx) = self.themes.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
|
||||||
|
self.theme_index = idx;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
self.theme_index = 0; // Fall back to Default
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notification/alert to display
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub message: String,
|
||||||
|
pub is_error: bool,
|
||||||
|
pub created_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached layout rectangles from the last render, used for mouse hit-testing.
|
||||||
|
/// Automatically updated every frame, so resize and visualiser toggle are handled.
|
||||||
|
#[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)
|
||||||
|
pub content_right: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete application state
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// Application configuration
|
||||||
|
pub config: Config,
|
||||||
|
/// Current page
|
||||||
|
pub page: Page,
|
||||||
|
/// Now playing information
|
||||||
|
pub now_playing: NowPlaying,
|
||||||
|
/// Play queue (songs)
|
||||||
|
pub queue: Vec<Child>,
|
||||||
|
/// Current position in queue
|
||||||
|
pub queue_position: Option<usize>,
|
||||||
|
/// Artists page state
|
||||||
|
pub artists: ArtistsState,
|
||||||
|
/// Queue page state
|
||||||
|
pub queue_state: QueueState,
|
||||||
|
/// Playlists page state
|
||||||
|
pub playlists: PlaylistsState,
|
||||||
|
/// Server page state (connection settings)
|
||||||
|
pub server_state: ServerState,
|
||||||
|
/// Settings page state (app preferences)
|
||||||
|
pub settings_state: SettingsState,
|
||||||
|
/// Current notification
|
||||||
|
pub notification: Option<Notification>,
|
||||||
|
/// Whether the app should quit
|
||||||
|
pub should_quit: bool,
|
||||||
|
/// Cava visualizer screen content (rows of styled spans)
|
||||||
|
pub cava_screen: Vec<CavaRow>,
|
||||||
|
/// Whether the cava binary is available on the system
|
||||||
|
pub cava_available: bool,
|
||||||
|
/// Cached layout areas from last render (for mouse hit-testing)
|
||||||
|
pub layout: LayoutAreas,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of styled segments from cava's terminal output
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CavaRow {
|
||||||
|
pub spans: Vec<CavaSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A styled text segment from cava's terminal output
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CavaSpan {
|
||||||
|
pub text: String,
|
||||||
|
pub fg: CavaColor,
|
||||||
|
pub bg: CavaColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color from cava's terminal output
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||||
|
pub enum CavaColor {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Indexed(u8),
|
||||||
|
Rgb(u8, u8, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let mut state = Self {
|
||||||
|
config: config.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Initialize server page with current values
|
||||||
|
state.server_state.base_url = config.base_url.clone();
|
||||||
|
state.server_state.username = config.username.clone();
|
||||||
|
state.server_state.password = config.password.clone();
|
||||||
|
// Initialize cava from config
|
||||||
|
state.settings_state.cava_enabled = config.cava;
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently playing song from the queue
|
||||||
|
pub fn current_song(&self) -> Option<&Child> {
|
||||||
|
self.queue_position.and_then(|pos| self.queue.get(pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a notification
|
||||||
|
pub fn notify(&mut self, message: impl Into<String>) {
|
||||||
|
self.notification = Some(Notification {
|
||||||
|
message: message.into(),
|
||||||
|
is_error: false,
|
||||||
|
created_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show an error notification
|
||||||
|
pub fn notify_error(&mut self, message: impl Into<String>) {
|
||||||
|
self.notification = Some(Notification {
|
||||||
|
message: message.into(),
|
||||||
|
is_error: true,
|
||||||
|
created_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if notification should be auto-cleared (after 2 seconds)
|
||||||
|
pub fn check_notification_timeout(&mut self) {
|
||||||
|
if let Some(ref notif) = self.notification {
|
||||||
|
if notif.created_at.elapsed().as_secs() >= 2 {
|
||||||
|
self.notification = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the notification
|
||||||
|
pub fn clear_notification(&mut self) {
|
||||||
|
self.notification = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe shared state
|
||||||
|
pub type SharedState = Arc<RwLock<AppState>>;
|
||||||
|
|
||||||
|
/// Create new shared state
|
||||||
|
pub fn new_shared_state(config: Config) -> SharedState {
|
||||||
|
Arc::new(RwLock::new(AppState::new(config)))
|
||||||
|
}
|
||||||
7
src/audio/mod.rs
Normal file
7
src/audio/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! Audio playback module
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod mpv;
|
||||||
|
pub mod pipewire;
|
||||||
|
pub mod queue;
|
||||||
434
src/audio/mpv.rs
Normal file
434
src/audio/mpv.rs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
//! MPV controller via JSON IPC
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
|
use crate::config::paths::mpv_socket_path;
|
||||||
|
use crate::error::AudioError;
|
||||||
|
|
||||||
|
/// MPV IPC command
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct MpvCommand {
|
||||||
|
command: Vec<Value>,
|
||||||
|
request_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPV IPC response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MpvResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
request_id: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
data: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPV event
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MpvEvent {
|
||||||
|
event: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
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
|
||||||
|
socket_path: PathBuf,
|
||||||
|
/// MPV process handle
|
||||||
|
process: Option<Child>,
|
||||||
|
/// Request ID counter
|
||||||
|
request_id: AtomicU64,
|
||||||
|
/// Socket connection
|
||||||
|
socket: Option<UnixStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpvController {
|
||||||
|
/// Create a new MPV controller
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
socket_path: mpv_socket_path(),
|
||||||
|
process: None,
|
||||||
|
request_id: AtomicU64::new(1),
|
||||||
|
socket: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start MPV process if not running
|
||||||
|
pub fn start(&mut self) -> Result<(), AudioError> {
|
||||||
|
if self.process.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing socket if present
|
||||||
|
let _ = std::fs::remove_file(&self.socket_path);
|
||||||
|
|
||||||
|
info!("Starting MPV with socket: {}", self.socket_path.display());
|
||||||
|
|
||||||
|
let child = Command::new("mpv")
|
||||||
|
.arg("--idle") // Stay running when nothing playing
|
||||||
|
.arg("--no-video") // Audio only
|
||||||
|
.arg("--no-terminal") // No MPV UI
|
||||||
|
.arg("--gapless-audio=yes") // Gapless playback between tracks
|
||||||
|
.arg("--prefetch-playlist=yes") // Pre-buffer next track
|
||||||
|
.arg("--cache=yes") // Enable cache for network streams
|
||||||
|
.arg("--cache-secs=120") // Cache up to 2 minutes ahead
|
||||||
|
.arg("--demuxer-max-bytes=100MiB") // Allow large demuxer buffer
|
||||||
|
.arg(format!("--input-ipc-server={}", self.socket_path.display()))
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(AudioError::MpvSpawn)?;
|
||||||
|
|
||||||
|
self.process = Some(child);
|
||||||
|
|
||||||
|
// Wait for socket to become available
|
||||||
|
for _ in 0..50 {
|
||||||
|
if self.socket_path.exists() {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.socket_path.exists() {
|
||||||
|
return Err(AudioError::MpvIpc("Socket not created".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connect()?;
|
||||||
|
info!("MPV started successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to the MPV socket
|
||||||
|
fn connect(&mut self) -> Result<(), AudioError> {
|
||||||
|
let stream = UnixStream::connect(&self.socket_path).map_err(AudioError::MpvSocket)?;
|
||||||
|
|
||||||
|
// Set read timeout
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
|
.map_err(AudioError::MpvSocket)?;
|
||||||
|
|
||||||
|
self.socket = Some(stream);
|
||||||
|
debug!("Connected to MPV socket");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if MPV is running
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.socket.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a command to MPV
|
||||||
|
fn send_command(&mut self, args: Vec<Value>) -> Result<Option<Value>, AudioError> {
|
||||||
|
let socket = self.socket.as_mut().ok_or(AudioError::MpvNotRunning)?;
|
||||||
|
|
||||||
|
let request_id = self.request_id.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let cmd = MpvCommand {
|
||||||
|
command: args,
|
||||||
|
request_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&cmd)?;
|
||||||
|
debug!("Sending MPV command: {}", json);
|
||||||
|
|
||||||
|
writeln!(socket, "{}", json).map_err(|e| AudioError::MpvIpc(e.to_string()))?;
|
||||||
|
socket
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| AudioError::MpvIpc(e.to_string()))?;
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
let mut reader = BufReader::new(socket.try_clone().map_err(AudioError::MpvSocket)?);
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => return Err(AudioError::MpvIpc("Socket closed".to_string())),
|
||||||
|
Ok(_) => {
|
||||||
|
if let Ok(resp) = serde_json::from_str::<MpvResponse>(&line) {
|
||||||
|
if resp.request_id == Some(request_id) {
|
||||||
|
if resp.error != "success" {
|
||||||
|
return Err(AudioError::MpvIpc(resp.error));
|
||||||
|
}
|
||||||
|
return Ok(resp.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Log discarded events for diagnostics
|
||||||
|
if let Ok(event) = serde_json::from_str::<MpvEvent>(&line) {
|
||||||
|
trace!("MPV event: {:?}", event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
// Timeout, try again
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(AudioError::MpvIpc(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and play a file/URL (replaces current playlist)
|
||||||
|
pub fn loadfile(&mut self, path: &str) -> Result<(), AudioError> {
|
||||||
|
info!("Loading: {}", path.split('?').next().unwrap_or(path));
|
||||||
|
self.send_command(vec![json!("loadfile"), json!(path), json!("replace")])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a file/URL to the playlist (for gapless playback)
|
||||||
|
pub fn loadfile_append(&mut self, path: &str) -> Result<(), AudioError> {
|
||||||
|
debug!(
|
||||||
|
"Appending to playlist: {}",
|
||||||
|
path.split('?').next().unwrap_or(path)
|
||||||
|
);
|
||||||
|
self.send_command(vec![json!("loadfile"), json!(path), json!("append")])?;
|
||||||
|
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);
|
||||||
|
self.send_command(vec![json!("playlist-remove"), json!(index)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current playlist position (0-indexed)
|
||||||
|
pub fn get_playlist_pos(&mut self) -> Result<Option<i64>, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("playlist-pos")])?;
|
||||||
|
Ok(data.and_then(|v| v.as_i64()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playlist count
|
||||||
|
pub fn get_playlist_count(&mut self) -> Result<usize, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("playlist-count")])?;
|
||||||
|
Ok(data.and_then(|v| v.as_u64()).unwrap_or(0) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause playback
|
||||||
|
pub fn pause(&mut self) -> Result<(), AudioError> {
|
||||||
|
debug!("Pausing playback");
|
||||||
|
self.send_command(vec![json!("set_property"), json!("pause"), json!(true)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume playback
|
||||||
|
pub fn resume(&mut self) -> Result<(), AudioError> {
|
||||||
|
debug!("Resuming playback");
|
||||||
|
self.send_command(vec![json!("set_property"), json!("pause"), json!(false)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle pause
|
||||||
|
pub fn toggle_pause(&mut self) -> Result<bool, AudioError> {
|
||||||
|
let paused = self.is_paused()?;
|
||||||
|
if paused {
|
||||||
|
self.resume()?;
|
||||||
|
} else {
|
||||||
|
self.pause()?;
|
||||||
|
}
|
||||||
|
Ok(!paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if paused
|
||||||
|
pub fn is_paused(&mut self) -> Result<bool, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("pause")])?;
|
||||||
|
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback
|
||||||
|
pub fn stop(&mut self) -> Result<(), AudioError> {
|
||||||
|
debug!("Stopping playback");
|
||||||
|
self.send_command(vec![json!("stop")])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek to position (seconds)
|
||||||
|
pub fn seek(&mut self, position: f64) -> Result<(), AudioError> {
|
||||||
|
debug!("Seeking to {:.1}s", position);
|
||||||
|
self.send_command(vec![json!("seek"), json!(position), json!("absolute")])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek relative to current position
|
||||||
|
pub fn seek_relative(&mut self, offset: f64) -> Result<(), AudioError> {
|
||||||
|
debug!("Seeking {:+.1}s", offset);
|
||||||
|
self.send_command(vec![json!("seek"), json!(offset), json!("relative")])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current playback position in seconds
|
||||||
|
pub fn get_time_pos(&mut self) -> Result<f64, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("time-pos")])?;
|
||||||
|
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total duration in seconds
|
||||||
|
pub fn get_duration(&mut self) -> Result<f64, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("duration")])?;
|
||||||
|
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);
|
||||||
|
self.send_command(vec![
|
||||||
|
json!("set_property"),
|
||||||
|
json!("volume"),
|
||||||
|
json!(volume.clamp(0, 100)),
|
||||||
|
])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio sample rate
|
||||||
|
pub fn get_sample_rate(&mut self) -> Result<Option<u32>, AudioError> {
|
||||||
|
let data = self.send_command(vec![
|
||||||
|
json!("get_property"),
|
||||||
|
json!("audio-params/samplerate"),
|
||||||
|
])?;
|
||||||
|
Ok(data.and_then(|v| v.as_u64()).map(|v| v as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio bit depth
|
||||||
|
pub fn get_bit_depth(&mut self) -> Result<Option<u32>, AudioError> {
|
||||||
|
// MPV returns format string like "s16" or "s32"
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?;
|
||||||
|
let format = data.and_then(|v| v.as_str().map(String::from));
|
||||||
|
|
||||||
|
Ok(format.and_then(|f| {
|
||||||
|
if f.contains("32") || f.contains("float") {
|
||||||
|
Some(32)
|
||||||
|
} else if f.contains("24") {
|
||||||
|
Some(24)
|
||||||
|
} else if f.contains("16") {
|
||||||
|
Some(16)
|
||||||
|
} else if f.contains("8") {
|
||||||
|
Some(8)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio format string
|
||||||
|
pub fn get_audio_format(&mut self) -> Result<Option<String>, AudioError> {
|
||||||
|
let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?;
|
||||||
|
Ok(data.and_then(|v| v.as_str().map(String::from)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio channel layout
|
||||||
|
pub fn get_channels(&mut self) -> Result<Option<String>, AudioError> {
|
||||||
|
let data = self.send_command(vec![
|
||||||
|
json!("get_property"),
|
||||||
|
json!("audio-params/channel-count"),
|
||||||
|
])?;
|
||||||
|
let count = data.and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||||
|
|
||||||
|
Ok(count.map(|c| match c {
|
||||||
|
1 => "Mono".to_string(),
|
||||||
|
2 => "Stereo".to_string(),
|
||||||
|
n => format!("{}ch", n),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
let _ = self.send_command(vec![json!("quit")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut child) = self.process.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.socket = None;
|
||||||
|
let _ = std::fs::remove_file(&self.socket_path);
|
||||||
|
|
||||||
|
info!("MPV shut down");
|
||||||
|
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 {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MpvController {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/audio/pipewire.rs
Normal file
202
src/audio/pipewire.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//! PipeWire sample rate control
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
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
|
||||||
|
original_rate: Option<u32>,
|
||||||
|
/// Current forced sample rate
|
||||||
|
current_rate: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeWireController {
|
||||||
|
/// Create a new PipeWire controller
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let original_rate = Self::get_current_rate_internal().ok();
|
||||||
|
debug!("Original PipeWire sample rate: {:?}", original_rate);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
original_rate,
|
||||||
|
current_rate: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current sample rate from PipeWire
|
||||||
|
fn get_current_rate_internal() -> Result<u32, AudioError> {
|
||||||
|
let output = Command::new("pw-metadata")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("settings")
|
||||||
|
.arg("0")
|
||||||
|
.arg("clock.force-rate")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
// Parse output like: "update: id:0 key:'clock.force-rate' value:'48000' type:''"
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if 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>() {
|
||||||
|
return Ok(rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No forced rate, return default
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current forced sample rate
|
||||||
|
pub fn get_current_rate(&self) -> Option<u32> {
|
||||||
|
self.current_rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the sample rate
|
||||||
|
pub fn set_rate(&mut self, rate: u32) -> Result<(), AudioError> {
|
||||||
|
if self.current_rate == Some(rate) {
|
||||||
|
debug!("Sample rate already set to {}", rate);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Setting PipeWire sample rate to {} Hz", rate);
|
||||||
|
|
||||||
|
let output = Command::new("pw-metadata")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("settings")
|
||||||
|
.arg("0")
|
||||||
|
.arg("clock.force-rate")
|
||||||
|
.arg(rate.to_string())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AudioError::PipeWire(format!(
|
||||||
|
"pw-metadata failed: {}",
|
||||||
|
stderr
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_rate = Some(rate);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore original sample rate
|
||||||
|
pub fn restore_original(&mut self) -> Result<(), AudioError> {
|
||||||
|
if let Some(rate) = self.original_rate {
|
||||||
|
if rate > 0 {
|
||||||
|
info!("Restoring original sample rate: {} Hz", rate);
|
||||||
|
self.set_rate(rate)?;
|
||||||
|
} else {
|
||||||
|
info!("Clearing forced sample rate");
|
||||||
|
self.clear_forced_rate()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the forced sample rate (let PipeWire use default)
|
||||||
|
pub fn clear_forced_rate(&mut self) -> Result<(), AudioError> {
|
||||||
|
info!("Clearing PipeWire forced sample rate");
|
||||||
|
|
||||||
|
let output = Command::new("pw-metadata")
|
||||||
|
.arg("-n")
|
||||||
|
.arg("settings")
|
||||||
|
.arg("0")
|
||||||
|
.arg("clock.force-rate")
|
||||||
|
.arg("0")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AudioError::PipeWire(format!(
|
||||||
|
"pw-metadata failed: {}",
|
||||||
|
stderr
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_rate = None;
|
||||||
|
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 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PipeWireController {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Err(e) = self.restore_original() {
|
||||||
|
error!("Failed to restore sample rate: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_available() {
|
||||||
|
// This test just checks the function doesn't panic
|
||||||
|
let _ = PipeWireController::is_available();
|
||||||
|
}
|
||||||
|
}
|
||||||
321
src/audio/queue.rs
Normal file
321
src/audio/queue.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
//! 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/config/mod.rs
Normal file
165
src/config/mod.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Configuration loading and management
|
||||||
|
|
||||||
|
pub mod paths;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
|
/// Main application configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Subsonic server base URL
|
||||||
|
#[serde(rename = "BaseURL", default)]
|
||||||
|
pub base_url: String,
|
||||||
|
|
||||||
|
/// Username for authentication
|
||||||
|
#[serde(rename = "Username", default)]
|
||||||
|
pub username: String,
|
||||||
|
|
||||||
|
/// Password for authentication
|
||||||
|
#[serde(rename = "Password", default)]
|
||||||
|
pub password: String,
|
||||||
|
|
||||||
|
/// UI Theme name
|
||||||
|
#[serde(rename = "Theme", default)]
|
||||||
|
pub theme: String,
|
||||||
|
|
||||||
|
/// Enable cava audio visualizer
|
||||||
|
#[serde(rename = "Cava", default)]
|
||||||
|
pub cava: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Create a new empty config
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config from the default location
|
||||||
|
pub fn load_default() -> Result<Self, ConfigError> {
|
||||||
|
let path = paths::config_file().ok_or_else(|| ConfigError::NotFound {
|
||||||
|
path: "default config location".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
Self::load_from_file(&path)
|
||||||
|
} else {
|
||||||
|
info!("No config file found at {}, using defaults", path.display());
|
||||||
|
Ok(Self::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config from a specific file
|
||||||
|
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||||
|
debug!("Loading config from {}", path.display());
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(ConfigError::NotFound {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(path)?;
|
||||||
|
let config: Config = toml::from_str(&contents)?;
|
||||||
|
|
||||||
|
debug!("Config loaded successfully");
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save config to the default location
|
||||||
|
pub fn save_default(&self) -> Result<(), ConfigError> {
|
||||||
|
let path = paths::config_file().ok_or_else(|| ConfigError::NotFound {
|
||||||
|
path: "default config location".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.save_to_file(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save config to a specific file
|
||||||
|
pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
|
||||||
|
debug!("Saving config to {}", path.display());
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = toml::to_string_pretty(self)?;
|
||||||
|
std::fs::write(path, contents)?;
|
||||||
|
|
||||||
|
info!("Config saved to {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the config has valid server settings
|
||||||
|
pub fn is_configured(&self) -> bool {
|
||||||
|
!self.base_url.is_empty() && !self.username.is_empty() && !self.password.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the config
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.base_url.is_empty() {
|
||||||
|
return Err(ConfigError::MissingField {
|
||||||
|
field: "BaseURL".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if url::Url::parse(&self.base_url).is_err() {
|
||||||
|
return Err(ConfigError::InvalidUrl {
|
||||||
|
url: self.base_url.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.username.is_empty() {
|
||||||
|
warn!("Username is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.password.is_empty() {
|
||||||
|
warn!("Password is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_parse() {
|
||||||
|
let toml_content = r#"
|
||||||
|
BaseURL = "https://example.com"
|
||||||
|
Username = "testuser"
|
||||||
|
Password = "testpass"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(toml_content.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
let config = Config::load_from_file(file.path()).unwrap();
|
||||||
|
assert_eq!(config.base_url, "https://example.com");
|
||||||
|
assert_eq!(config.username, "testuser");
|
||||||
|
assert_eq!(config.password, "testpass");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_configured() {
|
||||||
|
let mut config = Config::new();
|
||||||
|
assert!(!config.is_configured());
|
||||||
|
|
||||||
|
config.base_url = "https://example.com".to_string();
|
||||||
|
config.username = "user".to_string();
|
||||||
|
config.password = "pass".to_string();
|
||||||
|
assert!(config.is_configured());
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/config/paths.rs
Normal file
46
src/config/paths.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//! Platform-specific configuration paths
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Get the default config directory for ferrosonic
|
||||||
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("ferrosonic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default config file path
|
||||||
|
pub fn config_file() -> Option<PathBuf> {
|
||||||
|
config_dir().map(|p| p.join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the themes directory path
|
||||||
|
pub fn themes_dir() -> Option<PathBuf> {
|
||||||
|
config_dir().map(|p| p.join("themes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the log file path
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn log_file() -> Option<PathBuf> {
|
||||||
|
config_dir().map(|p| p.join("ferrosonic.log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the MPV socket path
|
||||||
|
pub fn mpv_socket_path() -> PathBuf {
|
||||||
|
std::env::temp_dir().join("ferrosonic-mpv.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the config directory exists
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn ensure_config_dir() -> std::io::Result<PathBuf> {
|
||||||
|
let dir = config_dir().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"Could not determine config directory",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !dir.exists() {
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
115
src/error.rs
Normal file
115
src/error.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! Error types for ferrosonic
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Main error type for ferrosonic
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(#[from] ConfigError),
|
||||||
|
|
||||||
|
#[error("Subsonic API error: {0}")]
|
||||||
|
Subsonic(#[from] SubsonicError),
|
||||||
|
|
||||||
|
#[error("Audio playback error: {0}")]
|
||||||
|
Audio(#[from] AudioError),
|
||||||
|
|
||||||
|
#[error("UI error: {0}")]
|
||||||
|
Ui(#[from] UiError),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration-related errors
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Config file not found at {path}")]
|
||||||
|
NotFound { path: String },
|
||||||
|
|
||||||
|
#[error("Failed to parse config: {0}")]
|
||||||
|
Parse(#[from] toml::de::Error),
|
||||||
|
|
||||||
|
#[error("Failed to serialize config: {0}")]
|
||||||
|
Serialize(#[from] toml::ser::Error),
|
||||||
|
|
||||||
|
#[error("Missing required field: {field}")]
|
||||||
|
MissingField { field: String },
|
||||||
|
|
||||||
|
#[error("Invalid URL: {url}")]
|
||||||
|
InvalidUrl { url: String },
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subsonic API errors
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum SubsonicError {
|
||||||
|
#[error("HTTP request failed: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("API error {code}: {message}")]
|
||||||
|
Api { code: i32, message: String },
|
||||||
|
|
||||||
|
#[error("Authentication failed")]
|
||||||
|
AuthFailed,
|
||||||
|
|
||||||
|
#[error("Server not configured")]
|
||||||
|
NotConfigured,
|
||||||
|
|
||||||
|
#[error("Failed to parse response: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
|
||||||
|
#[error("URL parse error: {0}")]
|
||||||
|
UrlParse(#[from] url::ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio playback errors
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AudioError {
|
||||||
|
#[error("MPV not running")]
|
||||||
|
MpvNotRunning,
|
||||||
|
|
||||||
|
#[error("Failed to spawn MPV: {0}")]
|
||||||
|
MpvSpawn(std::io::Error),
|
||||||
|
|
||||||
|
#[error("MPV IPC error: {0}")]
|
||||||
|
MpvIpc(String),
|
||||||
|
|
||||||
|
#[error("MPV socket connection failed: {0}")]
|
||||||
|
MpvSocket(std::io::Error),
|
||||||
|
|
||||||
|
#[error("PipeWire command failed: {0}")]
|
||||||
|
PipeWire(String),
|
||||||
|
|
||||||
|
#[error("Queue is empty")]
|
||||||
|
QueueEmpty,
|
||||||
|
|
||||||
|
#[error("Invalid queue index: {index}")]
|
||||||
|
InvalidIndex { index: usize },
|
||||||
|
|
||||||
|
#[error("JSON serialization error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-related errors
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum UiError {
|
||||||
|
#[error("Terminal initialization failed: {0}")]
|
||||||
|
TerminalInit(std::io::Error),
|
||||||
|
|
||||||
|
#[error("Render error: {0}")]
|
||||||
|
Render(std::io::Error),
|
||||||
|
|
||||||
|
#[error("Input error: {0}")]
|
||||||
|
Input(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type alias using our Error
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
131
src/main.rs
Normal file
131
src/main.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! Termsonic - A terminal-based Subsonic music client
|
||||||
|
//!
|
||||||
|
//! Features:
|
||||||
|
//! - Bit-perfect audio playback via MPV and PipeWire sample rate switching
|
||||||
|
//! - MPRIS2 desktop integration for media controls
|
||||||
|
//! - Browse artists, albums, and playlists
|
||||||
|
//! - Play queue with shuffle and reorder support
|
||||||
|
//! - Server configuration with connection testing
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod audio;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod mpris;
|
||||||
|
mod subsonic;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::config::paths::config_dir;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
/// Termsonic - Terminal Subsonic Music Client
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "ferrosonic")]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to config file
|
||||||
|
#[arg(short, long, value_name = "FILE")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Enable verbose/debug logging
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize file-based logging
|
||||||
|
fn init_logging(verbose: bool) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
|
// Get log directory (same as config dir for consistency with Go version)
|
||||||
|
let log_dir = config_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||||
|
|
||||||
|
// Create log directory if needed
|
||||||
|
if let Err(e) = fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!("Warning: Could not create log directory: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let log_file = log_dir.join("ferrosonic.log");
|
||||||
|
|
||||||
|
// Open log file (truncate on each run)
|
||||||
|
let file = match File::create(&log_file) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Could not create log file: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file);
|
||||||
|
|
||||||
|
let filter = if verbose {
|
||||||
|
EnvFilter::new("ferrosonic=debug")
|
||||||
|
} else {
|
||||||
|
EnvFilter::new("ferrosonic=info")
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(false),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
eprintln!("Logging to: {}", log_file.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Initialize file-based logging (keep guard alive for duration of program)
|
||||||
|
let _log_guard = init_logging(args.verbose);
|
||||||
|
|
||||||
|
info!("Termsonic starting...");
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = match args.config {
|
||||||
|
Some(path) => {
|
||||||
|
info!("Loading config from {}", path.display());
|
||||||
|
Config::load_from_file(&path)?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("Loading default config");
|
||||||
|
Config::load_default().unwrap_or_else(|e| {
|
||||||
|
info!("No config found ({}), using defaults", e);
|
||||||
|
Config::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Server: {}",
|
||||||
|
if config.base_url.is_empty() {
|
||||||
|
"(not configured)"
|
||||||
|
} else {
|
||||||
|
&config.base_url
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run the application
|
||||||
|
let mut app = App::new(config);
|
||||||
|
if let Err(e) = app.run().await {
|
||||||
|
tracing::error!("Application error: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Termsonic exiting...");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
5
src/mpris/mod.rs
Normal file
5
src/mpris/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! MPRIS2 D-Bus integration module
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod server;
|
||||||
372
src/mpris/server.rs
Normal file
372
src/mpris/server.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
//! MPRIS2 D-Bus server implementation
|
||||||
|
|
||||||
|
use mpris_server::{
|
||||||
|
zbus::{fdo, Result},
|
||||||
|
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
|
||||||
|
Server, Time, TrackId, Volume,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::info;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::app::actions::AudioAction;
|
||||||
|
use crate::app::state::{NowPlaying, PlaybackState, SharedState};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::subsonic::auth::generate_auth_params;
|
||||||
|
use crate::subsonic::models::Child;
|
||||||
|
|
||||||
|
/// API version for Subsonic
|
||||||
|
const API_VERSION: &str = "1.16.1";
|
||||||
|
/// Client name for Subsonic
|
||||||
|
const CLIENT_NAME: &str = "ferrosonic";
|
||||||
|
|
||||||
|
/// Build a cover art URL from config and cover art ID
|
||||||
|
fn build_cover_art_url(config: &Config, cover_art_id: &str) -> Option<String> {
|
||||||
|
if config.base_url.is_empty() || cover_art_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (salt, token) = generate_auth_params(&config.password);
|
||||||
|
let mut url = Url::parse(&format!("{}/rest/getCoverArt", config.base_url)).ok()?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("id", cover_art_id)
|
||||||
|
.append_pair("u", &config.username)
|
||||||
|
.append_pair("t", &token)
|
||||||
|
.append_pair("s", &salt)
|
||||||
|
.append_pair("v", API_VERSION)
|
||||||
|
.append_pair("c", CLIENT_NAME);
|
||||||
|
|
||||||
|
Some(url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPRIS server instance name
|
||||||
|
const PLAYER_NAME: &str = "ferrosonic";
|
||||||
|
|
||||||
|
/// MPRIS2 player implementation
|
||||||
|
pub struct MprisPlayer {
|
||||||
|
state: SharedState,
|
||||||
|
audio_tx: mpsc::Sender<AudioAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MprisPlayer {
|
||||||
|
pub fn new(state: SharedState, audio_tx: mpsc::Sender<AudioAction>) -> Self {
|
||||||
|
Self { state, audio_tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_state(&self) -> (NowPlaying, Option<Child>, Config) {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
let now_playing = state.now_playing.clone();
|
||||||
|
let current_song = state.current_song().cloned();
|
||||||
|
let config = state.config.clone();
|
||||||
|
(now_playing, current_song, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RootInterface for MprisPlayer {
|
||||||
|
async fn raise(&self) -> fdo::Result<()> {
|
||||||
|
// We're a terminal app, can't raise
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quit(&self) -> fdo::Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.should_quit = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_quit(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fullscreen(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_fullscreen(&self, _fullscreen: bool) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_raise(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn has_track_list(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn identity(&self) -> fdo::Result<String> {
|
||||||
|
Ok("Termsonic".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn desktop_entry(&self) -> fdo::Result<String> {
|
||||||
|
Ok("ferrosonic".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
|
||||||
|
Ok(vec!["http".to_string(), "https".to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
|
||||||
|
Ok(vec![
|
||||||
|
"audio/mpeg".to_string(),
|
||||||
|
"audio/flac".to_string(),
|
||||||
|
"audio/ogg".to_string(),
|
||||||
|
"audio/wav".to_string(),
|
||||||
|
"audio/x-wav".to_string(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerInterface for MprisPlayer {
|
||||||
|
async fn next(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Next).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn previous(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Previous).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pause(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Pause).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn play_pause(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::TogglePause).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Stop).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn play(&self) -> fdo::Result<()> {
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Resume).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seek(&self, offset: Time) -> fdo::Result<()> {
|
||||||
|
let offset_secs = offset.as_micros() as f64 / 1_000_000.0;
|
||||||
|
let _ = self
|
||||||
|
.audio_tx
|
||||||
|
.send(AudioAction::SeekRelative(offset_secs))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
|
||||||
|
let position_secs = position.as_micros() as f64 / 1_000_000.0;
|
||||||
|
let _ = self.audio_tx.send(AudioAction::Seek(position_secs)).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
|
||||||
|
// Not supported for now
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
|
||||||
|
let (now_playing, _, _) = self.get_state().await;
|
||||||
|
Ok(match now_playing.state {
|
||||||
|
PlaybackState::Playing => PlaybackStatus::Playing,
|
||||||
|
PlaybackState::Paused => PlaybackStatus::Paused,
|
||||||
|
PlaybackState::Stopped => PlaybackStatus::Stopped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
|
||||||
|
Ok(LoopStatus::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rate(&self) -> fdo::Result<PlaybackRate> {
|
||||||
|
Ok(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_rate(&self, _rate: PlaybackRate) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shuffle(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata(&self) -> fdo::Result<Metadata> {
|
||||||
|
let (_now_playing, current_song, config) = self.get_state().await;
|
||||||
|
|
||||||
|
let mut metadata = Metadata::new();
|
||||||
|
|
||||||
|
if let Some(song) = current_song {
|
||||||
|
metadata.set_trackid(
|
||||||
|
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
|
||||||
|
.flatten(),
|
||||||
|
);
|
||||||
|
metadata.set_title(Some(song.title));
|
||||||
|
metadata.set_artist(song.artist.map(|a| vec![a]));
|
||||||
|
metadata.set_album(song.album);
|
||||||
|
|
||||||
|
if let Some(duration) = song.duration {
|
||||||
|
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(track) = song.track {
|
||||||
|
metadata.set_track_number(Some(track));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(disc) = song.disc_number {
|
||||||
|
metadata.set_disc_number(Some(disc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cover art URL
|
||||||
|
if let Some(ref cover_art_id) = song.cover_art {
|
||||||
|
if let Some(cover_url) = build_cover_art_url(&config, cover_art_id) {
|
||||||
|
metadata.set_art_url(Some(cover_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn volume(&self) -> fdo::Result<Volume> {
|
||||||
|
Ok(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_volume(&self, volume: Volume) -> Result<()> {
|
||||||
|
let volume_int = (volume * 100.0) as i32;
|
||||||
|
let _ = self.audio_tx.send(AudioAction::SetVolume(volume_int)).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn position(&self) -> fdo::Result<Time> {
|
||||||
|
let (now_playing, _, _) = self.get_state().await;
|
||||||
|
Ok(Time::from_micros(
|
||||||
|
(now_playing.position * 1_000_000.0) as i64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||||
|
Ok(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
|
||||||
|
Ok(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_go_next(&self) -> fdo::Result<bool> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
Ok(state
|
||||||
|
.queue_position
|
||||||
|
.map(|p| p + 1 < state.queue.len())
|
||||||
|
.unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_go_previous(&self) -> fdo::Result<bool> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
Ok(state.queue_position.map(|p| p > 0).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_play(&self) -> fdo::Result<bool> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
Ok(!state.queue.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_pause(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_seek(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn can_control(&self) -> fdo::Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the MPRIS server
|
||||||
|
pub async fn start_mpris_server(
|
||||||
|
state: SharedState,
|
||||||
|
audio_tx: mpsc::Sender<AudioAction>,
|
||||||
|
) -> Result<Server<MprisPlayer>> {
|
||||||
|
info!("Starting MPRIS2 server");
|
||||||
|
|
||||||
|
let player = MprisPlayer::new(state, audio_tx);
|
||||||
|
let server = Server::new(PLAYER_NAME, player).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"MPRIS2 server started as org.mpris.MediaPlayer2.{}",
|
||||||
|
PLAYER_NAME
|
||||||
|
);
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update MPRIS properties when state changes
|
||||||
|
pub async fn update_mpris_properties(
|
||||||
|
server: &Server<MprisPlayer>,
|
||||||
|
state: &SharedState,
|
||||||
|
) -> Result<()> {
|
||||||
|
let state = state.read().await;
|
||||||
|
|
||||||
|
// Emit property changes
|
||||||
|
server
|
||||||
|
.properties_changed([
|
||||||
|
Property::PlaybackStatus(match state.now_playing.state {
|
||||||
|
PlaybackState::Playing => PlaybackStatus::Playing,
|
||||||
|
PlaybackState::Paused => PlaybackStatus::Paused,
|
||||||
|
PlaybackState::Stopped => PlaybackStatus::Stopped,
|
||||||
|
}),
|
||||||
|
Property::CanGoNext(
|
||||||
|
state
|
||||||
|
.queue_position
|
||||||
|
.map(|p| p + 1 < state.queue.len())
|
||||||
|
.unwrap_or(false),
|
||||||
|
),
|
||||||
|
Property::CanGoPrevious(state.queue_position.map(|p| p > 0).unwrap_or(false)),
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update metadata if we have a current song
|
||||||
|
if let Some(song) = state.current_song() {
|
||||||
|
let mut metadata = Metadata::new();
|
||||||
|
metadata.set_trackid(
|
||||||
|
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
|
||||||
|
.flatten(),
|
||||||
|
);
|
||||||
|
metadata.set_title(Some(song.title.clone()));
|
||||||
|
metadata.set_artist(song.artist.clone().map(|a| vec![a]));
|
||||||
|
metadata.set_album(song.album.clone());
|
||||||
|
|
||||||
|
if let Some(duration) = song.duration {
|
||||||
|
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cover art URL
|
||||||
|
if let Some(ref cover_art_id) = song.cover_art {
|
||||||
|
if let Some(cover_url) = build_cover_art_url(&state.config, cover_art_id) {
|
||||||
|
metadata.set_art_url(Some(cover_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server
|
||||||
|
.properties_changed([Property::Metadata(metadata)])
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
61
src/subsonic/auth.rs
Normal file
61
src/subsonic/auth.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Subsonic authentication helpers
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
/// Generate authentication parameters for Subsonic API requests
|
||||||
|
///
|
||||||
|
/// Subsonic uses token-based authentication:
|
||||||
|
/// - salt: random string
|
||||||
|
/// - token: md5(password + salt)
|
||||||
|
pub fn generate_auth_params(password: &str) -> (String, String) {
|
||||||
|
let salt = generate_salt();
|
||||||
|
let token = generate_token(password, &salt);
|
||||||
|
(salt, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random salt string
|
||||||
|
fn generate_salt() -> String {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..16)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..36);
|
||||||
|
if idx < 10 {
|
||||||
|
(b'0' + idx) as char
|
||||||
|
} else {
|
||||||
|
(b'a' + idx - 10) as char
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate authentication token: md5(password + salt)
|
||||||
|
fn generate_token(password: &str, salt: &str) -> String {
|
||||||
|
let input = format!("{}{}", password, salt);
|
||||||
|
let digest = md5::compute(input.as_bytes());
|
||||||
|
format!("{:x}", digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_token() {
|
||||||
|
// Test with known values
|
||||||
|
let token = generate_token("sesame", "c19b2d");
|
||||||
|
assert_eq!(token, "26719a1196d2a940705a59634eb18eab");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_salt_length() {
|
||||||
|
let salt = generate_salt();
|
||||||
|
assert_eq!(salt.len(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_params() {
|
||||||
|
let (salt, token) = generate_auth_params("password");
|
||||||
|
assert_eq!(salt.len(), 16);
|
||||||
|
assert_eq!(token.len(), 32); // MD5 hex is 32 chars
|
||||||
|
}
|
||||||
|
}
|
||||||
384
src/subsonic/client.rs
Normal file
384
src/subsonic/client.rs
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
//! Subsonic API client
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use super::auth::generate_auth_params;
|
||||||
|
use super::models::*;
|
||||||
|
use crate::error::SubsonicError;
|
||||||
|
|
||||||
|
/// Client name sent to Subsonic server
|
||||||
|
const CLIENT_NAME: &str = "ferrosonic-rs";
|
||||||
|
/// API version we support
|
||||||
|
const API_VERSION: &str = "1.16.1";
|
||||||
|
|
||||||
|
/// Subsonic API client
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SubsonicClient {
|
||||||
|
/// Base URL of the Subsonic server
|
||||||
|
base_url: Url,
|
||||||
|
/// Username for authentication
|
||||||
|
username: String,
|
||||||
|
/// Password for authentication (stored for stream URLs)
|
||||||
|
password: String,
|
||||||
|
/// HTTP client
|
||||||
|
http: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubsonicClient {
|
||||||
|
/// Create a new Subsonic client
|
||||||
|
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
|
||||||
|
let base_url = Url::parse(base_url)?;
|
||||||
|
|
||||||
|
let http = Client::builder()
|
||||||
|
.user_agent(CLIENT_NAME)
|
||||||
|
.build()
|
||||||
|
.map_err(SubsonicError::Http)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
base_url,
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build URL with authentication parameters
|
||||||
|
fn build_url(&self, endpoint: &str) -> Result<Url, SubsonicError> {
|
||||||
|
let mut url = self.base_url.join(&format!("rest/{}", endpoint))?;
|
||||||
|
|
||||||
|
let (salt, token) = generate_auth_params(&self.password);
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("u", &self.username)
|
||||||
|
.append_pair("t", &token)
|
||||||
|
.append_pair("s", &salt)
|
||||||
|
.append_pair("v", API_VERSION)
|
||||||
|
.append_pair("c", CLIENT_NAME)
|
||||||
|
.append_pair("f", "json");
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an API request and parse the response
|
||||||
|
async fn request<T>(&self, endpoint: &str) -> Result<T, SubsonicError>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
let url = self.build_url(endpoint)?;
|
||||||
|
debug!(
|
||||||
|
"Requesting: {}",
|
||||||
|
url.as_str().split('?').next().unwrap_or("")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.http.get(url).send().await?;
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
let parsed: SubsonicResponse<T> = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| SubsonicError::Parse(format!("Failed to parse response: {}", e)))?;
|
||||||
|
|
||||||
|
let inner = parsed.subsonic_response;
|
||||||
|
|
||||||
|
if inner.status != "ok" {
|
||||||
|
if let Some(error) = inner.error {
|
||||||
|
return Err(SubsonicError::Api {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(SubsonicError::Api {
|
||||||
|
code: 0,
|
||||||
|
message: "Unknown error".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inner
|
||||||
|
.data
|
||||||
|
.ok_or_else(|| SubsonicError::Parse("Empty response data".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection to the server
|
||||||
|
pub async fn ping(&self) -> Result<(), SubsonicError> {
|
||||||
|
let url = self.build_url("ping")?;
|
||||||
|
debug!("Pinging server");
|
||||||
|
|
||||||
|
let response = self.http.get(url).send().await?;
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
let parsed: SubsonicResponse<PingData> = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| SubsonicError::Parse(format!("Failed to parse ping 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Server ping successful");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all artists
|
||||||
|
pub async fn get_artists(&self) -> Result<Vec<Artist>, SubsonicError> {
|
||||||
|
let data: ArtistsData = self.request("getArtists").await?;
|
||||||
|
|
||||||
|
let artists: Vec<Artist> = data
|
||||||
|
.artists
|
||||||
|
.index
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|idx| idx.artist)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
debug!("Fetched {} artists", artists.len());
|
||||||
|
Ok(artists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get artist details with albums
|
||||||
|
pub async fn get_artist(&self, id: &str) -> Result<(Artist, Vec<Album>), SubsonicError> {
|
||||||
|
let url = self.build_url(&format!("getArtist?id={}", id))?;
|
||||||
|
debug!("Fetching artist: {}", id);
|
||||||
|
|
||||||
|
let response = self.http.get(url).send().await?;
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
let parsed: SubsonicResponse<ArtistData> = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| SubsonicError::Parse(format!("Failed to parse artist 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 detail = parsed
|
||||||
|
.subsonic_response
|
||||||
|
.data
|
||||||
|
.ok_or_else(|| SubsonicError::Parse("Empty artist data".to_string()))?
|
||||||
|
.artist;
|
||||||
|
|
||||||
|
let artist = Artist {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name.clone(),
|
||||||
|
album_count: Some(detail.album.len() as i32),
|
||||||
|
cover_art: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Fetched artist {} with {} albums",
|
||||||
|
detail.name,
|
||||||
|
detail.album.len()
|
||||||
|
);
|
||||||
|
Ok((artist, detail.album))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get album details with songs
|
||||||
|
pub async fn get_album(&self, id: &str) -> Result<(Album, Vec<Child>), SubsonicError> {
|
||||||
|
let url = self.build_url(&format!("getAlbum?id={}", id))?;
|
||||||
|
debug!("Fetching album: {}", id);
|
||||||
|
|
||||||
|
let response = self.http.get(url).send().await?;
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
let parsed: SubsonicResponse<AlbumData> = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| SubsonicError::Parse(format!("Failed to parse album 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 detail = parsed
|
||||||
|
.subsonic_response
|
||||||
|
.data
|
||||||
|
.ok_or_else(|| SubsonicError::Parse("Empty album data".to_string()))?
|
||||||
|
.album;
|
||||||
|
|
||||||
|
let album = Album {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name.clone(),
|
||||||
|
artist: detail.artist,
|
||||||
|
artist_id: detail.artist_id,
|
||||||
|
cover_art: None,
|
||||||
|
song_count: Some(detail.song.len() as i32),
|
||||||
|
duration: None,
|
||||||
|
year: detail.year,
|
||||||
|
genre: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Fetched album {} with {} songs",
|
||||||
|
detail.name,
|
||||||
|
detail.song.len()
|
||||||
|
);
|
||||||
|
Ok((album, detail.song))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all playlists
|
||||||
|
pub async fn get_playlists(&self) -> Result<Vec<Playlist>, SubsonicError> {
|
||||||
|
let data: PlaylistsData = self.request("getPlaylists").await?;
|
||||||
|
let playlists = data.playlists.playlist;
|
||||||
|
debug!("Fetched {} playlists", playlists.len());
|
||||||
|
Ok(playlists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playlist details with songs
|
||||||
|
pub async fn get_playlist(&self, id: &str) -> Result<(Playlist, Vec<Child>), SubsonicError> {
|
||||||
|
let url = self.build_url(&format!("getPlaylist?id={}", id))?;
|
||||||
|
debug!("Fetching playlist: {}", id);
|
||||||
|
|
||||||
|
let response = self.http.get(url).send().await?;
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
let parsed: SubsonicResponse<PlaylistData> = serde_json::from_str(&text).map_err(|e| {
|
||||||
|
SubsonicError::Parse(format!("Failed to parse playlist 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 detail = parsed
|
||||||
|
.subsonic_response
|
||||||
|
.data
|
||||||
|
.ok_or_else(|| SubsonicError::Parse("Empty playlist data".to_string()))?
|
||||||
|
.playlist;
|
||||||
|
|
||||||
|
let playlist = Playlist {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name.clone(),
|
||||||
|
owner: detail.owner,
|
||||||
|
song_count: detail.song_count,
|
||||||
|
duration: detail.duration,
|
||||||
|
cover_art: None,
|
||||||
|
public: None,
|
||||||
|
comment: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Fetched playlist {} with {} songs",
|
||||||
|
detail.name,
|
||||||
|
detail.entry.len()
|
||||||
|
);
|
||||||
|
Ok((playlist, detail.entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get stream URL for a song
|
||||||
|
///
|
||||||
|
/// Returns the full URL with authentication that can be passed to MPV
|
||||||
|
pub fn get_stream_url(&self, song_id: &str) -> Result<String, SubsonicError> {
|
||||||
|
let mut url = self.base_url.join("rest/stream")?;
|
||||||
|
|
||||||
|
let (salt, token) = generate_auth_params(&self.password);
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("id", song_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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::*;
|
||||||
|
|
||||||
|
#[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";
|
||||||
|
let id = SubsonicClient::parse_song_id_from_url(url);
|
||||||
|
assert_eq!(id, Some("12345".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_song_id_missing() {
|
||||||
|
let url = "https://example.com/rest/stream?u=user";
|
||||||
|
let id = SubsonicClient::parse_song_id_from_url(url);
|
||||||
|
assert_eq!(id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/subsonic/mod.rs
Normal file
9
src/subsonic/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//! Subsonic API client module
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod client;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
|
pub use client::SubsonicClient;
|
||||||
235
src/subsonic/models.rs
Normal file
235
src/subsonic/models.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//! Subsonic API response models
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Wrapper for all Subsonic API responses
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SubsonicResponse<T> {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
pub subsonic_response: SubsonicResponseInner<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SubsonicResponseInner<T> {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<ApiError>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API error response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub code: i32,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artists response wrapper
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtistsData {
|
||||||
|
pub artists: ArtistsIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtistsIndex {
|
||||||
|
#[serde(default)]
|
||||||
|
pub index: Vec<ArtistIndex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtistIndex {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Vec<Artist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Artist {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, rename = "albumCount")]
|
||||||
|
pub album_count: Option<i32>,
|
||||||
|
#[serde(default, rename = "coverArt")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist detail with albums
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtistData {
|
||||||
|
pub artist: ArtistDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtistDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album: Vec<Album>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default, rename = "artistId")]
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
#[serde(default, rename = "coverArt")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(default, rename = "songCount")]
|
||||||
|
pub song_count: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album detail with songs
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumData {
|
||||||
|
pub album: AlbumDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default, rename = "artistId")]
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub song: Vec<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Song/Media item (called "Child" in Subsonic API)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Child {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parent: Option<String>,
|
||||||
|
#[serde(default, rename = "isDir")]
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(default, rename = "coverArt")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<i64>,
|
||||||
|
#[serde(default, rename = "contentType")]
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub suffix: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(default, rename = "bitRate")]
|
||||||
|
pub bit_rate: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default, rename = "discNumber")]
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Child {
|
||||||
|
/// Format duration as MM:SS
|
||||||
|
pub fn format_duration(&self) -> String {
|
||||||
|
match self.duration {
|
||||||
|
Some(d) => {
|
||||||
|
let mins = d / 60;
|
||||||
|
let secs = d % 60;
|
||||||
|
format!("{:02}:{:02}", mins, secs)
|
||||||
|
}
|
||||||
|
None => "--:--".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlists response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlaylistsData {
|
||||||
|
pub playlists: PlaylistsInner,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlaylistsInner {
|
||||||
|
#[serde(default)]
|
||||||
|
pub playlist: Vec<Playlist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner: Option<String>,
|
||||||
|
#[serde(default, rename = "songCount")]
|
||||||
|
pub song_count: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(default, rename = "coverArt")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub public: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist detail with songs
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlaylistData {
|
||||||
|
pub playlist: PlaylistDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlaylistDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner: Option<String>,
|
||||||
|
#[serde(default, rename = "songCount")]
|
||||||
|
pub song_count: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub entry: Vec<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping response (for testing connection)
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
144
src/ui/footer.rs
Normal file
144
src/ui/footer.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//! Footer bar with keybind hints and status
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::Style,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::Widget,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::{Notification, Page};
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
/// Footer bar widget
|
||||||
|
pub struct Footer<'a> {
|
||||||
|
page: Page,
|
||||||
|
sample_rate: Option<u32>,
|
||||||
|
notification: Option<&'a Notification>,
|
||||||
|
colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Footer<'a> {
|
||||||
|
pub fn new(page: Page, colors: ThemeColors) -> Self {
|
||||||
|
Self {
|
||||||
|
page,
|
||||||
|
sample_rate: None,
|
||||||
|
notification: None,
|
||||||
|
colors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sample_rate(mut self, rate: Option<u32>) -> Self {
|
||||||
|
self.sample_rate = rate;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notification(mut self, notification: Option<&'a Notification>) -> Self {
|
||||||
|
self.notification = notification;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keybinds(&self) -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut binds = vec![
|
||||||
|
("q", "Quit"),
|
||||||
|
("p/Space", "Pause"),
|
||||||
|
("h", "Prev"),
|
||||||
|
("l", "Next"),
|
||||||
|
("t", "Theme"),
|
||||||
|
];
|
||||||
|
|
||||||
|
match self.page {
|
||||||
|
Page::Artists => {
|
||||||
|
binds.extend([
|
||||||
|
("/", "Filter"),
|
||||||
|
("←/→", "Focus"),
|
||||||
|
("e", "Add"),
|
||||||
|
("n", "Add next"),
|
||||||
|
("Enter", "Play"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Page::Queue => {
|
||||||
|
binds.extend([
|
||||||
|
("d", "Remove"),
|
||||||
|
("J/K", "Move"),
|
||||||
|
("r", "Shuffle"),
|
||||||
|
("c", "Clear history"),
|
||||||
|
("Enter", "Play"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Page::Playlists => {
|
||||||
|
binds.extend([
|
||||||
|
("←/→", "Focus"),
|
||||||
|
("e", "Add"),
|
||||||
|
("n", "Add next"),
|
||||||
|
("r", "Shuffle play"),
|
||||||
|
("Enter", "Play"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Page::Server => {
|
||||||
|
binds.extend([
|
||||||
|
("Tab", "Next field"),
|
||||||
|
("Enter", "Test/Save"),
|
||||||
|
("Ctrl+R", "Refresh"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Page::Settings => {
|
||||||
|
binds.extend([("←/→/Enter", "Change theme")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Footer<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||||
|
|
||||||
|
// Left side: keybinds or notification
|
||||||
|
if let Some(notif) = self.notification {
|
||||||
|
let style = if notif.is_error {
|
||||||
|
Style::default().fg(self.colors.error)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(self.colors.success)
|
||||||
|
};
|
||||||
|
buf.set_string(chunks[0].x, chunks[0].y, ¬if.message, style);
|
||||||
|
} else {
|
||||||
|
// Keybind hints
|
||||||
|
let binds = self.keybinds();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
|
for (i, (key, desc)) in binds.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
" │ ",
|
||||||
|
Style::default().fg(self.colors.secondary),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(*key, Style::default().fg(self.colors.accent)));
|
||||||
|
spans.push(Span::raw(":"));
|
||||||
|
spans.push(Span::styled(*desc, Style::default().fg(self.colors.muted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
|
buf.set_line(chunks[0].x, chunks[0].y, &line, chunks[0].width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right side: sample rate / status
|
||||||
|
if let Some(rate) = self.sample_rate {
|
||||||
|
let rate_str = format!("{}kHz", rate / 1000);
|
||||||
|
let x = chunks[1].x + chunks[1].width.saturating_sub(rate_str.len() as u16);
|
||||||
|
buf.set_string(
|
||||||
|
x,
|
||||||
|
chunks[1].y,
|
||||||
|
&rate_str,
|
||||||
|
Style::default().fg(self.colors.success),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/ui/header.rs
Normal file
168
src/ui/header.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Header bar with page tabs and playback controls
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Tabs, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::{Page, PlaybackState};
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
/// Header bar widget
|
||||||
|
pub struct Header {
|
||||||
|
current_page: Page,
|
||||||
|
playback_state: PlaybackState,
|
||||||
|
colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Header {
|
||||||
|
pub fn new(current_page: Page, playback_state: PlaybackState, colors: ThemeColors) -> Self {
|
||||||
|
Self {
|
||||||
|
current_page,
|
||||||
|
playback_state,
|
||||||
|
colors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Header {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split header: [tabs] [playback controls]
|
||||||
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||||
|
|
||||||
|
// Page tabs
|
||||||
|
let titles: Vec<Line> = vec![
|
||||||
|
Page::Artists,
|
||||||
|
Page::Queue,
|
||||||
|
Page::Playlists,
|
||||||
|
Page::Server,
|
||||||
|
Page::Settings,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.select(self.current_page.index())
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(self.colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.divider(" │ ");
|
||||||
|
|
||||||
|
tabs.render(chunks[0], buf);
|
||||||
|
|
||||||
|
// Playback controls
|
||||||
|
let nav_style = Style::default().fg(self.colors.muted);
|
||||||
|
let play_style = match self.playback_state {
|
||||||
|
PlaybackState::Playing => Style::default().fg(self.colors.accent),
|
||||||
|
_ => Style::default().fg(self.colors.muted),
|
||||||
|
};
|
||||||
|
let pause_style = match self.playback_state {
|
||||||
|
PlaybackState::Paused => Style::default().fg(self.colors.accent),
|
||||||
|
_ => Style::default().fg(self.colors.muted),
|
||||||
|
};
|
||||||
|
let stop_style = match self.playback_state {
|
||||||
|
PlaybackState::Stopped => Style::default().fg(self.colors.accent),
|
||||||
|
_ => Style::default().fg(self.colors.muted),
|
||||||
|
};
|
||||||
|
|
||||||
|
let controls = Line::from(vec![
|
||||||
|
Span::styled(" ⏮ ", nav_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" ▶ ", play_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" ⏸ ", pause_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" ⏹ ", stop_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" ⏭ ", nav_style),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Right-align controls - " ⏮ ▶ ⏸ ⏹ ⏭ " = 5*3 + 4*1 = 19
|
||||||
|
let controls_width = 19;
|
||||||
|
let x = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
||||||
|
buf.set_line(x, chunks[1].y, &controls, controls_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clickable region in the header
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HeaderRegion {
|
||||||
|
Tab(Page),
|
||||||
|
PrevButton,
|
||||||
|
PlayButton,
|
||||||
|
PauseButton,
|
||||||
|
StopButton,
|
||||||
|
NextButton,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Header {
|
||||||
|
/// Determine which region was clicked
|
||||||
|
pub fn region_at(area: Rect, x: u16, _y: u16) -> Option<HeaderRegion> {
|
||||||
|
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||||
|
|
||||||
|
if x >= chunks[0].x && x < chunks[0].x + chunks[0].width {
|
||||||
|
// Tab area — compute actual tab positions matching Tabs widget rendering.
|
||||||
|
// Tabs renders: [pad][title][pad] [divider] [pad][title][pad] ...
|
||||||
|
// Default padding = 1 space each side. Divider = " │ " = 3 chars.
|
||||||
|
let pages = [
|
||||||
|
Page::Artists,
|
||||||
|
Page::Queue,
|
||||||
|
Page::Playlists,
|
||||||
|
Page::Server,
|
||||||
|
Page::Settings,
|
||||||
|
];
|
||||||
|
let divider_width: u16 = 3; // " │ "
|
||||||
|
let padding: u16 = 1; // 1 space each side
|
||||||
|
|
||||||
|
let rel_x = x - chunks[0].x;
|
||||||
|
let mut cursor: u16 = 0;
|
||||||
|
for (i, page) in pages.iter().enumerate() {
|
||||||
|
let label = format!("{} {}", page.shortcut(), page.label());
|
||||||
|
let tab_width = padding + label.len() as u16 + padding;
|
||||||
|
if rel_x >= cursor && rel_x < cursor + tab_width {
|
||||||
|
return Some(HeaderRegion::Tab(*page));
|
||||||
|
}
|
||||||
|
cursor += tab_width;
|
||||||
|
// Add divider (except after the last tab)
|
||||||
|
if i < pages.len() - 1 {
|
||||||
|
cursor += divider_width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if x >= chunks[1].x && x < chunks[1].x + chunks[1].width {
|
||||||
|
// Controls area — rendered as spans:
|
||||||
|
// " ⏮ " + " " + " ▶ " + " " + " ⏸ " + " " + " ⏹ " + " " + " ⏭ "
|
||||||
|
// Each button span: space + icon + space = 3 display cells (icon is 1 cell)
|
||||||
|
// Gap spans: 1 space each
|
||||||
|
// Total: 5*3 + 4*1 = 19 display cells
|
||||||
|
let controls_width: u16 = 19;
|
||||||
|
let control_start = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
||||||
|
if x >= control_start {
|
||||||
|
let offset = x - control_start;
|
||||||
|
// Layout: [0..2] ⏮ [3] gap [4..6] ▶ [7] gap [8..10] ⏸ [11] gap [12..14] ⏹ [15] gap [16..18] ⏭
|
||||||
|
return match offset {
|
||||||
|
0..=2 => Some(HeaderRegion::PrevButton),
|
||||||
|
4..=6 => Some(HeaderRegion::PlayButton),
|
||||||
|
8..=10 => Some(HeaderRegion::PauseButton),
|
||||||
|
12..=14 => Some(HeaderRegion::StopButton),
|
||||||
|
16..=18 => Some(HeaderRegion::NextButton),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/ui/layout.rs
Normal file
112
src/ui/layout.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Main layout and rendering
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::{AppState, LayoutAreas, Page};
|
||||||
|
|
||||||
|
use super::footer::Footer;
|
||||||
|
use super::header::Header;
|
||||||
|
use super::pages;
|
||||||
|
use super::widgets::{CavaWidget, NowPlayingWidget};
|
||||||
|
|
||||||
|
/// Draw the entire UI
|
||||||
|
pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
let cava_active = state.settings_state.cava_enabled && !state.cava_screen.is_empty();
|
||||||
|
|
||||||
|
// Main layout:
|
||||||
|
// [Header] - 1 line
|
||||||
|
// [Cava] - ~40% (optional, only when cava is active)
|
||||||
|
// [Page Content] - flexible
|
||||||
|
// [Now Playing] - 7 lines
|
||||||
|
// [Footer] - 1 line
|
||||||
|
|
||||||
|
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::Min(10), // Page content
|
||||||
|
Constraint::Length(7), // Now playing
|
||||||
|
Constraint::Length(1), // Footer
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], Some(chunks[1]), chunks[2], chunks[3], chunks[4])
|
||||||
|
} else {
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Header
|
||||||
|
Constraint::Min(10), // Page content
|
||||||
|
Constraint::Length(7), // Now playing
|
||||||
|
Constraint::Length(1), // Footer
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], None, chunks[1], chunks[2], chunks[3])
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute dual-pane splits for pages that use them
|
||||||
|
let (content_left, content_right) = match state.page {
|
||||||
|
Page::Artists | Page::Playlists => {
|
||||||
|
let panes = Layout::horizontal([
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Percentage(60),
|
||||||
|
])
|
||||||
|
.split(content_area);
|
||||||
|
(Some(panes[0]), Some(panes[1]))
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render header
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
let header = Header::new(state.page, state.now_playing.state, colors);
|
||||||
|
frame.render_widget(header, header_area);
|
||||||
|
|
||||||
|
// Render cava visualizer if active
|
||||||
|
if let Some(cava_rect) = cava_area {
|
||||||
|
let cava_widget = CavaWidget::new(&state.cava_screen);
|
||||||
|
frame.render_widget(cava_widget, cava_rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render current page
|
||||||
|
match state.page {
|
||||||
|
Page::Artists => {
|
||||||
|
pages::artists::render(frame, content_area, state);
|
||||||
|
}
|
||||||
|
Page::Queue => {
|
||||||
|
pages::queue::render(frame, content_area, state);
|
||||||
|
}
|
||||||
|
Page::Playlists => {
|
||||||
|
pages::playlists::render(frame, content_area, state);
|
||||||
|
}
|
||||||
|
Page::Server => {
|
||||||
|
pages::server::render(frame, content_area, state);
|
||||||
|
}
|
||||||
|
Page::Settings => {
|
||||||
|
pages::settings::render(frame, content_area, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render now playing
|
||||||
|
let now_playing = NowPlayingWidget::new(&state.now_playing, colors);
|
||||||
|
frame.render_widget(now_playing, now_playing_area);
|
||||||
|
|
||||||
|
// Render footer
|
||||||
|
let footer = Footer::new(state.page, colors)
|
||||||
|
.sample_rate(state.now_playing.sample_rate)
|
||||||
|
.notification(state.notification.as_ref());
|
||||||
|
frame.render_widget(footer, footer_area);
|
||||||
|
}
|
||||||
10
src/ui/mod.rs
Normal file
10
src/ui/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! Terminal UI module
|
||||||
|
|
||||||
|
pub mod footer;
|
||||||
|
pub mod header;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod pages;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
|
pub use layout::draw;
|
||||||
272
src/ui/pages/artists.rs
Normal file
272
src/ui/pages/artists.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
//! Artists page with tree browser and song list
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::AppState;
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
use crate::subsonic::models::{Album, Artist};
|
||||||
|
|
||||||
|
|
||||||
|
/// A tree item - either an artist or an album
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum TreeItem {
|
||||||
|
Artist { artist: Artist, expanded: bool },
|
||||||
|
Album { album: Album },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build flattened tree items from state
|
||||||
|
pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
|
||||||
|
let artists = &state.artists;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
// Filter artists by name
|
||||||
|
let filtered_artists: Vec<_> = if artists.filter.is_empty() {
|
||||||
|
artists.artists.iter().collect()
|
||||||
|
} else {
|
||||||
|
let filter_lower = artists.filter.to_lowercase();
|
||||||
|
artists
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.name.to_lowercase().contains(&filter_lower))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for artist in filtered_artists {
|
||||||
|
let is_expanded = artists.expanded.contains(&artist.id);
|
||||||
|
items.push(TreeItem::Artist {
|
||||||
|
artist: artist.clone(),
|
||||||
|
expanded: is_expanded,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
sorted_albums.sort_by(|a, b| {
|
||||||
|
// Albums with no year go last
|
||||||
|
match (a.year, b.year) {
|
||||||
|
(None, None) => std::cmp::Ordering::Equal,
|
||||||
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
|
(Some(_), None) => std::cmp::Ordering::Less,
|
||||||
|
(Some(y1), Some(y2)) => std::cmp::Ord::cmp(&y1, &y2),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for album in sorted_albums {
|
||||||
|
items.push(TreeItem::Album { album });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the artists page
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
|
||||||
|
// Split into two panes: [Tree Browser] [Song List]
|
||||||
|
let chunks =
|
||||||
|
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
|
||||||
|
|
||||||
|
render_tree(frame, chunks[0], state, &colors);
|
||||||
|
render_songs(frame, chunks[1], state, &colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the artist/album tree
|
||||||
|
fn render_tree(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||||
|
let artists = &state.artists;
|
||||||
|
|
||||||
|
let focused = artists.focus == 0;
|
||||||
|
let border_style = if focused {
|
||||||
|
Style::default().fg(colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.border_unfocused)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if artists.filter_active {
|
||||||
|
format!(" Artists (/{}) ", artists.filter)
|
||||||
|
} else if !artists.filter.is_empty() {
|
||||||
|
format!(" Artists [{}] ", artists.filter)
|
||||||
|
} else {
|
||||||
|
" Artists ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(title)
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
let tree_items = build_tree_items(state);
|
||||||
|
|
||||||
|
// Build list items from tree
|
||||||
|
let items: Vec<ListItem> = tree_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let is_selected = Some(i) == artists.selected_index;
|
||||||
|
|
||||||
|
match item {
|
||||||
|
TreeItem::Artist {
|
||||||
|
artist,
|
||||||
|
expanded: _,
|
||||||
|
} => {
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.artist)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.artist)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(artist.name.clone()).style(style)
|
||||||
|
}
|
||||||
|
TreeItem::Album { album } => {
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.album)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.album)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Indent albums with tree-style connector, show year in brackets
|
||||||
|
let year_str = album.year.map(|y| format!(" [{}]", y)).unwrap_or_default();
|
||||||
|
let text = format!(" └─ {}{}", album.name, year_str);
|
||||||
|
|
||||||
|
ListItem::new(text).style(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut list = List::new(items).block(block);
|
||||||
|
if focused {
|
||||||
|
list = list.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(colors.highlight_bg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(state.artists.selected_index);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
state.artists.tree_scroll_offset = list_state.offset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the song list for selected album
|
||||||
|
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||||
|
let artists = &state.artists;
|
||||||
|
|
||||||
|
let focused = artists.focus == 1;
|
||||||
|
let border_style = if focused {
|
||||||
|
Style::default().fg(colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.border_unfocused)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if !artists.songs.is_empty() {
|
||||||
|
if let Some(album) = artists.songs.first().and_then(|s| s.album.as_ref()) {
|
||||||
|
format!(" {} ({}) ", album, artists.songs.len())
|
||||||
|
} else {
|
||||||
|
format!(" Songs ({}) ", artists.songs.len())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
" Songs ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(title)
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
if artists.songs.is_empty() {
|
||||||
|
let hint = Paragraph::new("Select an album to view songs")
|
||||||
|
.style(Style::default().fg(colors.muted))
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(hint, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if album has multiple discs
|
||||||
|
let has_multiple_discs = artists
|
||||||
|
.songs
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.disc_number.map(|d| d > 1).unwrap_or(false));
|
||||||
|
|
||||||
|
// Build song list items
|
||||||
|
let items: Vec<ListItem> = artists
|
||||||
|
.songs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, song)| {
|
||||||
|
let is_selected = Some(i) == artists.selected_song;
|
||||||
|
let is_playing = state
|
||||||
|
.current_song()
|
||||||
|
.map(|s| s.id == song.id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let indicator = if is_playing { "▶ " } else { " " };
|
||||||
|
// Show disc.track format for multi-disc albums
|
||||||
|
let track = if has_multiple_discs {
|
||||||
|
match (song.disc_number, song.track) {
|
||||||
|
(Some(d), Some(t)) => format!("{}.{:02}. ", d, t),
|
||||||
|
(None, Some(t)) => format!("{:02}. ", t),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
song.track
|
||||||
|
.map(|t| format!("{:02}. ", t))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let duration = song.format_duration();
|
||||||
|
let title = song.title.clone();
|
||||||
|
|
||||||
|
// Colors based on state
|
||||||
|
let (title_color, track_color, time_color) = if is_selected {
|
||||||
|
// When highlighted, use highlight foreground for readability
|
||||||
|
(
|
||||||
|
colors.highlight_fg,
|
||||||
|
colors.highlight_fg,
|
||||||
|
colors.highlight_fg,
|
||||||
|
)
|
||||||
|
} else if is_playing {
|
||||||
|
(colors.playing, colors.muted, colors.muted)
|
||||||
|
} else {
|
||||||
|
(colors.song, colors.muted, colors.muted)
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(indicator.to_string(), Style::default().fg(colors.playing)),
|
||||||
|
Span::styled(track, Style::default().fg(track_color)),
|
||||||
|
Span::styled(title, Style::default().fg(title_color)),
|
||||||
|
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ListItem::new(line)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut list = List::new(items).block(block);
|
||||||
|
if focused {
|
||||||
|
list = list.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(colors.highlight_bg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(artists.selected_song);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
state.artists.song_scroll_offset = list_state.offset();
|
||||||
|
}
|
||||||
7
src/ui/pages/mod.rs
Normal file
7
src/ui/pages/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! UI page implementations
|
||||||
|
|
||||||
|
pub mod artists;
|
||||||
|
pub mod playlists;
|
||||||
|
pub mod queue;
|
||||||
|
pub mod server;
|
||||||
|
pub mod settings;
|
||||||
197
src/ui/pages/playlists.rs
Normal file
197
src/ui/pages/playlists.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//! Playlists page with dual-panel browser
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::AppState;
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
|
||||||
|
/// Render the playlists page
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
|
||||||
|
// Split into two panes: [Playlists] [Songs]
|
||||||
|
let chunks =
|
||||||
|
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
|
||||||
|
|
||||||
|
render_playlists(frame, chunks[0], state, &colors);
|
||||||
|
render_songs(frame, chunks[1], state, &colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the playlists list
|
||||||
|
fn render_playlists(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||||
|
let playlists = &state.playlists;
|
||||||
|
|
||||||
|
let focused = playlists.focus == 0;
|
||||||
|
let border_style = if focused {
|
||||||
|
Style::default().fg(colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.border_unfocused)
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Playlists ({}) ", playlists.playlists.len()))
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
if playlists.playlists.is_empty() {
|
||||||
|
let hint = Paragraph::new("No playlists found")
|
||||||
|
.style(Style::default().fg(colors.muted))
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(hint, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = playlists
|
||||||
|
.playlists
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, playlist)| {
|
||||||
|
let is_selected = playlists.selected_playlist == Some(i);
|
||||||
|
|
||||||
|
let count = playlist.song_count.unwrap_or(0);
|
||||||
|
let duration = playlist.duration.map(|d| {
|
||||||
|
let mins = d / 60;
|
||||||
|
let secs = d % 60;
|
||||||
|
format!("{}:{:02}", mins, secs)
|
||||||
|
});
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.album)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(&playlist.name, style),
|
||||||
|
Span::styled(
|
||||||
|
format!(" ({} songs)", count),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(dur) = duration {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" [{}]", dur),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem::new(Line::from(spans))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut list = List::new(items).block(block);
|
||||||
|
if focused {
|
||||||
|
list = list
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(colors.highlight_bg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("▸ ");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(playlists.selected_playlist);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
state.playlists.playlist_scroll_offset = list_state.offset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the songs in selected playlist
|
||||||
|
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||||
|
let playlists = &state.playlists;
|
||||||
|
|
||||||
|
let focused = playlists.focus == 1;
|
||||||
|
let border_style = if focused {
|
||||||
|
Style::default().fg(colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.border_unfocused)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if !playlists.songs.is_empty() {
|
||||||
|
format!(" Songs ({}) ", playlists.songs.len())
|
||||||
|
} else {
|
||||||
|
" Songs ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(title)
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
if playlists.songs.is_empty() {
|
||||||
|
let hint = Paragraph::new("Select a playlist to view songs")
|
||||||
|
.style(Style::default().fg(colors.muted))
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(hint, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = playlists
|
||||||
|
.songs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, song)| {
|
||||||
|
let is_selected = playlists.selected_song == Some(i);
|
||||||
|
let is_playing = state
|
||||||
|
.current_song()
|
||||||
|
.map(|s| s.id == song.id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let indicator = if is_playing { "▶ " } else { " " };
|
||||||
|
let artist = song.artist.clone().unwrap_or_default();
|
||||||
|
let duration = song.format_duration();
|
||||||
|
|
||||||
|
// Colors based on state
|
||||||
|
let (title_color, artist_color, time_color) = if is_selected {
|
||||||
|
(
|
||||||
|
colors.highlight_fg,
|
||||||
|
colors.highlight_fg,
|
||||||
|
colors.highlight_fg,
|
||||||
|
)
|
||||||
|
} else if is_playing {
|
||||||
|
(colors.playing, colors.muted, colors.muted)
|
||||||
|
} else {
|
||||||
|
(colors.song, colors.muted, colors.muted)
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(indicator, Style::default().fg(colors.playing)),
|
||||||
|
Span::styled(&song.title, Style::default().fg(title_color)),
|
||||||
|
if !artist.is_empty() {
|
||||||
|
Span::styled(format!(" - {}", artist), Style::default().fg(artist_color))
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
},
|
||||||
|
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ListItem::new(line)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut list = List::new(items).block(block);
|
||||||
|
if focused {
|
||||||
|
list = list.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(colors.highlight_bg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(playlists.selected_song);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
state.playlists.song_scroll_offset = list_state.offset();
|
||||||
|
}
|
||||||
118
src/ui/pages/queue.rs
Normal file
118
src/ui/pages/queue.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//! Queue page showing current play queue
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
/// Render the queue page
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Queue ({}) ", state.queue.len()))
|
||||||
|
.border_style(Style::default().fg(colors.border_focused));
|
||||||
|
|
||||||
|
if state.queue.is_empty() {
|
||||||
|
let hint = Paragraph::new("Queue is empty. Add songs from Artists or Playlists.")
|
||||||
|
.style(Style::default().fg(colors.muted))
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(hint, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = state
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, song)| {
|
||||||
|
let is_current = state.queue_position == Some(i);
|
||||||
|
let is_selected = state.queue_state.selected == Some(i);
|
||||||
|
let is_played = state.queue_position.map(|pos| i < pos).unwrap_or(false);
|
||||||
|
|
||||||
|
let indicator = if is_current { "▶ " } else { " " };
|
||||||
|
|
||||||
|
let artist = song.artist.clone().unwrap_or_default();
|
||||||
|
let duration = song.format_duration();
|
||||||
|
// Show disc.track for songs with disc info
|
||||||
|
let track_info = match (song.disc_number, song.track) {
|
||||||
|
(Some(d), Some(t)) if d > 1 => format!(" [{}.{}]", d, t),
|
||||||
|
(_, Some(t)) => format!(" [#{}]", t),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color scheme: played = muted, current = playing color, upcoming = song color
|
||||||
|
let (title_style, artist_style, number_style) = if is_current {
|
||||||
|
(
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.playing)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().fg(colors.playing),
|
||||||
|
Style::default().fg(colors.playing),
|
||||||
|
)
|
||||||
|
} else if is_played {
|
||||||
|
(
|
||||||
|
if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.played)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.played)
|
||||||
|
},
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
)
|
||||||
|
} else if is_selected {
|
||||||
|
(
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
Style::default().fg(colors.song),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(format!("{:3}. ", i + 1), number_style),
|
||||||
|
Span::styled(indicator, Style::default().fg(colors.playing)),
|
||||||
|
Span::styled(song.title.clone(), title_style),
|
||||||
|
Span::styled(track_info, Style::default().fg(colors.muted)),
|
||||||
|
if !artist.is_empty() {
|
||||||
|
Span::styled(format!(" - {}", artist), artist_style)
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
},
|
||||||
|
Span::styled(
|
||||||
|
format!(" [{}]", duration),
|
||||||
|
Style::default().fg(colors.muted),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ListItem::new(line)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(block)
|
||||||
|
.highlight_style(Style::default().bg(colors.highlight_bg))
|
||||||
|
.highlight_symbol("▸ ");
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(state.queue_state.selected);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
state.queue_state.scroll_offset = list_state.offset();
|
||||||
|
}
|
||||||
179
src/ui/pages/server.rs
Normal file
179
src/ui/pages/server.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! Server page with connection settings form
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::AppState;
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
/// Render the server page
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Server Connection ")
|
||||||
|
.border_style(Style::default().fg(colors.border_focused));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
if inner.height < 10 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = &state.server_state;
|
||||||
|
|
||||||
|
// Layout fields vertically with spacing
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Length(4), // Server URL (1 label + 3 field)
|
||||||
|
Constraint::Length(4), // Username (1 label + 3 field)
|
||||||
|
Constraint::Length(4), // Password (1 label + 3 field)
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Length(1), // Test button
|
||||||
|
Constraint::Length(1), // Save button
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Min(1), // Status
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// Server URL field - show cursor when selected (always editable)
|
||||||
|
render_field(
|
||||||
|
frame,
|
||||||
|
chunks[1],
|
||||||
|
"Server URL",
|
||||||
|
&server.base_url,
|
||||||
|
server.selected_field == 0,
|
||||||
|
server.selected_field == 0, // cursor when selected
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Username field
|
||||||
|
render_field(
|
||||||
|
frame,
|
||||||
|
chunks[2],
|
||||||
|
"Username",
|
||||||
|
&server.username,
|
||||||
|
server.selected_field == 1,
|
||||||
|
server.selected_field == 1,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
render_field(
|
||||||
|
frame,
|
||||||
|
chunks[3],
|
||||||
|
"Password",
|
||||||
|
&"*".repeat(server.password.len()),
|
||||||
|
server.selected_field == 2,
|
||||||
|
server.selected_field == 2,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test button
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
chunks[5],
|
||||||
|
"Test Connection",
|
||||||
|
server.selected_field == 3,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
chunks[6],
|
||||||
|
"Save",
|
||||||
|
server.selected_field == 4,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status message
|
||||||
|
if let Some(ref status) = server.status {
|
||||||
|
let style: Style = if status.contains("failed") || status.contains("error") {
|
||||||
|
Style::default().fg(colors.error)
|
||||||
|
} else if status.contains("saved") || status.contains("success") {
|
||||||
|
Style::default().fg(colors.success)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.accent)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_text = Paragraph::new(status.as_str()).style(style);
|
||||||
|
frame.render_widget(status_text, chunks[8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a form field
|
||||||
|
fn render_field(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
label: &str,
|
||||||
|
value: &str,
|
||||||
|
selected: bool,
|
||||||
|
editing: bool,
|
||||||
|
colors: &ThemeColors,
|
||||||
|
) {
|
||||||
|
let label_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.highlight_fg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let value_style = if editing {
|
||||||
|
Style::default().fg(colors.accent)
|
||||||
|
} else if selected {
|
||||||
|
Style::default().fg(colors.primary)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.muted)
|
||||||
|
};
|
||||||
|
|
||||||
|
let border_style = if selected {
|
||||||
|
Style::default().fg(colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.border_unfocused)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Label on first line
|
||||||
|
let label_text = Paragraph::new(label).style(label_style);
|
||||||
|
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
|
||||||
|
|
||||||
|
// Value field with border on second line (height 3 = 1 top border + 1 content + 1 bottom border)
|
||||||
|
let field_area = Rect::new(area.x, area.y + 1, area.width.min(60), 3);
|
||||||
|
|
||||||
|
let display_value = if editing {
|
||||||
|
format!("{}▏", value)
|
||||||
|
} else {
|
||||||
|
value.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let field = Paragraph::new(display_value).style(value_style).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(field, field_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a button
|
||||||
|
fn render_button(frame: &mut Frame, area: Rect, label: &str, selected: bool, colors: &ThemeColors) {
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.highlight_fg)
|
||||||
|
.bg(colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.muted)
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = format!("[ {} ]", label);
|
||||||
|
let button = Paragraph::new(text).style(style);
|
||||||
|
frame.render_widget(button, area);
|
||||||
|
}
|
||||||
123
src/ui/pages/settings.rs
Normal file
123
src/ui/pages/settings.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//! Settings page with app preferences and theming
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::AppState;
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
/// Render the settings page
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let colors = *state.settings_state.theme_colors();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Settings ")
|
||||||
|
.border_style(Style::default().fg(colors.border_focused));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
if inner.height < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = &state.settings_state;
|
||||||
|
|
||||||
|
// Layout fields vertically with spacing
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Length(2), // Theme selector
|
||||||
|
Constraint::Length(1), // Spacing
|
||||||
|
Constraint::Length(2), // Cava toggle
|
||||||
|
Constraint::Min(1), // Remaining space
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// Theme selector (field 0)
|
||||||
|
render_option(
|
||||||
|
frame,
|
||||||
|
chunks[1],
|
||||||
|
"Theme",
|
||||||
|
settings.theme_name(),
|
||||||
|
settings.selected_field == 0,
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cava toggle (field 1)
|
||||||
|
let cava_value = if !state.cava_available {
|
||||||
|
"Off (cava not found)"
|
||||||
|
} else if settings.cava_enabled {
|
||||||
|
"On"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
};
|
||||||
|
|
||||||
|
render_option(
|
||||||
|
frame,
|
||||||
|
chunks[3],
|
||||||
|
"Cava Visualizer",
|
||||||
|
cava_value,
|
||||||
|
settings.selected_field == 1,
|
||||||
|
&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",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
|
||||||
|
|
||||||
|
let help_area = Rect::new(
|
||||||
|
inner.x,
|
||||||
|
inner.y + inner.height.saturating_sub(2),
|
||||||
|
inner.width,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
frame.render_widget(help, help_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an option selector
|
||||||
|
fn render_option(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
label: &str,
|
||||||
|
value: &str,
|
||||||
|
selected: bool,
|
||||||
|
colors: &ThemeColors,
|
||||||
|
) {
|
||||||
|
let label_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.primary)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.highlight_fg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let value_style = if selected {
|
||||||
|
Style::default().fg(colors.accent)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.muted)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Label
|
||||||
|
let label_text = Paragraph::new(label).style(label_style);
|
||||||
|
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
|
||||||
|
|
||||||
|
// Value with arrows
|
||||||
|
let value_text = if selected {
|
||||||
|
format!(" ◀ {} ▶", value)
|
||||||
|
} else {
|
||||||
|
format!(" {}", value)
|
||||||
|
};
|
||||||
|
|
||||||
|
let value_para = Paragraph::new(value_text).style(value_style);
|
||||||
|
frame.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 1));
|
||||||
|
}
|
||||||
553
src/ui/theme.rs
Normal file
553
src/ui/theme.rs
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
//! Theme color definitions — file-based themes loaded from ~/.config/ferrosonic/themes/
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::config::paths;
|
||||||
|
|
||||||
|
/// Color palette for a theme
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ThemeColors {
|
||||||
|
/// Primary highlight color (focused elements, selected tabs)
|
||||||
|
pub primary: Color,
|
||||||
|
/// Secondary color (borders, less important elements)
|
||||||
|
pub secondary: Color,
|
||||||
|
/// Accent color (currently playing, important highlights)
|
||||||
|
pub accent: Color,
|
||||||
|
/// Artist names
|
||||||
|
pub artist: Color,
|
||||||
|
/// Album names
|
||||||
|
pub album: Color,
|
||||||
|
/// Song titles (default)
|
||||||
|
pub song: Color,
|
||||||
|
/// Muted text (track numbers, durations, hints)
|
||||||
|
pub muted: Color,
|
||||||
|
/// Selection/highlight background
|
||||||
|
pub highlight_bg: Color,
|
||||||
|
/// Text on highlighted background
|
||||||
|
pub highlight_fg: Color,
|
||||||
|
/// Success messages
|
||||||
|
pub success: Color,
|
||||||
|
/// Error messages
|
||||||
|
pub error: Color,
|
||||||
|
/// Playing indicator
|
||||||
|
pub playing: Color,
|
||||||
|
/// Played songs in queue
|
||||||
|
pub played: Color,
|
||||||
|
/// Border color (focused)
|
||||||
|
pub border_focused: Color,
|
||||||
|
/// Border color (unfocused)
|
||||||
|
pub border_unfocused: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loaded theme: display name + colors + cava gradients
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ThemeData {
|
||||||
|
/// Display name (e.g. "Catppuccin", "Default")
|
||||||
|
pub name: String,
|
||||||
|
/// UI colors
|
||||||
|
pub colors: ThemeColors,
|
||||||
|
/// Cava vertical gradient (8 hex strings)
|
||||||
|
pub cava_gradient: [String; 8],
|
||||||
|
/// Cava horizontal gradient (8 hex strings)
|
||||||
|
pub cava_horizontal_gradient: [String; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TOML deserialization structs ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ThemeFile {
|
||||||
|
colors: ThemeFileColors,
|
||||||
|
cava: Option<ThemeFileCava>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ThemeFileColors {
|
||||||
|
primary: String,
|
||||||
|
secondary: String,
|
||||||
|
accent: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
song: String,
|
||||||
|
muted: String,
|
||||||
|
highlight_bg: String,
|
||||||
|
highlight_fg: String,
|
||||||
|
success: String,
|
||||||
|
error: String,
|
||||||
|
playing: String,
|
||||||
|
played: String,
|
||||||
|
border_focused: String,
|
||||||
|
border_unfocused: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ThemeFileCava {
|
||||||
|
gradient: Option<Vec<String>>,
|
||||||
|
horizontal_gradient: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hex color parsing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn hex_to_color(hex: &str) -> Color {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
if hex.len() == 6 {
|
||||||
|
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||||
|
u8::from_str_radix(&hex[0..2], 16),
|
||||||
|
u8::from_str_radix(&hex[2..4], 16),
|
||||||
|
u8::from_str_radix(&hex[4..6], 16),
|
||||||
|
) {
|
||||||
|
return Color::Rgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn!("Invalid hex color '{}', falling back to white", hex);
|
||||||
|
Color::White
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_gradient(values: &[String], fallback: &[&str; 8]) -> [String; 8] {
|
||||||
|
let mut result: [String; 8] = std::array::from_fn(|i| fallback[i].to_string());
|
||||||
|
for (i, v) in values.iter().enumerate().take(8) {
|
||||||
|
result[i] = v.clone();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ThemeData construction ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl ThemeData {
|
||||||
|
fn from_file_content(name: &str, content: &str) -> Result<Self, String> {
|
||||||
|
let file: ThemeFile =
|
||||||
|
toml::from_str(content).map_err(|e| format!("Failed to parse theme '{}': {}", name, e))?;
|
||||||
|
|
||||||
|
let c = &file.colors;
|
||||||
|
let colors = ThemeColors {
|
||||||
|
primary: hex_to_color(&c.primary),
|
||||||
|
secondary: hex_to_color(&c.secondary),
|
||||||
|
accent: hex_to_color(&c.accent),
|
||||||
|
artist: hex_to_color(&c.artist),
|
||||||
|
album: hex_to_color(&c.album),
|
||||||
|
song: hex_to_color(&c.song),
|
||||||
|
muted: hex_to_color(&c.muted),
|
||||||
|
highlight_bg: hex_to_color(&c.highlight_bg),
|
||||||
|
highlight_fg: hex_to_color(&c.highlight_fg),
|
||||||
|
success: hex_to_color(&c.success),
|
||||||
|
error: hex_to_color(&c.error),
|
||||||
|
playing: hex_to_color(&c.playing),
|
||||||
|
played: hex_to_color(&c.played),
|
||||||
|
border_focused: hex_to_color(&c.border_focused),
|
||||||
|
border_unfocused: hex_to_color(&c.border_unfocused),
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_g: [&str; 8] = [
|
||||||
|
"#59cc33", "#cccc33", "#cc8033", "#cc5533",
|
||||||
|
"#cc3333", "#bb1111", "#990000", "#990000",
|
||||||
|
];
|
||||||
|
let default_h: [&str; 8] = [
|
||||||
|
"#c45161", "#e094a0", "#f2b6c0", "#f2dde1",
|
||||||
|
"#cbc7d8", "#8db7d2", "#5e62a9", "#434279",
|
||||||
|
];
|
||||||
|
|
||||||
|
let cava = file.cava.as_ref();
|
||||||
|
let cava_gradient = match cava.and_then(|c| c.gradient.as_ref()) {
|
||||||
|
Some(g) => parse_gradient(g, &default_g),
|
||||||
|
None => std::array::from_fn(|i| default_g[i].to_string()),
|
||||||
|
};
|
||||||
|
let cava_horizontal_gradient = match cava.and_then(|c| c.horizontal_gradient.as_ref()) {
|
||||||
|
Some(h) => parse_gradient(h, &default_h),
|
||||||
|
None => std::array::from_fn(|i| default_h[i].to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ThemeData {
|
||||||
|
name: name.to_string(),
|
||||||
|
colors,
|
||||||
|
cava_gradient,
|
||||||
|
cava_horizontal_gradient,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hardcoded Default theme
|
||||||
|
pub fn default_theme() -> Self {
|
||||||
|
ThemeData {
|
||||||
|
name: "Default".to_string(),
|
||||||
|
colors: ThemeColors {
|
||||||
|
primary: Color::Cyan,
|
||||||
|
secondary: Color::DarkGray,
|
||||||
|
accent: Color::Yellow,
|
||||||
|
artist: Color::LightGreen,
|
||||||
|
album: Color::Magenta,
|
||||||
|
song: Color::Magenta,
|
||||||
|
muted: Color::Gray,
|
||||||
|
highlight_bg: Color::Rgb(102, 51, 153),
|
||||||
|
highlight_fg: Color::White,
|
||||||
|
success: Color::Green,
|
||||||
|
error: Color::Red,
|
||||||
|
playing: Color::LightGreen,
|
||||||
|
played: Color::Red,
|
||||||
|
border_focused: Color::Cyan,
|
||||||
|
border_unfocused: Color::DarkGray,
|
||||||
|
},
|
||||||
|
cava_gradient: [
|
||||||
|
"#59cc33".into(), "#cccc33".into(), "#cc8033".into(), "#cc5533".into(),
|
||||||
|
"#cc3333".into(), "#bb1111".into(), "#990000".into(), "#990000".into(),
|
||||||
|
],
|
||||||
|
cava_horizontal_gradient: [
|
||||||
|
"#c45161".into(), "#e094a0".into(), "#f2b6c0".into(), "#f2dde1".into(),
|
||||||
|
"#cbc7d8".into(), "#8db7d2".into(), "#5e62a9".into(), "#434279".into(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load all themes: Default (hardcoded) + TOML files from themes dir (sorted alphabetically)
|
||||||
|
pub fn load_themes() -> Vec<ThemeData> {
|
||||||
|
let mut themes = vec![ThemeData::default_theme()];
|
||||||
|
|
||||||
|
if let Some(dir) = paths::themes_dir() {
|
||||||
|
if dir.is_dir() {
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.path()
|
||||||
|
.extension()
|
||||||
|
.map_or(false, |ext| ext == "toml")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.path();
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
// Capitalize first letter for display name
|
||||||
|
let name = titlecase_filename(stem);
|
||||||
|
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(content) => match ThemeData::from_file_content(&name, &content) {
|
||||||
|
Ok(theme) => {
|
||||||
|
info!("Loaded theme '{}' from {}", name, path.display());
|
||||||
|
themes.push(theme);
|
||||||
|
}
|
||||||
|
Err(e) => error!("{}", e),
|
||||||
|
},
|
||||||
|
Err(e) => error!("Failed to read {}: {}", path.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 == '_')
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.map(|word| {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => {
|
||||||
|
let upper: String = first.to_uppercase().collect();
|
||||||
|
upper + chars.as_str()
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seeding built-in themes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Write the built-in themes as TOML files into the given directory.
|
||||||
|
/// Only writes files that don't already exist.
|
||||||
|
pub fn seed_default_themes(dir: &Path) {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
error!("Failed to create themes directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (filename, content) in BUILTIN_THEMES {
|
||||||
|
let path = dir.join(filename);
|
||||||
|
if !path.exists() {
|
||||||
|
if let Err(e) = std::fs::write(&path, content) {
|
||||||
|
error!("Failed to write theme {}: {}", filename, e);
|
||||||
|
} else {
|
||||||
|
info!("Seeded theme file: {}", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
"##),
|
||||||
|
];
|
||||||
61
src/ui/widgets/cava.rs
Normal file
61
src/ui/widgets/cava.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Cava audio visualizer widget — renders captured noncurses output
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::Widget,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::{CavaColor, CavaRow};
|
||||||
|
|
||||||
|
pub struct CavaWidget<'a> {
|
||||||
|
screen: &'a [CavaRow],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CavaWidget<'a> {
|
||||||
|
pub fn new(screen: &'a [CavaRow]) -> Self {
|
||||||
|
Self { screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cava_color_to_ratatui(c: CavaColor) -> Option<Color> {
|
||||||
|
match c {
|
||||||
|
CavaColor::Default => None,
|
||||||
|
CavaColor::Indexed(i) => Some(Color::Indexed(i)),
|
||||||
|
CavaColor::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for CavaWidget<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.width == 0 || area.height == 0 || self.screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row_idx, cava_row) in self.screen.iter().enumerate() {
|
||||||
|
if row_idx >= area.height as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let y = area.y + row_idx as u16;
|
||||||
|
let mut x = area.x;
|
||||||
|
|
||||||
|
for span in &cava_row.spans {
|
||||||
|
for ch in span.text.chars() {
|
||||||
|
if x >= area.x + area.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut style = Style::default();
|
||||||
|
if let Some(fg) = cava_color_to_ratatui(span.fg) {
|
||||||
|
style = style.fg(fg);
|
||||||
|
}
|
||||||
|
if let Some(bg) = cava_color_to_ratatui(span.bg) {
|
||||||
|
style = style.bg(bg);
|
||||||
|
}
|
||||||
|
buf[(x, y)].set_char(ch).set_style(style);
|
||||||
|
x += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/widgets/mod.rs
Normal file
8
src/ui/widgets/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Custom UI widgets
|
||||||
|
|
||||||
|
pub mod cava;
|
||||||
|
pub mod now_playing;
|
||||||
|
pub mod progress_bar;
|
||||||
|
|
||||||
|
pub use cava::CavaWidget;
|
||||||
|
pub use now_playing::NowPlayingWidget;
|
||||||
283
src/ui/widgets/now_playing.rs
Normal file
283
src/ui/widgets/now_playing.rs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
//! Now playing display widget
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Alignment, Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::state::NowPlaying;
|
||||||
|
use crate::ui::theme::ThemeColors;
|
||||||
|
|
||||||
|
/// Now playing panel widget
|
||||||
|
pub struct NowPlayingWidget<'a> {
|
||||||
|
now_playing: &'a NowPlaying,
|
||||||
|
focused: bool,
|
||||||
|
colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> NowPlayingWidget<'a> {
|
||||||
|
pub fn new(now_playing: &'a NowPlaying, colors: ThemeColors) -> Self {
|
||||||
|
Self {
|
||||||
|
now_playing,
|
||||||
|
focused: false,
|
||||||
|
colors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn focused(mut self, focused: bool) -> Self {
|
||||||
|
self.focused = focused;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for NowPlayingWidget<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
// Need at least 6 rows for full display
|
||||||
|
if area.height < 4 || area.width < 20 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Now Playing ")
|
||||||
|
.border_style(if self.focused {
|
||||||
|
Style::default().fg(self.colors.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(self.colors.border_unfocused)
|
||||||
|
});
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if something is playing
|
||||||
|
if self.now_playing.song.is_none() {
|
||||||
|
let no_track = Paragraph::new("No track playing")
|
||||||
|
.style(Style::default().fg(self.colors.muted))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
no_track.render(inner, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let song = self.now_playing.song.as_ref().unwrap();
|
||||||
|
|
||||||
|
// Build centered lines like Go version:
|
||||||
|
// Line 1: Artist (green)
|
||||||
|
// Line 2: Album (purple/magenta)
|
||||||
|
// Line 3: Title (white, bold)
|
||||||
|
// Line 4: Quality info (gray)
|
||||||
|
// Line 5: Progress bar
|
||||||
|
|
||||||
|
let artist = song.artist.clone().unwrap_or_default();
|
||||||
|
let album = song.album.clone().unwrap_or_default();
|
||||||
|
let title = song.title.clone();
|
||||||
|
|
||||||
|
// Build quality string
|
||||||
|
let mut quality_parts = Vec::new();
|
||||||
|
if let Some(ref fmt) = self.now_playing.format {
|
||||||
|
quality_parts.push(fmt.to_string().to_uppercase());
|
||||||
|
}
|
||||||
|
if let Some(bits) = self.now_playing.bit_depth {
|
||||||
|
quality_parts.push(format!("{}-bit", bits));
|
||||||
|
}
|
||||||
|
if let Some(rate) = self.now_playing.sample_rate {
|
||||||
|
let khz = rate as f64 / 1000.0;
|
||||||
|
if khz == khz.floor() {
|
||||||
|
quality_parts.push(format!("{}kHz", khz as u32));
|
||||||
|
} else {
|
||||||
|
quality_parts.push(format!("{:.1}kHz", khz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref channels) = self.now_playing.channels {
|
||||||
|
quality_parts.push(channels.to_string());
|
||||||
|
}
|
||||||
|
let quality = quality_parts.join(" │ ");
|
||||||
|
|
||||||
|
// Layout based on available height
|
||||||
|
if inner.height >= 5 {
|
||||||
|
// Full layout with separate lines
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Artist
|
||||||
|
Constraint::Length(1), // Album
|
||||||
|
Constraint::Length(1), // Title
|
||||||
|
Constraint::Length(1), // Quality
|
||||||
|
Constraint::Length(1), // Progress
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// Artist line (centered, artist color)
|
||||||
|
let artist_line = Line::from(vec![Span::styled(
|
||||||
|
&artist,
|
||||||
|
Style::default().fg(self.colors.artist),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(artist_line)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[0], buf);
|
||||||
|
|
||||||
|
// Album line (centered, album color)
|
||||||
|
let album_line = Line::from(vec![Span::styled(
|
||||||
|
&album,
|
||||||
|
Style::default().fg(self.colors.album),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(album_line)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[1], buf);
|
||||||
|
|
||||||
|
// Title line (centered, bold)
|
||||||
|
let title_line = Line::from(vec![Span::styled(
|
||||||
|
&title,
|
||||||
|
Style::default()
|
||||||
|
.fg(self.colors.highlight_fg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(title_line)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[2], buf);
|
||||||
|
|
||||||
|
// Quality line (centered, muted)
|
||||||
|
if !quality.is_empty() {
|
||||||
|
let quality_line = Line::from(vec![Span::styled(
|
||||||
|
&quality,
|
||||||
|
Style::default().fg(self.colors.muted),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(quality_line)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[3], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
render_progress_bar(
|
||||||
|
chunks[4],
|
||||||
|
buf,
|
||||||
|
self.now_playing.progress_percent(),
|
||||||
|
&self.now_playing.format_position(),
|
||||||
|
&self.now_playing.format_duration(),
|
||||||
|
&self.colors,
|
||||||
|
);
|
||||||
|
} else if inner.height >= 3 {
|
||||||
|
// Compact layout
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Artist - Title
|
||||||
|
Constraint::Length(1), // Album / Quality
|
||||||
|
Constraint::Length(1), // Progress
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// Combined artist - title line
|
||||||
|
let line1 = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
&title,
|
||||||
|
Style::default()
|
||||||
|
.fg(self.colors.highlight_fg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" - ", Style::default().fg(self.colors.muted)),
|
||||||
|
Span::styled(&artist, Style::default().fg(self.colors.artist)),
|
||||||
|
]);
|
||||||
|
Paragraph::new(line1)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[0], buf);
|
||||||
|
|
||||||
|
// Album line
|
||||||
|
let line2 = Line::from(vec![Span::styled(
|
||||||
|
&album,
|
||||||
|
Style::default().fg(self.colors.album),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(line2)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[1], buf);
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
render_progress_bar(
|
||||||
|
chunks[2],
|
||||||
|
buf,
|
||||||
|
self.now_playing.progress_percent(),
|
||||||
|
&self.now_playing.format_position(),
|
||||||
|
&self.now_playing.format_duration(),
|
||||||
|
&self.colors,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Minimal layout
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(1), // Title
|
||||||
|
Constraint::Length(1), // Progress
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let line1 = Line::from(vec![Span::styled(
|
||||||
|
&title,
|
||||||
|
Style::default().fg(self.colors.highlight_fg),
|
||||||
|
)]);
|
||||||
|
Paragraph::new(line1)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(chunks[0], buf);
|
||||||
|
|
||||||
|
render_progress_bar(
|
||||||
|
chunks[1],
|
||||||
|
buf,
|
||||||
|
self.now_playing.progress_percent(),
|
||||||
|
&self.now_playing.format_position(),
|
||||||
|
&self.now_playing.format_duration(),
|
||||||
|
&self.colors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a simple progress bar
|
||||||
|
fn render_progress_bar(
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
progress: f64,
|
||||||
|
pos: &str,
|
||||||
|
dur: &str,
|
||||||
|
colors: &ThemeColors,
|
||||||
|
) {
|
||||||
|
if area.width < 15 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: "00:00 / 00:00 [════════════────────]"
|
||||||
|
let time_str = format!("{} / {}", pos, dur);
|
||||||
|
let time_width = time_str.len() as u16;
|
||||||
|
|
||||||
|
// Calculate positions - center the whole thing
|
||||||
|
let bar_width = area.width.saturating_sub(time_width + 3); // 2 spaces + some padding
|
||||||
|
let total_width = time_width + 2 + bar_width;
|
||||||
|
let start_x = area.x + (area.width.saturating_sub(total_width)) / 2;
|
||||||
|
|
||||||
|
// Draw time string
|
||||||
|
buf.set_string(
|
||||||
|
start_x,
|
||||||
|
area.y,
|
||||||
|
&time_str,
|
||||||
|
Style::default().fg(colors.highlight_fg),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw progress bar
|
||||||
|
let bar_start = start_x + time_width + 2;
|
||||||
|
if bar_width > 0 {
|
||||||
|
let filled = (bar_width as f64 * progress) as u16;
|
||||||
|
|
||||||
|
// Draw filled portion (success color like Go version)
|
||||||
|
for x in bar_start..(bar_start + filled) {
|
||||||
|
buf[(x, area.y)]
|
||||||
|
.set_char('━')
|
||||||
|
.set_style(Style::default().fg(colors.success));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw empty portion
|
||||||
|
for x in (bar_start + filled)..(bar_start + bar_width) {
|
||||||
|
buf[(x, area.y)]
|
||||||
|
.set_char('─')
|
||||||
|
.set_style(Style::default().fg(colors.muted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/ui/widgets/progress_bar.rs
Normal file
165
src/ui/widgets/progress_bar.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Progress bar widget with seek support
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::Widget,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A horizontal progress bar with time display
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ProgressBar<'a> {
|
||||||
|
/// Progress value (0.0 to 1.0)
|
||||||
|
progress: f64,
|
||||||
|
/// Current position formatted
|
||||||
|
position_text: &'a str,
|
||||||
|
/// Total duration formatted
|
||||||
|
duration_text: &'a str,
|
||||||
|
/// Filled portion style
|
||||||
|
filled_style: Style,
|
||||||
|
/// Empty portion style
|
||||||
|
empty_style: Style,
|
||||||
|
/// Text style
|
||||||
|
text_style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl<'a> ProgressBar<'a> {
|
||||||
|
pub fn new(progress: f64, position_text: &'a str, duration_text: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
progress: progress.clamp(0.0, 1.0),
|
||||||
|
position_text,
|
||||||
|
duration_text,
|
||||||
|
filled_style: Style::default().bg(Color::Blue),
|
||||||
|
empty_style: Style::default().bg(Color::DarkGray),
|
||||||
|
text_style: Style::default().fg(Color::White),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filled_style(mut self, style: Style) -> Self {
|
||||||
|
self.filled_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_style(mut self, style: Style) -> Self {
|
||||||
|
self.empty_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_style(mut self, style: Style) -> Self {
|
||||||
|
self.text_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate position from x coordinate within the bar area
|
||||||
|
pub fn position_from_x(area: Rect, x: u16) -> Option<f64> {
|
||||||
|
// Account for time text margins
|
||||||
|
let bar_start = area.x + 8; // "00:00 " prefix
|
||||||
|
let bar_end = area.x + area.width - 8; // " 00:00" suffix
|
||||||
|
|
||||||
|
if x >= bar_start && x < bar_end {
|
||||||
|
let bar_width = bar_end - bar_start;
|
||||||
|
let relative_x = x - bar_start;
|
||||||
|
Some(relative_x as f64 / bar_width as f64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ProgressBar<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.width < 20 || area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: "00:00 [==========----------] 00:00"
|
||||||
|
let pos_width = self.position_text.len();
|
||||||
|
let dur_width = self.duration_text.len();
|
||||||
|
|
||||||
|
// Draw position text
|
||||||
|
buf.set_string(area.x, area.y, self.position_text, self.text_style);
|
||||||
|
|
||||||
|
// Draw duration text
|
||||||
|
let dur_x = area.x + area.width - dur_width as u16;
|
||||||
|
buf.set_string(dur_x, area.y, self.duration_text, self.text_style);
|
||||||
|
|
||||||
|
// Calculate bar area
|
||||||
|
let bar_x = area.x + pos_width as u16 + 1;
|
||||||
|
let bar_width = area
|
||||||
|
.width
|
||||||
|
.saturating_sub((pos_width + dur_width + 2) as u16);
|
||||||
|
|
||||||
|
if bar_width > 0 {
|
||||||
|
let filled_width = (bar_width as f64 * self.progress) as u16;
|
||||||
|
|
||||||
|
// Draw filled portion
|
||||||
|
for x in bar_x..(bar_x + filled_width) {
|
||||||
|
buf[(x, area.y)].set_char('━').set_style(self.filled_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw empty portion
|
||||||
|
for x in (bar_x + filled_width)..(bar_x + bar_width) {
|
||||||
|
buf[(x, area.y)].set_char('─').set_style(self.empty_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical gauge (for volume, etc.)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct VerticalBar {
|
||||||
|
/// Value (0.0 to 1.0)
|
||||||
|
value: f64,
|
||||||
|
/// Filled style
|
||||||
|
filled_style: Style,
|
||||||
|
/// Empty style
|
||||||
|
empty_style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl VerticalBar {
|
||||||
|
pub fn new(value: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
value: value.clamp(0.0, 1.0),
|
||||||
|
filled_style: Style::default().bg(Color::Blue),
|
||||||
|
empty_style: Style::default().bg(Color::DarkGray),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filled_style(mut self, style: Style) -> Self {
|
||||||
|
self.filled_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_style(mut self, style: Style) -> Self {
|
||||||
|
self.empty_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for VerticalBar {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.height < 1 || area.width < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filled_height = (area.height as f64 * self.value) as u16;
|
||||||
|
let empty_start = area.y + area.height - filled_height;
|
||||||
|
|
||||||
|
// Draw empty portion (top)
|
||||||
|
for y in area.y..empty_start {
|
||||||
|
for x in area.x..(area.x + area.width) {
|
||||||
|
buf[(x, y)].set_char('░').set_style(self.empty_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw filled portion (bottom)
|
||||||
|
for y in empty_start..(area.y + area.height) {
|
||||||
|
for x in area.x..(area.x + area.width) {
|
||||||
|
buf[(x, y)].set_char('█').set_style(self.filled_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user