Remove dead code and #![allow(dead_code)] blanket suppressions
- Delete src/audio/queue.rs (321 lines, PlayQueue never used) - Remove #![allow(dead_code)] from audio, subsonic, and mpris module roots - Remove unused MpvEvent2 enum, playlist_clear, get_volume, get_path, is_eof, observe_property from mpv.rs - Remove unused DEFAULT_DEVICE_ID, is_available, get_effective_rate from pipewire.rs (and associated dead test) - Remove unused search(), get_cover_art_url() from subsonic client - Remove unused SearchResult3Data, SearchResult3 model structs - Move parse_song_id_from_url into #[cfg(test)] block (only used by tests) - Add targeted #[allow(dead_code)] on deserialization-only fields (MpvEvent, SubsonicResponseInner.version, ArtistIndex.name) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
//! Application actions and message passing
|
||||
|
||||
use crate::subsonic::models::{Album, Artist, Child, Playlist};
|
||||
|
||||
/// Actions that can be sent to the audio backend
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioAction {
|
||||
/// Play a specific song by URL
|
||||
Play { url: String, song: Child },
|
||||
/// Pause playback
|
||||
Pause,
|
||||
/// Resume playback
|
||||
@@ -26,86 +22,3 @@ pub enum AudioAction {
|
||||
/// Set volume (0-100)
|
||||
SetVolume(i32),
|
||||
}
|
||||
|
||||
/// Actions that can be sent to update the UI
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiAction {
|
||||
/// Update playback position
|
||||
UpdatePosition { position: f64, duration: f64 },
|
||||
/// Update playback state
|
||||
UpdatePlaybackState(PlaybackStateUpdate),
|
||||
/// Update audio properties
|
||||
UpdateAudioProperties {
|
||||
sample_rate: Option<u32>,
|
||||
bit_depth: Option<u32>,
|
||||
format: Option<String>,
|
||||
},
|
||||
/// Track ended (EOF from MPV)
|
||||
TrackEnded,
|
||||
/// Show notification
|
||||
Notify { message: String, is_error: bool },
|
||||
/// Artists loaded from server
|
||||
ArtistsLoaded(Vec<Artist>),
|
||||
/// Albums loaded for an artist
|
||||
AlbumsLoaded {
|
||||
artist_id: String,
|
||||
albums: Vec<Album>,
|
||||
},
|
||||
/// Songs loaded for an album
|
||||
SongsLoaded { album_id: String, songs: Vec<Child> },
|
||||
/// Playlists loaded from server
|
||||
PlaylistsLoaded(Vec<Playlist>),
|
||||
/// Playlist songs loaded
|
||||
PlaylistSongsLoaded {
|
||||
playlist_id: String,
|
||||
songs: Vec<Child>,
|
||||
},
|
||||
/// Server connection test result
|
||||
ConnectionTestResult { success: bool, message: String },
|
||||
/// Force redraw
|
||||
Redraw,
|
||||
}
|
||||
|
||||
/// Playback state update
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PlaybackStateUpdate {
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Actions for the Subsonic client
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubsonicAction {
|
||||
/// Fetch all artists
|
||||
FetchArtists,
|
||||
/// Fetch albums for an artist
|
||||
FetchAlbums { artist_id: String },
|
||||
/// Fetch songs for an album
|
||||
FetchAlbum { album_id: String },
|
||||
/// Fetch all playlists
|
||||
FetchPlaylists,
|
||||
/// Fetch songs in a playlist
|
||||
FetchPlaylist { playlist_id: String },
|
||||
/// Test server connection
|
||||
TestConnection,
|
||||
}
|
||||
|
||||
/// Queue manipulation actions
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum QueueAction {
|
||||
/// Append songs to queue
|
||||
Append(Vec<Child>),
|
||||
/// Insert songs after current position
|
||||
InsertNext(Vec<Child>),
|
||||
/// Clear the queue
|
||||
Clear,
|
||||
/// Remove song at index
|
||||
Remove(usize),
|
||||
/// Move song from one index to another
|
||||
Move { from: usize, to: usize },
|
||||
/// Shuffle the queue (keeping current song in place)
|
||||
Shuffle,
|
||||
/// Play song at queue index
|
||||
PlayIndex(usize),
|
||||
}
|
||||
|
||||
@@ -32,16 +32,6 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_index(index: usize) -> Self {
|
||||
match index {
|
||||
0 => Page::Artists,
|
||||
1 => Page::Queue,
|
||||
2 => Page::Playlists,
|
||||
3 => Page::Server,
|
||||
4 => Page::Settings,
|
||||
_ => Page::Artists,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -205,6 +195,8 @@ pub struct SettingsState {
|
||||
pub theme_index: usize,
|
||||
/// Cava visualizer enabled
|
||||
pub cava_enabled: bool,
|
||||
/// Cava visualizer height percentage (10-80, step 5)
|
||||
pub cava_size: u8,
|
||||
}
|
||||
|
||||
impl Default for SettingsState {
|
||||
@@ -214,6 +206,7 @@ impl Default for SettingsState {
|
||||
themes: vec![ThemeData::default_theme()],
|
||||
theme_index: 0,
|
||||
cava_enabled: false,
|
||||
cava_size: 40,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,10 +262,8 @@ pub struct Notification {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LayoutAreas {
|
||||
pub header: Rect,
|
||||
pub cava: Option<Rect>,
|
||||
pub content: Rect,
|
||||
pub now_playing: Rect,
|
||||
pub footer: Rect,
|
||||
/// Left pane for dual-pane pages (Artists tree, Playlists list)
|
||||
pub content_left: Option<Rect>,
|
||||
/// Right pane for dual-pane pages (Songs list)
|
||||
@@ -349,6 +340,7 @@ impl AppState {
|
||||
state.server_state.password = config.password.clone();
|
||||
// Initialize cava from config
|
||||
state.settings_state.cava_enabled = config.cava;
|
||||
state.settings_state.cava_size = config.cava_size.clamp(10, 80);
|
||||
state
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Audio playback module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod mpv;
|
||||
pub mod pipewire;
|
||||
pub mod queue;
|
||||
|
||||
@@ -32,8 +32,9 @@ struct MpvResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// MPV event
|
||||
/// MPV event (used for deserialization and debug tracing)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)] // Fields populated by deserialization, read via Debug
|
||||
struct MpvEvent {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
@@ -42,27 +43,6 @@ struct MpvEvent {
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
/// Events emitted by MPV
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MpvEvent2 {
|
||||
/// Track reached end of file
|
||||
EndFile,
|
||||
/// Playback paused
|
||||
Pause,
|
||||
/// Playback resumed
|
||||
Unpause,
|
||||
/// Position changed (time in seconds)
|
||||
TimePos(f64),
|
||||
/// Audio properties changed
|
||||
AudioProperties {
|
||||
sample_rate: Option<u32>,
|
||||
bit_depth: Option<u32>,
|
||||
format: Option<String>,
|
||||
},
|
||||
/// MPV shut down
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// MPV controller
|
||||
pub struct MpvController {
|
||||
/// Path to the IPC socket
|
||||
@@ -217,13 +197,6 @@ impl MpvController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the playlist except current track
|
||||
pub fn playlist_clear(&mut self) -> Result<(), AudioError> {
|
||||
debug!("Clearing playlist");
|
||||
self.send_command(vec![json!("playlist-clear")])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a specific entry from the playlist by index
|
||||
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
|
||||
debug!("Removing playlist entry {}", index);
|
||||
@@ -307,15 +280,6 @@ impl MpvController {
|
||||
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Get volume (0-100)
|
||||
pub fn get_volume(&mut self) -> Result<i32, AudioError> {
|
||||
let data = self.send_command(vec![json!("get_property"), json!("volume")])?;
|
||||
Ok(data
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|v| v as i32)
|
||||
.unwrap_or(100))
|
||||
}
|
||||
|
||||
/// Set volume (0-100)
|
||||
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
|
||||
debug!("Setting volume to {}", volume);
|
||||
@@ -378,24 +342,12 @@ impl MpvController {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get current filename/URL
|
||||
pub fn get_path(&mut self) -> Result<Option<String>, AudioError> {
|
||||
let data = self.send_command(vec![json!("get_property"), json!("path")])?;
|
||||
Ok(data.and_then(|v| v.as_str().map(String::from)))
|
||||
}
|
||||
|
||||
/// Check if anything is loaded
|
||||
pub fn is_idle(&mut self) -> Result<bool, AudioError> {
|
||||
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
|
||||
Ok(data.and_then(|v| v.as_bool()).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Check if current file has reached EOF
|
||||
pub fn is_eof(&mut self) -> Result<bool, AudioError> {
|
||||
let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?;
|
||||
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Quit MPV
|
||||
pub fn quit(&mut self) -> Result<(), AudioError> {
|
||||
if self.socket.is_some() {
|
||||
@@ -414,11 +366,6 @@ impl MpvController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Observe a property for changes
|
||||
pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> {
|
||||
self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MpvController {
|
||||
|
||||
@@ -5,9 +5,6 @@ use tracing::{debug, error, info};
|
||||
|
||||
use crate::error::AudioError;
|
||||
|
||||
/// Default audio device ID for PipeWire
|
||||
const DEFAULT_DEVICE_ID: u32 = 0;
|
||||
|
||||
/// PipeWire sample rate controller
|
||||
pub struct PipeWireController {
|
||||
/// Original sample rate before ferrosonic started
|
||||
@@ -133,47 +130,6 @@ impl PipeWireController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if PipeWire is available
|
||||
pub fn is_available() -> bool {
|
||||
Command::new("pw-metadata")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the effective sample rate (from pw-metadata or system default)
|
||||
pub fn get_effective_rate() -> Option<u32> {
|
||||
// Try to get from PipeWire
|
||||
let output = Command::new("pw-metadata")
|
||||
.arg("-n")
|
||||
.arg("settings")
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Look for clock.rate or clock.force-rate
|
||||
for line in stdout.lines() {
|
||||
if (line.contains("clock.rate") || line.contains("clock.force-rate"))
|
||||
&& line.contains("value:")
|
||||
{
|
||||
if let Some(start) = line.find("value:'") {
|
||||
let rest = &line[start + 7..];
|
||||
if let Some(end) = rest.find('\'') {
|
||||
let rate_str = &rest[..end];
|
||||
if let Ok(rate) = rate_str.parse::<u32>() {
|
||||
if rate > 0 {
|
||||
return Some(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PipeWireController {
|
||||
@@ -190,13 +146,3 @@ impl Drop for PipeWireController {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_available() {
|
||||
// This test just checks the function doesn't panic
|
||||
let _ = PipeWireController::is_available();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Play queue management
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::subsonic::models::Child;
|
||||
use crate::subsonic::SubsonicClient;
|
||||
|
||||
/// Play queue
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PlayQueue {
|
||||
/// Songs in the queue
|
||||
songs: Vec<Child>,
|
||||
/// Current position in the queue (None = stopped)
|
||||
position: Option<usize>,
|
||||
}
|
||||
|
||||
impl PlayQueue {
|
||||
/// Create a new empty queue
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get the songs in the queue
|
||||
pub fn songs(&self) -> &[Child] {
|
||||
&self.songs
|
||||
}
|
||||
|
||||
/// Get the current position
|
||||
pub fn position(&self) -> Option<usize> {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// Get the current song
|
||||
pub fn current(&self) -> Option<&Child> {
|
||||
self.position.and_then(|pos| self.songs.get(pos))
|
||||
}
|
||||
|
||||
/// Get number of songs in queue
|
||||
pub fn len(&self) -> usize {
|
||||
self.songs.len()
|
||||
}
|
||||
|
||||
/// Check if queue is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.songs.is_empty()
|
||||
}
|
||||
|
||||
/// Add songs to the end of the queue
|
||||
pub fn append(&mut self, songs: impl IntoIterator<Item = Child>) {
|
||||
self.songs.extend(songs);
|
||||
debug!("Queue now has {} songs", self.songs.len());
|
||||
}
|
||||
|
||||
/// Insert songs after the current position
|
||||
pub fn insert_next(&mut self, songs: impl IntoIterator<Item = Child>) {
|
||||
let insert_pos = self.position.map(|p| p + 1).unwrap_or(0);
|
||||
let new_songs: Vec<_> = songs.into_iter().collect();
|
||||
let count = new_songs.len();
|
||||
|
||||
for (i, song) in new_songs.into_iter().enumerate() {
|
||||
self.songs.insert(insert_pos + i, song);
|
||||
}
|
||||
|
||||
debug!("Inserted {} songs at position {}", count, insert_pos);
|
||||
}
|
||||
|
||||
/// Clear the queue
|
||||
pub fn clear(&mut self) {
|
||||
self.songs.clear();
|
||||
self.position = None;
|
||||
debug!("Queue cleared");
|
||||
}
|
||||
|
||||
/// Remove song at index
|
||||
pub fn remove(&mut self, index: usize) -> Option<Child> {
|
||||
if index >= self.songs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let song = self.songs.remove(index);
|
||||
|
||||
// Adjust position if needed
|
||||
if let Some(pos) = self.position {
|
||||
if index < pos {
|
||||
self.position = Some(pos - 1);
|
||||
} else if index == pos {
|
||||
// Removed current song
|
||||
if self.songs.is_empty() {
|
||||
self.position = None;
|
||||
} else if pos >= self.songs.len() {
|
||||
self.position = Some(self.songs.len() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Removed song at index {}", index);
|
||||
Some(song)
|
||||
}
|
||||
|
||||
/// Move song from one position to another
|
||||
pub fn move_song(&mut self, from: usize, to: usize) {
|
||||
if from >= self.songs.len() || to >= self.songs.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let song = self.songs.remove(from);
|
||||
self.songs.insert(to, song);
|
||||
|
||||
// Adjust position if needed
|
||||
if let Some(pos) = self.position {
|
||||
if from == pos {
|
||||
self.position = Some(to);
|
||||
} else if from < pos && to >= pos {
|
||||
self.position = Some(pos - 1);
|
||||
} else if from > pos && to <= pos {
|
||||
self.position = Some(pos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Moved song from {} to {}", from, to);
|
||||
}
|
||||
|
||||
/// Shuffle the queue, keeping current song in place
|
||||
pub fn shuffle(&mut self) {
|
||||
if self.songs.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
if let Some(pos) = self.position {
|
||||
// Keep current song, shuffle the rest
|
||||
let current = self.songs.remove(pos);
|
||||
|
||||
// Shuffle remaining songs
|
||||
self.songs.shuffle(&mut rng);
|
||||
|
||||
// Put current song at the front
|
||||
self.songs.insert(0, current);
|
||||
self.position = Some(0);
|
||||
} else {
|
||||
// No current song, shuffle everything
|
||||
self.songs.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
debug!("Queue shuffled");
|
||||
}
|
||||
|
||||
/// Set current position
|
||||
pub fn set_position(&mut self, position: Option<usize>) {
|
||||
if let Some(pos) = position {
|
||||
if pos < self.songs.len() {
|
||||
self.position = Some(pos);
|
||||
debug!("Position set to {}", pos);
|
||||
}
|
||||
} else {
|
||||
self.position = None;
|
||||
debug!("Position cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to next song
|
||||
/// Returns true if there was a next song
|
||||
pub fn next(&mut self) -> bool {
|
||||
match self.position {
|
||||
Some(pos) if pos + 1 < self.songs.len() => {
|
||||
self.position = Some(pos + 1);
|
||||
debug!("Advanced to position {}", pos + 1);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.position = None;
|
||||
debug!("Reached end of queue");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to previous song
|
||||
/// Returns true if there was a previous song
|
||||
pub fn previous(&mut self) -> bool {
|
||||
match self.position {
|
||||
Some(pos) if pos > 0 => {
|
||||
self.position = Some(pos - 1);
|
||||
debug!("Went back to position {}", pos - 1);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if !self.songs.is_empty() {
|
||||
self.position = Some(0);
|
||||
}
|
||||
debug!("At start of queue");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream URL for current song
|
||||
pub fn current_stream_url(&self, client: &SubsonicClient) -> Option<String> {
|
||||
self.current()
|
||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
||||
}
|
||||
|
||||
/// Get stream URL for song at index
|
||||
pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option<String> {
|
||||
self.songs
|
||||
.get(index)
|
||||
.and_then(|song| client.get_stream_url(&song.id).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_song(id: &str, title: &str) -> Child {
|
||||
Child {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
parent: None,
|
||||
is_dir: false,
|
||||
album: None,
|
||||
artist: None,
|
||||
track: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
cover_art: None,
|
||||
size: None,
|
||||
content_type: None,
|
||||
suffix: None,
|
||||
duration: None,
|
||||
bit_rate: None,
|
||||
path: None,
|
||||
disc_number: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_and_len() {
|
||||
let mut queue = PlayQueue::new();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]);
|
||||
assert_eq!(queue.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_and_navigation() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
|
||||
assert!(queue.current().is_none());
|
||||
|
||||
queue.set_position(Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "1");
|
||||
|
||||
assert!(queue.next());
|
||||
assert_eq!(queue.current().unwrap().id, "2");
|
||||
|
||||
assert!(queue.next());
|
||||
assert_eq!(queue.current().unwrap().id, "3");
|
||||
|
||||
assert!(!queue.next());
|
||||
assert!(queue.current().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
queue.set_position(Some(1));
|
||||
|
||||
// Remove song before current
|
||||
queue.remove(0);
|
||||
assert_eq!(queue.position(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "2");
|
||||
|
||||
// Remove current song
|
||||
queue.remove(0);
|
||||
assert_eq!(queue.current().unwrap().id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_next() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]);
|
||||
queue.set_position(Some(0));
|
||||
|
||||
queue.insert_next(vec![make_song("2", "Song 2")]);
|
||||
|
||||
assert_eq!(queue.songs[0].id, "1");
|
||||
assert_eq!(queue.songs[1].id, "2");
|
||||
assert_eq!(queue.songs[2].id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_song() {
|
||||
let mut queue = PlayQueue::new();
|
||||
queue.append(vec![
|
||||
make_song("1", "Song 1"),
|
||||
make_song("2", "Song 2"),
|
||||
make_song("3", "Song 3"),
|
||||
]);
|
||||
queue.set_position(Some(0));
|
||||
|
||||
queue.move_song(0, 2);
|
||||
assert_eq!(queue.songs[0].id, "2");
|
||||
assert_eq!(queue.songs[1].id, "3");
|
||||
assert_eq!(queue.songs[2].id, "1");
|
||||
assert_eq!(queue.position(), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,17 @@ pub struct Config {
|
||||
/// Enable cava audio visualizer
|
||||
#[serde(rename = "Cava", default)]
|
||||
pub cava: bool,
|
||||
|
||||
/// Cava visualizer height percentage (10-80, step 5)
|
||||
#[serde(rename = "CavaSize", default = "Config::default_cava_size")]
|
||||
pub cava_size: u8,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn default_cava_size() -> u8 {
|
||||
40
|
||||
}
|
||||
|
||||
/// Create a new empty config
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! MPRIS2 D-Bus integration module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod server;
|
||||
|
||||
@@ -297,77 +297,23 @@ impl SubsonicClient {
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl SubsonicClient {
|
||||
/// Parse song ID from a stream URL
|
||||
///
|
||||
/// Useful for session restoration
|
||||
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
|
||||
fn parse_song_id_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
parsed
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "id")
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
/// Get cover art URL for a given cover art ID
|
||||
pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result<String, SubsonicError> {
|
||||
let (salt, token) = generate_auth_params(&self.password);
|
||||
let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("id", cover_art_id)
|
||||
.append_pair("u", &self.username)
|
||||
.append_pair("t", &token)
|
||||
.append_pair("s", &salt)
|
||||
.append_pair("v", API_VERSION)
|
||||
.append_pair("c", CLIENT_NAME);
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Search for artists, albums, and songs
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<(Vec<Artist>, Vec<Album>, Vec<Child>), SubsonicError> {
|
||||
let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?;
|
||||
debug!("Searching: {}", query);
|
||||
|
||||
let response = self.http.get(url).send().await?;
|
||||
let text = response.text().await?;
|
||||
|
||||
let parsed: SubsonicResponse<SearchResult3Data> = serde_json::from_str(&text)
|
||||
.map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?;
|
||||
|
||||
if parsed.subsonic_response.status != "ok" {
|
||||
if let Some(error) = parsed.subsonic_response.error {
|
||||
return Err(SubsonicError::Api {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result = parsed
|
||||
.subsonic_response
|
||||
.data
|
||||
.ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))?
|
||||
.search_result3;
|
||||
|
||||
debug!(
|
||||
"Search found {} artists, {} albums, {} songs",
|
||||
result.artist.len(),
|
||||
result.album.len(),
|
||||
result.song.len()
|
||||
);
|
||||
|
||||
Ok((result.artist, result.album, result.song))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_song_id() {
|
||||
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Subsonic API client module
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct SubsonicResponse<T> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubsonicResponseInner<T> {
|
||||
pub status: String,
|
||||
#[allow(dead_code)] // Present in API response, needed for deserialization
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub error: Option<ApiError>,
|
||||
@@ -40,6 +41,7 @@ pub struct ArtistsIndex {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistIndex {
|
||||
#[allow(dead_code)] // Present in API response, needed for deserialization
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub artist: Vec<Artist>,
|
||||
@@ -217,19 +219,3 @@ pub struct PlaylistDetail {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PingData {}
|
||||
|
||||
/// Search result
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchResult3Data {
|
||||
#[serde(rename = "searchResult3")]
|
||||
pub search_result3: SearchResult3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchResult3 {
|
||||
#[serde(default)]
|
||||
pub artist: Vec<Artist>,
|
||||
#[serde(default)]
|
||||
pub album: Vec<Album>,
|
||||
#[serde(default)]
|
||||
pub song: Vec<Child>,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
||||
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Percentage(40), // Cava visualizer — top half-ish
|
||||
Constraint::Percentage(state.settings_state.cava_size as u16), // Cava visualizer
|
||||
Constraint::Min(10), // Page content
|
||||
Constraint::Length(7), // Now playing
|
||||
Constraint::Length(1), // Footer
|
||||
@@ -62,10 +62,8 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
||||
// Store layout areas for mouse hit-testing
|
||||
state.layout = LayoutAreas {
|
||||
header: header_area,
|
||||
cava: cava_area,
|
||||
content: content_area,
|
||||
now_playing: now_playing_area,
|
||||
footer: footer_area,
|
||||
content_left,
|
||||
content_right,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
Constraint::Length(2), // Theme selector
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(2), // Cava toggle
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(2), // Cava size
|
||||
Constraint::Min(1), // Remaining space
|
||||
])
|
||||
.split(inner);
|
||||
@@ -66,11 +68,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Cava size (field 2)
|
||||
let cava_size_value = if !state.cava_available {
|
||||
"N/A (cava not found)".to_string()
|
||||
} else {
|
||||
format!("{}%", settings.cava_size)
|
||||
};
|
||||
|
||||
render_option(
|
||||
frame,
|
||||
chunks[5],
|
||||
"Cava Size",
|
||||
&cava_size_value,
|
||||
settings.selected_field == 2,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Help text at bottom
|
||||
let help_text = match settings.selected_field {
|
||||
0 => "← → or Enter to change theme (auto-saves)",
|
||||
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
|
||||
1 => "cava is not installed on this system",
|
||||
2 if state.cava_available => "← → to adjust cava size (10%-80%, auto-saves)",
|
||||
2 => "cava is not installed on this system",
|
||||
_ => "",
|
||||
};
|
||||
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
|
||||
|
||||
Reference in New Issue
Block a user