Initial Commit
This commit is contained in:
parent
01258c9b03
commit
725252f244
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
2905
Cargo.lock
generated
Normal file
2905
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "jellyfin-asio-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
|
cpal = { version = "0.15", features = ["asio"] }
|
||||||
|
ratatui = "0.26"
|
||||||
|
crossterm = "0.27"
|
||||||
|
anyhow = "1.0"
|
||||||
|
rpassword = "7.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
dirs = "5.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
once_cell = "1.19"
|
624
src/audio.rs
Normal file
624
src/audio.rs
Normal file
|
@ -0,0 +1,624 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{Device, Stream, StreamConfig, SampleFormat, SampleRate};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use symphonia::core::audio::{AudioBuffer as SymphoniaAudioBuffer, Signal};
|
||||||
|
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
||||||
|
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
use tracing::{info, debug, error, warn};
|
||||||
|
|
||||||
|
pub struct AsioPlayer {
|
||||||
|
device: Device,
|
||||||
|
stream: Option<Stream>,
|
||||||
|
forced_sample_rate: Option<u32>,
|
||||||
|
stream_buffer: Option<Arc<Mutex<StreamBuffer>>>,
|
||||||
|
duration_ms: Option<u64>,
|
||||||
|
start_time: Option<Instant>,
|
||||||
|
paused_position: Option<f32>,
|
||||||
|
volume: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared audio buffer for streaming
|
||||||
|
struct StreamBuffer {
|
||||||
|
samples: Vec<f32>,
|
||||||
|
position: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
volume: f32,
|
||||||
|
paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsioPlayer {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let host = cpal::host_from_id(cpal::HostId::Asio)?;
|
||||||
|
let device = host.default_output_device()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No ASIO output device found"))?;
|
||||||
|
|
||||||
|
info!("Using ASIO device: {}", device.name()?);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
device,
|
||||||
|
stream: None,
|
||||||
|
forced_sample_rate: None,
|
||||||
|
stream_buffer: None,
|
||||||
|
duration_ms: None,
|
||||||
|
start_time: None,
|
||||||
|
paused_position: None,
|
||||||
|
volume: 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sample_rate(&mut self, sample_rate: u32) -> Result<()> {
|
||||||
|
// Validate that the device supports this sample rate
|
||||||
|
let supported_configs: Vec<_> = self.device.supported_output_configs()?.collect();
|
||||||
|
|
||||||
|
let mut supported = false;
|
||||||
|
for config in &supported_configs {
|
||||||
|
if config.min_sample_rate().0 <= sample_rate && config.max_sample_rate().0 >= sample_rate {
|
||||||
|
supported = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !supported {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Sample rate {} Hz is not supported by this device. Supported ranges: {:?}",
|
||||||
|
sample_rate,
|
||||||
|
supported_configs.iter()
|
||||||
|
.map(|c| format!("{}-{} Hz", c.min_sample_rate().0, c.max_sample_rate().0))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.forced_sample_rate = Some(sample_rate);
|
||||||
|
info!("Forced sample rate set to {} Hz", sample_rate);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_sample_rate(&mut self) {
|
||||||
|
self.forced_sample_rate = None;
|
||||||
|
info!("Sample rate forcing cleared - will use automatic selection");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play_url(&mut self, url: &str) -> Result<()> {
|
||||||
|
info!("Starting playback of: {}", url);
|
||||||
|
|
||||||
|
// Stop any existing playback
|
||||||
|
self.stop()?;
|
||||||
|
|
||||||
|
// Download and decode the audio
|
||||||
|
let (samples, original_sample_rate, channels, duration_ms) = self.download_and_decode(url).await?;
|
||||||
|
|
||||||
|
// Set up ASIO stream with proper sample rate
|
||||||
|
self.setup_stream(samples, original_sample_rate, channels)?;
|
||||||
|
|
||||||
|
// Store duration
|
||||||
|
self.duration_ms = Some(duration_ms);
|
||||||
|
|
||||||
|
// Start playing
|
||||||
|
if let Some(stream) = &self.stream {
|
||||||
|
stream.play()?;
|
||||||
|
self.start_time = Some(Instant::now());
|
||||||
|
self.paused_position = None;
|
||||||
|
info!("Playback started");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&mut self) -> Result<()> {
|
||||||
|
if let Some(stream) = &self.stream {
|
||||||
|
stream.pause()?;
|
||||||
|
|
||||||
|
// Calculate current position when pausing
|
||||||
|
if let Some(start_time) = self.start_time {
|
||||||
|
let elapsed = start_time.elapsed().as_secs_f32();
|
||||||
|
let duration_secs = self.duration_ms.unwrap_or(0) as f32 / 1000.0;
|
||||||
|
self.paused_position = Some((elapsed / duration_secs).min(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set paused flag in buffer
|
||||||
|
if let Some(buffer) = &self.stream_buffer {
|
||||||
|
let mut buffer = buffer.lock().unwrap();
|
||||||
|
buffer.paused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Playback paused");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("No active stream to pause"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&mut self) -> Result<()> {
|
||||||
|
if let Some(stream) = &self.stream {
|
||||||
|
// Update start time based on paused position
|
||||||
|
if let Some(paused_pos) = self.paused_position {
|
||||||
|
let duration_secs = self.duration_ms.unwrap_or(0) as f32 / 1000.0;
|
||||||
|
let elapsed_secs = paused_pos * duration_secs;
|
||||||
|
self.start_time = Some(Instant::now() - Duration::from_secs_f32(elapsed_secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear paused flag in buffer
|
||||||
|
if let Some(buffer) = &self.stream_buffer {
|
||||||
|
let mut buffer = buffer.lock().unwrap();
|
||||||
|
buffer.paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.play()?;
|
||||||
|
info!("Playback resumed");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("No active stream to resume"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self) -> Result<()> {
|
||||||
|
if let Some(stream) = &self.stream {
|
||||||
|
stream.pause()?;
|
||||||
|
self.stream = None;
|
||||||
|
self.stream_buffer = None;
|
||||||
|
self.start_time = None;
|
||||||
|
self.paused_position = None;
|
||||||
|
info!("Playback stopped");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// Not an error if already stopped
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&mut self, position: f32) -> Result<()> {
|
||||||
|
// position is 0.0 - 1.0 (percentage of track)
|
||||||
|
let position = position.max(0.0).min(1.0);
|
||||||
|
|
||||||
|
if let Some(buffer) = &self.stream_buffer {
|
||||||
|
let mut buffer = buffer.lock().unwrap();
|
||||||
|
let total_frames = buffer.samples.len() / buffer.channels as usize;
|
||||||
|
let new_frame = (total_frames as f32 * position) as usize;
|
||||||
|
buffer.position = new_frame * buffer.channels as usize;
|
||||||
|
|
||||||
|
// Update start time to reflect new position
|
||||||
|
if let Some(duration_ms) = self.duration_ms {
|
||||||
|
let duration_secs = duration_ms as f32 / 1000.0;
|
||||||
|
let elapsed_secs = position * duration_secs;
|
||||||
|
self.start_time = Some(Instant::now() - Duration::from_secs_f32(elapsed_secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stream) = &self.stream {
|
||||||
|
if buffer.paused {
|
||||||
|
self.paused_position = Some(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Seeked to position: {:.1}%", position * 100.0);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("No active stream to seek"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&mut self, volume: f32) -> Result<()> {
|
||||||
|
let volume = volume.max(0.0).min(1.0);
|
||||||
|
self.volume = volume;
|
||||||
|
|
||||||
|
if let Some(buffer) = &self.stream_buffer {
|
||||||
|
let mut buffer = buffer.lock().unwrap();
|
||||||
|
buffer.volume = volume;
|
||||||
|
info!("Volume set to: {:.0}%", volume * 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_position(&self) -> Option<f32> {
|
||||||
|
if let (Some(start_time), Some(duration_ms)) = (self.start_time, self.duration_ms) {
|
||||||
|
if let Some(paused_pos) = self.paused_position {
|
||||||
|
return Some(paused_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = start_time.elapsed().as_millis() as f32;
|
||||||
|
let duration = duration_ms as f32;
|
||||||
|
Some((elapsed / duration).min(1.0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_and_decode(&self, url: &str) -> Result<(Vec<f32>, u32, u16, u64)> {
|
||||||
|
debug!("Downloading audio from: {}", url);
|
||||||
|
|
||||||
|
let response = reqwest::get(url).await?;
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
debug!("Downloaded {} bytes, decoding...", bytes.len());
|
||||||
|
|
||||||
|
// Create media source
|
||||||
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
|
let media_source = Box::new(cursor);
|
||||||
|
let mss = MediaSourceStream::new(media_source, Default::default());
|
||||||
|
|
||||||
|
// Probe the format
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
let format_opts = FormatOptions::default();
|
||||||
|
let metadata_opts = MetadataOptions::default();
|
||||||
|
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(&hint, mss, &format_opts, &metadata_opts)?;
|
||||||
|
|
||||||
|
let mut format_reader = probed.format;
|
||||||
|
let track = format_reader.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No audio track found"))?;
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||||
|
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2) as u16;
|
||||||
|
|
||||||
|
// Create decoder
|
||||||
|
let decoder_opts = DecoderOptions::default();
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &decoder_opts)?;
|
||||||
|
|
||||||
|
info!("Decoding: {} channels, {} Hz", channels, sample_rate);
|
||||||
|
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
|
||||||
|
// Decode packets
|
||||||
|
loop {
|
||||||
|
match format_reader.next_packet() {
|
||||||
|
Ok(packet) => {
|
||||||
|
if packet.track_id() != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match decoder.decode(&packet) {
|
||||||
|
Ok(decoded) => {
|
||||||
|
// Convert samples based on the actual format
|
||||||
|
match decoded {
|
||||||
|
symphonia::core::audio::AudioBufferRef::F32(buf) => {
|
||||||
|
let spec = *buf.spec();
|
||||||
|
if spec.channels.count() == 1 {
|
||||||
|
// Mono
|
||||||
|
let chan = buf.chan(0);
|
||||||
|
samples.extend_from_slice(chan);
|
||||||
|
} else if spec.channels.count() == 2 {
|
||||||
|
// Stereo - interleave L/R
|
||||||
|
let left = buf.chan(0);
|
||||||
|
let right = buf.chan(1);
|
||||||
|
|
||||||
|
for i in 0..left.len() {
|
||||||
|
samples.push(left[i]);
|
||||||
|
samples.push(right[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
symphonia::core::audio::AudioBufferRef::S32(buf) => {
|
||||||
|
let spec = *buf.spec();
|
||||||
|
if spec.channels.count() == 1 {
|
||||||
|
// Mono - convert i32 to f32
|
||||||
|
let chan = buf.chan(0);
|
||||||
|
for &sample in chan {
|
||||||
|
samples.push(sample as f32 / i32::MAX as f32);
|
||||||
|
}
|
||||||
|
} else if spec.channels.count() == 2 {
|
||||||
|
// Stereo - interleave L/R and convert
|
||||||
|
let left = buf.chan(0);
|
||||||
|
let right = buf.chan(1);
|
||||||
|
|
||||||
|
for i in 0..left.len() {
|
||||||
|
samples.push(left[i] as f32 / i32::MAX as f32);
|
||||||
|
samples.push(right[i] as f32 / i32::MAX as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
symphonia::core::audio::AudioBufferRef::S16(buf) => {
|
||||||
|
let spec = *buf.spec();
|
||||||
|
if spec.channels.count() == 1 {
|
||||||
|
// Mono - convert i16 to f32
|
||||||
|
let chan = buf.chan(0);
|
||||||
|
for &sample in chan {
|
||||||
|
samples.push(sample as f32 / i16::MAX as f32);
|
||||||
|
}
|
||||||
|
} else if spec.channels.count() == 2 {
|
||||||
|
// Stereo - interleave L/R and convert
|
||||||
|
let left = buf.chan(0);
|
||||||
|
let right = buf.chan(1);
|
||||||
|
|
||||||
|
for i in 0..left.len() {
|
||||||
|
samples.push(left[i] as f32 / i16::MAX as f32);
|
||||||
|
samples.push(right[i] as f32 / i16::MAX as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!("Unsupported audio format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Decode error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration in milliseconds
|
||||||
|
let num_frames = samples.len() / channels as usize;
|
||||||
|
let duration_ms = (num_frames as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||||
|
|
||||||
|
info!("Decoded {} samples, duration: {}ms", samples.len(), duration_ms);
|
||||||
|
Ok((samples, sample_rate, channels, duration_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_stream(&mut self, samples: Vec<f32>, source_sample_rate: u32, source_channels: u16) -> Result<()> {
|
||||||
|
// Get the device's supported configurations
|
||||||
|
let supported_configs: Vec<_> = self.device.supported_output_configs()?.collect();
|
||||||
|
|
||||||
|
// Find a configuration that matches our requirements
|
||||||
|
let mut selected_config = None;
|
||||||
|
let mut target_sample_rate = self.forced_sample_rate.unwrap_or(source_sample_rate);
|
||||||
|
|
||||||
|
// If we have a forced sample rate, prioritize that
|
||||||
|
if let Some(forced_rate) = self.forced_sample_rate {
|
||||||
|
for config in &supported_configs {
|
||||||
|
if config.min_sample_rate().0 <= forced_rate &&
|
||||||
|
config.max_sample_rate().0 >= forced_rate &&
|
||||||
|
config.channels() == source_channels {
|
||||||
|
selected_config = Some(config.with_sample_rate(SampleRate(forced_rate)));
|
||||||
|
target_sample_rate = forced_rate;
|
||||||
|
info!("Using forced sample rate: {} Hz", forced_rate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If forced rate doesn't work with desired channels, try with any channel count
|
||||||
|
if selected_config.is_none() {
|
||||||
|
for config in &supported_configs {
|
||||||
|
if config.min_sample_rate().0 <= forced_rate &&
|
||||||
|
config.max_sample_rate().0 >= forced_rate {
|
||||||
|
selected_config = Some(config.with_sample_rate(SampleRate(forced_rate)));
|
||||||
|
target_sample_rate = forced_rate;
|
||||||
|
warn!("Using forced sample rate {} Hz but with {} channels instead of requested {}",
|
||||||
|
forced_rate, config.channels(), source_channels);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no forced rate or forced rate didn't work, try to find exact match for source
|
||||||
|
if selected_config.is_none() {
|
||||||
|
for config in &supported_configs {
|
||||||
|
if config.min_sample_rate().0 <= source_sample_rate &&
|
||||||
|
config.max_sample_rate().0 >= source_sample_rate &&
|
||||||
|
config.channels() == source_channels {
|
||||||
|
selected_config = Some(config.with_sample_rate(SampleRate(source_sample_rate)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, find a reasonable alternative
|
||||||
|
if selected_config.is_none() {
|
||||||
|
for config in &supported_configs {
|
||||||
|
if config.channels() == source_channels {
|
||||||
|
// Use the minimum supported sample rate if source is too low
|
||||||
|
if source_sample_rate < config.min_sample_rate().0 {
|
||||||
|
target_sample_rate = config.min_sample_rate().0;
|
||||||
|
selected_config = Some(config.with_sample_rate(SampleRate(target_sample_rate)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Use the maximum supported sample rate if source is too high
|
||||||
|
else if source_sample_rate > config.max_sample_rate().0 {
|
||||||
|
target_sample_rate = config.max_sample_rate().0;
|
||||||
|
selected_config = Some(config.with_sample_rate(SampleRate(target_sample_rate)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to device default if nothing works
|
||||||
|
let config = if let Some(config) = selected_config {
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
warn!("No suitable config found, using device default");
|
||||||
|
self.device.default_output_config()?
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Selected config: {} channels, {} Hz, format: {:?}",
|
||||||
|
config.channels(), config.sample_rate().0, config.sample_format());
|
||||||
|
|
||||||
|
// Resample if necessary
|
||||||
|
let final_samples = if target_sample_rate != source_sample_rate {
|
||||||
|
warn!("Resampling from {} Hz to {} Hz", source_sample_rate, target_sample_rate);
|
||||||
|
self.resample(samples, source_sample_rate, target_sample_rate, source_channels)
|
||||||
|
} else {
|
||||||
|
samples
|
||||||
|
};
|
||||||
|
|
||||||
|
let audio_buffer = Arc::new(Mutex::new(StreamBuffer {
|
||||||
|
samples: final_samples,
|
||||||
|
position: 0,
|
||||||
|
sample_rate: target_sample_rate,
|
||||||
|
channels: source_channels,
|
||||||
|
volume: self.volume,
|
||||||
|
paused: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.stream_buffer = Some(audio_buffer.clone());
|
||||||
|
|
||||||
|
let stream_config = config.config();
|
||||||
|
|
||||||
|
// Build stream based on the selected format
|
||||||
|
match config.sample_format() {
|
||||||
|
SampleFormat::F32 => {
|
||||||
|
self.build_f32_stream(stream_config, audio_buffer)?;
|
||||||
|
}
|
||||||
|
SampleFormat::I32 => {
|
||||||
|
self.build_i32_stream(stream_config, audio_buffer)?;
|
||||||
|
}
|
||||||
|
SampleFormat::I16 => {
|
||||||
|
self.build_i16_stream(stream_config, audio_buffer)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(anyhow::anyhow!("Unsupported sample format"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple linear interpolation resampling (not high quality, but functional)
|
||||||
|
fn resample(&self, input: Vec<f32>, input_rate: u32, output_rate: u32, channels: u16) -> Vec<f32> {
|
||||||
|
let ratio = input_rate as f64 / output_rate as f64;
|
||||||
|
let input_frames = input.len() / channels as usize;
|
||||||
|
let output_frames = (input_frames as f64 / ratio) as usize;
|
||||||
|
let mut output = Vec::with_capacity(output_frames * channels as usize);
|
||||||
|
|
||||||
|
for frame in 0..output_frames {
|
||||||
|
let input_frame = (frame as f64 * ratio) as usize;
|
||||||
|
|
||||||
|
if input_frame < input_frames {
|
||||||
|
for ch in 0..channels {
|
||||||
|
let input_idx = input_frame * channels as usize + ch as usize;
|
||||||
|
if input_idx < input.len() {
|
||||||
|
output.push(input[input_idx]);
|
||||||
|
} else {
|
||||||
|
output.push(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _ in 0..channels {
|
||||||
|
output.push(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_f32_stream(&mut self, config: StreamConfig, audio_buffer: Arc<Mutex<StreamBuffer>>) -> Result<()> {
|
||||||
|
let stream = self.device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut buffer = audio_buffer.lock().unwrap();
|
||||||
|
|
||||||
|
// If paused, just output silence
|
||||||
|
if buffer.paused {
|
||||||
|
data.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples_to_copy = data.len().min(buffer.samples.len() - buffer.position);
|
||||||
|
|
||||||
|
if samples_to_copy > 0 {
|
||||||
|
let end_pos = buffer.position + samples_to_copy;
|
||||||
|
|
||||||
|
// Apply volume
|
||||||
|
if buffer.volume < 0.999 {
|
||||||
|
for i in 0..samples_to_copy {
|
||||||
|
data[i] = buffer.samples[buffer.position + i] * buffer.volume;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data[..samples_to_copy].copy_from_slice(&buffer.samples[buffer.position..end_pos]);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.position = end_pos;
|
||||||
|
|
||||||
|
// Fill remaining with silence if we don't have enough samples
|
||||||
|
if samples_to_copy < data.len() {
|
||||||
|
data[samples_to_copy..].fill(0.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No more samples, fill with silence
|
||||||
|
data.fill(0.0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|err| error!("ASIO stream error: {}", err),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.stream = Some(stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_i32_stream(&mut self, config: StreamConfig, audio_buffer: Arc<Mutex<StreamBuffer>>) -> Result<()> {
|
||||||
|
let stream = self.device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [i32], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut buffer = audio_buffer.lock().unwrap();
|
||||||
|
|
||||||
|
// If paused, just output silence
|
||||||
|
if buffer.paused {
|
||||||
|
data.fill(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples_to_copy = data.len().min(buffer.samples.len() - buffer.position);
|
||||||
|
|
||||||
|
if samples_to_copy > 0 {
|
||||||
|
for i in 0..samples_to_copy {
|
||||||
|
data[i] = (buffer.samples[buffer.position + i] * buffer.volume * i32::MAX as f32) as i32;
|
||||||
|
}
|
||||||
|
buffer.position += samples_to_copy;
|
||||||
|
|
||||||
|
if samples_to_copy < data.len() {
|
||||||
|
data[samples_to_copy..].fill(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.fill(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|err| error!("ASIO stream error: {}", err),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.stream = Some(stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_i16_stream(&mut self, config: StreamConfig, audio_buffer: Arc<Mutex<StreamBuffer>>) -> Result<()> {
|
||||||
|
let stream = self.device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut buffer = audio_buffer.lock().unwrap();
|
||||||
|
|
||||||
|
// If paused, just output silence
|
||||||
|
if buffer.paused {
|
||||||
|
data.fill(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples_to_copy = data.len().min(buffer.samples.len() - buffer.position);
|
||||||
|
|
||||||
|
if samples_to_copy > 0 {
|
||||||
|
for i in 0..samples_to_copy {
|
||||||
|
data[i] = (buffer.samples[buffer.position + i] * buffer.volume * i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
buffer.position += samples_to_copy;
|
||||||
|
|
||||||
|
if samples_to_copy < data.len() {
|
||||||
|
data[samples_to_copy..].fill(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.fill(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|err| error!("ASIO stream error: {}", err),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.stream = Some(stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
79
src/config.rs
Normal file
79
src/config.rs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::{info, error};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server_url: String,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server_url: String::new(),
|
||||||
|
user_id: None,
|
||||||
|
access_token: None,
|
||||||
|
username: None,
|
||||||
|
device_id: "jellyfin-asio-001".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let config_path = get_config_path()?;
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
info!("No config file found, creating default config");
|
||||||
|
let default_config = Config::default();
|
||||||
|
default_config.save()?;
|
||||||
|
return Ok(default_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_str = fs::read_to_string(&config_path)?;
|
||||||
|
let config: Config = serde_json::from_str(&config_str)?;
|
||||||
|
|
||||||
|
info!("Loaded config from {}", config_path.display());
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let config_path = get_config_path()?;
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_str = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(&config_path, config_str)?;
|
||||||
|
|
||||||
|
info!("Saved config to {}", config_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
self.user_id.is_some() && self.access_token.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_auth(&mut self) -> Result<()> {
|
||||||
|
self.user_id = None;
|
||||||
|
self.access_token = None;
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config_path() -> Result<PathBuf> {
|
||||||
|
let mut path = dirs::config_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
|
||||||
|
|
||||||
|
path.push("jellyfin-asio-client");
|
||||||
|
path.push("config.json");
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
55
src/errorlog.log
Normal file
55
src/errorlog.log
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
error: struct is not supported in `trait`s or `impl`s
|
||||||
|
--> src\tui.rs:469:5
|
||||||
|
|
|
||||||
|
469 | struct UiData {
|
||||||
|
| ^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= help: consider moving the struct out to a nearby module scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:485:34
|
||||||
|
|
|
||||||
|
485 | fn prepare_ui_data(&self) -> UiData {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0422]: cannot find struct, variant or union type `UiData` in this scope
|
||||||
|
--> src\tui.rs:486:9
|
||||||
|
|
|
||||||
|
486 | UiData {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:504:48
|
||||||
|
|
|
||||||
|
504 | fn draw_ui_static(f: &mut Frame, ui_data: &UiData, player: &Player) {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:547:58
|
||||||
|
|
|
||||||
|
547 | fn draw_artists(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:564:57
|
||||||
|
|
|
||||||
|
564 | fn draw_albums(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:588:56
|
||||||
|
|
|
||||||
|
588 | fn draw_songs(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:618:56
|
||||||
|
|
|
||||||
|
618 | fn draw_queue(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
| ^^^^^^ not found in this scope
|
||||||
|
|
||||||
|
error[E0412]: cannot find type `UiData` in this scope
|
||||||
|
--> src\tui.rs:642:57
|
||||||
|
|
|
||||||
|
642 | fn draw_search(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
| ^^^^^^ not found in this scope
|
162
src/jellyfin.rs
Normal file
162
src/jellyfin.rs
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::{info, debug};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JellyfinClient {
|
||||||
|
client: Client,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
#[serde(rename = "User")]
|
||||||
|
pub user: User,
|
||||||
|
#[serde(rename = "AccessToken")]
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct LibraryItem {
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub item_type: String,
|
||||||
|
#[serde(rename = "Artists")]
|
||||||
|
pub artists: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "Album")]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(rename = "RunTimeTicks")]
|
||||||
|
pub runtime_ticks: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ItemsResponse {
|
||||||
|
#[serde(rename = "Items")]
|
||||||
|
pub items: Vec<LibraryItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent("JellyfinASIOClient/0.1.0")
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(&mut self, username: &str, password: &str) -> Result<()> {
|
||||||
|
let auth_url = format!("{}/Users/authenticatebyname", self.config.server_url);
|
||||||
|
|
||||||
|
let mut auth_data = HashMap::new();
|
||||||
|
auth_data.insert("Username", username);
|
||||||
|
auth_data.insert("Pw", password);
|
||||||
|
|
||||||
|
info!("Authenticating with Jellyfin server...");
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.post(&auth_url)
|
||||||
|
.header("X-Emby-Authorization", format!(r#"MediaBrowser Client="JellyfinASIOClient", Device="Rust Client", DeviceId="{}", Version="0.1.0""#, self.config.device_id))
|
||||||
|
.json(&auth_data)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
return Err(anyhow::anyhow!("Authentication failed: {} - {}", status, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_response: AuthResponse = response.json().await?;
|
||||||
|
|
||||||
|
self.config.user_id = Some(auth_response.user.id.clone());
|
||||||
|
self.config.access_token = Some(auth_response.access_token.clone());
|
||||||
|
self.config.username = Some(username.to_string());
|
||||||
|
self.config.save()?;
|
||||||
|
|
||||||
|
info!("Authenticated as: {}", auth_response.user.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_music_library(&self) -> Result<Vec<LibraryItem>> {
|
||||||
|
let user_id = self.config.user_id.as_ref().ok_or_else(|| anyhow::anyhow!("Not authenticated"))?;
|
||||||
|
let token = self.config.access_token.as_ref().ok_or_else(|| anyhow::anyhow!("Not authenticated"))?;
|
||||||
|
|
||||||
|
let url = format!("{}/Users/{}/Items", self.config.server_url, user_id);
|
||||||
|
|
||||||
|
debug!("Fetching music library...");
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Authorization", format!(r#"MediaBrowser Client="JellyfinASIOClient", Device="Rust Client", DeviceId="{}", Version="0.1.0", Token="{}""#, self.config.device_id, token))
|
||||||
|
.query(&[
|
||||||
|
("IncludeItemTypes", "Audio,MusicAlbum"),
|
||||||
|
("Recursive", "true"),
|
||||||
|
("Fields", "Artists,Album,RunTimeTicks"),
|
||||||
|
("SortBy", "Artist,Album,SortName"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
return Err(anyhow::anyhow!("Failed to fetch library: {} - {}", status, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let items_response: ItemsResponse = response.json().await?;
|
||||||
|
|
||||||
|
info!("Found {} music items", items_response.items.len());
|
||||||
|
Ok(items_response.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_stream_url(&self, item_id: &str) -> Result<String> {
|
||||||
|
let user_id = self.config.user_id.as_ref().ok_or_else(|| anyhow::anyhow!("Not authenticated"))?;
|
||||||
|
let token = self.config.access_token.as_ref().ok_or_else(|| anyhow::anyhow!("Not authenticated"))?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
|
||||||
|
self.config.server_url, item_id, user_id, token
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
self.config.is_authenticated()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout(&mut self) -> Result<()> {
|
||||||
|
self.config.clear_auth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryItem {
|
||||||
|
pub fn artist_name(&self) -> Option<String> {
|
||||||
|
self.artists.as_ref()
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_name(&self) -> Option<String> {
|
||||||
|
self.album.clone()
|
||||||
|
}
|
||||||
|
}
|
160
src/main.rs
Normal file
160
src/main.rs
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
use cpal::traits::{HostTrait, DeviceTrait};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
mod jellyfin;
|
||||||
|
mod audio;
|
||||||
|
mod config;
|
||||||
|
mod player;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
info!("Starting Jellyfin ASIO Client");
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
let mut config = config::Config::load()?;
|
||||||
|
|
||||||
|
// Create Jellyfin client
|
||||||
|
let mut jellyfin = jellyfin::JellyfinClient::new(config.clone());
|
||||||
|
|
||||||
|
// Check if we need to authenticate
|
||||||
|
if !jellyfin.is_authenticated() {
|
||||||
|
println!("No saved session found. Please log in.");
|
||||||
|
|
||||||
|
// Get server URL if not set
|
||||||
|
if config.server_url.is_empty() {
|
||||||
|
print!("Enter Jellyfin server URL (e.g., https://jellyfin.example.com): ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut url = String::new();
|
||||||
|
std::io::stdin().read_line(&mut url)?;
|
||||||
|
config.server_url = url.trim().to_string();
|
||||||
|
config.save()?;
|
||||||
|
jellyfin = jellyfin::JellyfinClient::new(config.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("Enter your Jellyfin username: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut username = String::new();
|
||||||
|
std::io::stdin().read_line(&mut username)?;
|
||||||
|
username = username.trim().to_string();
|
||||||
|
|
||||||
|
print!("Enter your Jellyfin password: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let password = rpassword::read_password()?;
|
||||||
|
|
||||||
|
jellyfin.authenticate(&username, &password).await?
|
||||||
|
} else {
|
||||||
|
info!("Using saved session for {}", config.username.unwrap_or_else(|| "Unknown user".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// List ASIO devices
|
||||||
|
list_asio_devices()?;
|
||||||
|
|
||||||
|
// Create player
|
||||||
|
let player = player::Player::new(jellyfin.clone())?;
|
||||||
|
|
||||||
|
// Create and run TUI
|
||||||
|
let mut tui = tui::Tui::new(jellyfin, player)?;
|
||||||
|
tui.run().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_jellyfin_connection() -> Result<()> {
|
||||||
|
info!("Testing Jellyfin connection...");
|
||||||
|
|
||||||
|
let config = config::Config {
|
||||||
|
server_url: "https://jfin.mercurio.moe".to_string(),
|
||||||
|
..config::Config::default()
|
||||||
|
};
|
||||||
|
let mut client = jellyfin::JellyfinClient::new(config);
|
||||||
|
|
||||||
|
println!("Enter your Jellyfin username:");
|
||||||
|
let mut username = String::new();
|
||||||
|
std::io::stdin().read_line(&mut username)?;
|
||||||
|
username = username.trim().to_string();
|
||||||
|
|
||||||
|
println!("Enter your Jellyfin password:");
|
||||||
|
let password = rpassword::read_password()?;
|
||||||
|
|
||||||
|
client.authenticate(&username, &password).await?;
|
||||||
|
|
||||||
|
let items = client.get_music_library().await?;
|
||||||
|
|
||||||
|
info!("Your music library (first few items):");
|
||||||
|
for item in items.iter().take(10) {
|
||||||
|
let artist = item.artists.as_ref()
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Unknown Artist");
|
||||||
|
let album = item.album.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Unknown Album");
|
||||||
|
|
||||||
|
info!(" {} - {} - {}", artist, album, item.name);
|
||||||
|
|
||||||
|
if items.iter().position(|i| i.id == item.id) == Some(0) {
|
||||||
|
let stream_url = client.get_stream_url(&item.id)?;
|
||||||
|
info!(" Stream URL: {}", stream_url);
|
||||||
|
|
||||||
|
println!("\nDo you want to play this track? (y/n):");
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
|
if input.trim().to_lowercase() == "y" {
|
||||||
|
let mut player = audio::AsioPlayer::new()?;
|
||||||
|
|
||||||
|
println!("Do you want to set a specific sample rate? (y/n):");
|
||||||
|
let mut rate_input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut rate_input)?;
|
||||||
|
|
||||||
|
if rate_input.trim().to_lowercase() == "y" {
|
||||||
|
println!("Enter sample rate (e.g., 44100, 48000, 96000, 192000):");
|
||||||
|
let mut rate_str = String::new();
|
||||||
|
std::io::stdin().read_line(&mut rate_str)?;
|
||||||
|
|
||||||
|
if let Ok(sample_rate) = rate_str.trim().parse::<u32>() {
|
||||||
|
match player.set_sample_rate(sample_rate) {
|
||||||
|
Ok(_) => println!("Sample rate set to {} Hz", sample_rate),
|
||||||
|
Err(e) => println!("Failed to set sample rate: {}", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Invalid sample rate, using automatic selection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.play_url(&stream_url).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_asio_devices() -> Result<()> {
|
||||||
|
info!("Enumerating ASIO devices...");
|
||||||
|
|
||||||
|
let host = cpal::host_from_id(cpal::HostId::Asio)?;
|
||||||
|
|
||||||
|
let devices = host.output_devices()?;
|
||||||
|
|
||||||
|
for (i, device) in devices.enumerate() {
|
||||||
|
let name = device.name()?;
|
||||||
|
info!("ASIO Device {}: {}", i, name);
|
||||||
|
|
||||||
|
let supported_configs = device.supported_output_configs()?;
|
||||||
|
for config in supported_configs {
|
||||||
|
info!(" Channels: {}, Sample Rate: {}-{} Hz, Format: {:?}",
|
||||||
|
config.channels(),
|
||||||
|
config.min_sample_rate().0,
|
||||||
|
config.max_sample_rate().0,
|
||||||
|
config.sample_format());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
275
src/player.rs
Normal file
275
src/player.rs
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{info, debug, error};
|
||||||
|
|
||||||
|
use crate::audio::AsioPlayer;
|
||||||
|
use crate::jellyfin::{JellyfinClient, LibraryItem};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PlaybackState {
|
||||||
|
Stopped,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PlayerCommand {
|
||||||
|
Play(String),
|
||||||
|
PlayPause,
|
||||||
|
Stop,
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
Seek(f32),
|
||||||
|
SetVolume(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum AudioCommand {
|
||||||
|
Play(String),
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
Stop,
|
||||||
|
Seek(f32),
|
||||||
|
SetVolume(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlayerState {
|
||||||
|
pub current_item: Option<LibraryItem>,
|
||||||
|
pub queue: Vec<LibraryItem>,
|
||||||
|
pub queue_position: usize,
|
||||||
|
pub playback_state: PlaybackState,
|
||||||
|
pub volume: f32,
|
||||||
|
pub position: f32, // 0.0 - 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current_item: None,
|
||||||
|
queue: Vec::new(),
|
||||||
|
queue_position: 0,
|
||||||
|
playback_state: PlaybackState::Stopped,
|
||||||
|
volume: 1.0,
|
||||||
|
position: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Player {
|
||||||
|
state: Arc<Mutex<PlayerState>>,
|
||||||
|
command_tx: mpsc::Sender<PlayerCommand>,
|
||||||
|
audio_tx: mpsc::Sender<AudioCommand>,
|
||||||
|
jellyfin: Arc<Mutex<JellyfinClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
pub fn new(jellyfin: JellyfinClient) -> Result<Self> {
|
||||||
|
let (command_tx, mut command_rx) = mpsc::channel::<PlayerCommand>(32);
|
||||||
|
let (audio_tx, mut audio_rx) = mpsc::channel::<AudioCommand>(32);
|
||||||
|
let state = Arc::new(Mutex::new(PlayerState::default()));
|
||||||
|
let jellyfin = Arc::new(Mutex::new(jellyfin));
|
||||||
|
|
||||||
|
let player_state = state.clone();
|
||||||
|
let player_jellyfin = jellyfin.clone();
|
||||||
|
let command_tx_clone = command_tx.clone();
|
||||||
|
let audio_tx_clone = audio_tx.clone();
|
||||||
|
|
||||||
|
// Spawn audio handler in a blocking thread
|
||||||
|
let audio_state = state.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut asio_player = match AsioPlayer::new() {
|
||||||
|
Ok(player) => Some(player),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize ASIO player: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some(cmd) = audio_rx.recv().await {
|
||||||
|
if let Some(ref mut player) = asio_player {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::Play(url) => {
|
||||||
|
{
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.playback_state = PlaybackState::Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
match player.play_url(&url).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.playback_state = PlaybackState::Stopped;
|
||||||
|
state.position = 0.0;
|
||||||
|
},
|
||||||
|
Err(e) => error!("Playback error: {}", e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AudioCommand::Pause => {
|
||||||
|
if player.pause().is_ok() {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.playback_state = PlaybackState::Paused;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AudioCommand::Resume => {
|
||||||
|
if player.resume().is_ok() {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.playback_state = PlaybackState::Playing;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AudioCommand::Stop => {
|
||||||
|
if player.stop().is_ok() {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.playback_state = PlaybackState::Stopped;
|
||||||
|
state.position = 0.0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AudioCommand::Seek(position) => {
|
||||||
|
if player.seek(position).is_ok() {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.position = position;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AudioCommand::SetVolume(volume) => {
|
||||||
|
if player.set_volume(volume).is_ok() {
|
||||||
|
let mut state = audio_state.lock().unwrap();
|
||||||
|
state.volume = volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn command handler task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(cmd) = command_rx.recv().await {
|
||||||
|
match cmd {
|
||||||
|
PlayerCommand::Play(item_id) => {
|
||||||
|
// Get the URL
|
||||||
|
let url = {
|
||||||
|
let jellyfin_guard = player_jellyfin.lock().unwrap();
|
||||||
|
jellyfin_guard.get_stream_url(&item_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
match url {
|
||||||
|
Ok(url) => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::Play(url)).await;
|
||||||
|
},
|
||||||
|
Err(e) => error!("Failed to get stream URL: {}", e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerCommand::PlayPause => {
|
||||||
|
// Get the current state
|
||||||
|
let (current_state, item_id) = {
|
||||||
|
let state = player_state.lock().unwrap();
|
||||||
|
(state.playback_state.clone(), state.current_item.as_ref().map(|item| item.id.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
match current_state {
|
||||||
|
PlaybackState::Playing => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::Pause).await;
|
||||||
|
},
|
||||||
|
PlaybackState::Paused => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::Resume).await;
|
||||||
|
},
|
||||||
|
PlaybackState::Stopped => {
|
||||||
|
if let Some(item_id) = item_id {
|
||||||
|
let _ = command_tx_clone.send(PlayerCommand::Play(item_id)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerCommand::Stop => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::Stop).await;
|
||||||
|
},
|
||||||
|
PlayerCommand::Next => {
|
||||||
|
let next_item_id = {
|
||||||
|
let mut state = player_state.lock().unwrap();
|
||||||
|
if !state.queue.is_empty() && state.queue_position < state.queue.len() - 1 {
|
||||||
|
state.queue_position += 1;
|
||||||
|
Some(state.queue[state.queue_position].id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(item_id) = next_item_id {
|
||||||
|
let _ = command_tx_clone.send(PlayerCommand::Play(item_id)).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerCommand::Previous => {
|
||||||
|
let prev_item_id = {
|
||||||
|
let mut state = player_state.lock().unwrap();
|
||||||
|
if !state.queue.is_empty() && state.queue_position > 0 {
|
||||||
|
state.queue_position -= 1;
|
||||||
|
Some(state.queue[state.queue_position].id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(item_id) = prev_item_id {
|
||||||
|
let _ = command_tx_clone.send(PlayerCommand::Play(item_id)).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerCommand::Seek(position) => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::Seek(position)).await;
|
||||||
|
},
|
||||||
|
PlayerCommand::SetVolume(volume) => {
|
||||||
|
let _ = audio_tx_clone.send(AudioCommand::SetVolume(volume)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
state,
|
||||||
|
command_tx,
|
||||||
|
audio_tx,
|
||||||
|
jellyfin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_command(&self, command: PlayerCommand) -> Result<()> {
|
||||||
|
self.command_tx.send(command).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to send player command: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_state(&self) -> Arc<Mutex<PlayerState>> {
|
||||||
|
self.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_queue(&self, items: Vec<LibraryItem>) -> Result<()> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.queue = items;
|
||||||
|
state.queue_position = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_jellyfin(&self) -> Arc<Mutex<JellyfinClient>> {
|
||||||
|
self.jellyfin.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_volume(&self) -> f32 {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_position(&self) -> f32 {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state.position
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_item(&self) -> Option<LibraryItem> {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state.current_item.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_playback_state(&self) -> PlaybackState {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
state.playback_state.clone()
|
||||||
|
}
|
||||||
|
}
|
903
src/tui.rs
Normal file
903
src/tui.rs
Normal file
|
@ -0,0 +1,903 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
|
use crossterm::ExecutableCommand;
|
||||||
|
use ratatui::backend::{Backend, CrosstermBackend};
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span, Text};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs};
|
||||||
|
use ratatui::{Frame, Terminal};
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use std::thread;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::jellyfin::{JellyfinClient, LibraryItem};
|
||||||
|
use crate::player::{Player, PlayerCommand, PlaybackState};
|
||||||
|
|
||||||
|
const TAB_ARTISTS: usize = 0;
|
||||||
|
const TAB_ALBUMS: usize = 1;
|
||||||
|
const TAB_SONGS: usize = 2;
|
||||||
|
const TAB_QUEUE: usize = 3;
|
||||||
|
const TAB_SEARCH: usize = 4;
|
||||||
|
|
||||||
|
enum UiEvent {
|
||||||
|
Tick,
|
||||||
|
Input(KeyEvent),
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UiData {
|
||||||
|
active_tab: usize,
|
||||||
|
show_help: bool,
|
||||||
|
artists: Vec<String>,
|
||||||
|
artists_state: ListState,
|
||||||
|
albums: Vec<LibraryItem>,
|
||||||
|
albums_state: ListState,
|
||||||
|
songs: Vec<LibraryItem>,
|
||||||
|
songs_state: ListState,
|
||||||
|
queue: Vec<LibraryItem>,
|
||||||
|
queue_state: ListState,
|
||||||
|
search_query: String,
|
||||||
|
search_results: Vec<LibraryItem>,
|
||||||
|
search_state: ListState,
|
||||||
|
current_artist_filter: Option<String>,
|
||||||
|
current_album_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Tui {
|
||||||
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
events: Receiver<UiEvent>,
|
||||||
|
_event_sender: Sender<UiEvent>,
|
||||||
|
jellyfin: JellyfinClient,
|
||||||
|
player: Player,
|
||||||
|
|
||||||
|
active_tab: usize,
|
||||||
|
artists: Vec<String>,
|
||||||
|
artists_state: ListState,
|
||||||
|
all_albums: Vec<LibraryItem>,
|
||||||
|
albums: Vec<LibraryItem>,
|
||||||
|
albums_state: ListState,
|
||||||
|
all_songs: Vec<LibraryItem>,
|
||||||
|
songs: Vec<LibraryItem>,
|
||||||
|
songs_state: ListState,
|
||||||
|
queue: Vec<LibraryItem>,
|
||||||
|
queue_state: ListState,
|
||||||
|
search_query: String,
|
||||||
|
search_results: Vec<LibraryItem>,
|
||||||
|
search_state: ListState,
|
||||||
|
show_help: bool,
|
||||||
|
current_artist_filter: Option<String>,
|
||||||
|
current_album_filter: Option<String>,
|
||||||
|
last_key_time: Option<Instant>,
|
||||||
|
last_key_code: Option<KeyCode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tui {
|
||||||
|
pub fn new(jellyfin: JellyfinClient, player: Player) -> Result<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
io::stdout().execute(EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let tick_rate = Duration::from_millis(250);
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let event_tx = tx.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let tick_rate = tick_rate;
|
||||||
|
loop {
|
||||||
|
if event::poll(tick_rate).unwrap() {
|
||||||
|
if let Event::Key(key) = event::read().unwrap() {
|
||||||
|
if tx.send(UiEvent::Input(key)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if tx.send(UiEvent::Tick).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
terminal,
|
||||||
|
events: rx,
|
||||||
|
_event_sender: event_tx,
|
||||||
|
jellyfin,
|
||||||
|
player,
|
||||||
|
active_tab: 0,
|
||||||
|
artists: Vec::new(),
|
||||||
|
artists_state: ListState::default(),
|
||||||
|
all_albums: Vec::new(),
|
||||||
|
albums: Vec::new(),
|
||||||
|
albums_state: ListState::default(),
|
||||||
|
all_songs: Vec::new(),
|
||||||
|
songs: Vec::new(),
|
||||||
|
songs_state: ListState::default(),
|
||||||
|
queue: Vec::new(),
|
||||||
|
queue_state: ListState::default(),
|
||||||
|
search_query: String::new(),
|
||||||
|
search_results: Vec::new(),
|
||||||
|
search_state: ListState::default(),
|
||||||
|
show_help: false,
|
||||||
|
current_artist_filter: None,
|
||||||
|
current_album_filter: None,
|
||||||
|
last_key_time: None,
|
||||||
|
last_key_code: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&mut self) -> Result<()> {
|
||||||
|
self.load_library_data().await?;
|
||||||
|
|
||||||
|
if !self.artists.is_empty() {
|
||||||
|
self.artists_state.select(Some(0));
|
||||||
|
}
|
||||||
|
if !self.albums.is_empty() {
|
||||||
|
self.albums_state.select(Some(0));
|
||||||
|
}
|
||||||
|
if !self.songs.is_empty() {
|
||||||
|
self.songs_state.select(Some(0));
|
||||||
|
}
|
||||||
|
self.clear_filters();
|
||||||
|
|
||||||
|
let debounce_duration = Duration::from_millis(150);
|
||||||
|
loop {
|
||||||
|
let ui_data = self.prepare_ui_data();
|
||||||
|
self.terminal.draw(|f| {
|
||||||
|
Self::draw_ui_static(f, &ui_data, &self.player);
|
||||||
|
})?;
|
||||||
|
match self.events.recv()? {
|
||||||
|
UiEvent::Input(event) => {
|
||||||
|
let now = Instant::now();
|
||||||
|
let is_repeat = self.last_key_code == Some(event.code)
|
||||||
|
&& self.last_key_time.map_or(false, |t| now.duration_since(t) < debounce_duration);
|
||||||
|
if !is_repeat {
|
||||||
|
self.last_key_time = Some(now);
|
||||||
|
self.last_key_code = Some(event.code);
|
||||||
|
if self.handle_input(event).await? {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiEvent::Tick => {
|
||||||
|
// Just tick, no action needed
|
||||||
|
}
|
||||||
|
UiEvent::Quit => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disable_raw_mode()?;
|
||||||
|
io::stdout().execute(LeaveAlternateScreen)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_library_data(&mut self) -> Result<()> {
|
||||||
|
let items = self.jellyfin.get_music_library().await?;
|
||||||
|
|
||||||
|
let mut artist_set = std::collections::HashSet::new();
|
||||||
|
for item in &items {
|
||||||
|
if let Some(artist) = item.artist_name() {
|
||||||
|
artist_set.insert(artist.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.artists = artist_set.into_iter().collect();
|
||||||
|
self.artists.sort();
|
||||||
|
|
||||||
|
self.all_albums = items.iter()
|
||||||
|
.filter(|item| item.item_type == "MusicAlbum")
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
self.all_albums.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
self.all_songs = items.iter()
|
||||||
|
.filter(|item| item.item_type == "Audio")
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
self.all_songs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
// Initially show all albums and songs
|
||||||
|
self.albums = self.all_albums.clone();
|
||||||
|
self.songs = self.all_songs.clone();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_albums_by_artist(&mut self, artist: &str) {
|
||||||
|
self.albums = self.all_albums.iter()
|
||||||
|
.filter(|album| {
|
||||||
|
album.artist_name().map_or(false, |a| a == artist)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Reset album selection
|
||||||
|
self.albums_state.select(if self.albums.is_empty() { None } else { Some(0) });
|
||||||
|
|
||||||
|
// Clear album filter and update songs
|
||||||
|
self.current_album_filter = None;
|
||||||
|
self.filter_songs();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_songs_by_album(&mut self, album: &LibraryItem) {
|
||||||
|
self.current_album_filter = Some(album.name.clone());
|
||||||
|
self.filter_songs();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_songs(&mut self) {
|
||||||
|
self.songs = self.all_songs.iter()
|
||||||
|
.filter(|song| {
|
||||||
|
// Filter by artist if set
|
||||||
|
if let Some(ref artist_filter) = self.current_artist_filter {
|
||||||
|
if song.artist_name().map_or(true, |a| a != *artist_filter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by album if set
|
||||||
|
if let Some(ref album_filter) = self.current_album_filter {
|
||||||
|
if song.album_name().map_or(true, |a| a != *album_filter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Reset song selection
|
||||||
|
self.songs_state.select(if self.songs.is_empty() { None } else { Some(0) });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_filters(&mut self) {
|
||||||
|
self.current_artist_filter = None;
|
||||||
|
self.current_album_filter = None;
|
||||||
|
self.albums = self.all_albums.clone();
|
||||||
|
self.songs = self.all_songs.clone();
|
||||||
|
|
||||||
|
// Reset selections
|
||||||
|
self.albums_state.select(if self.albums.is_empty() { None } else { Some(0) });
|
||||||
|
self.songs_state.select(if self.songs.is_empty() { None } else { Some(0) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_input(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
|
// Global shortcuts
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('q'), _) => return Ok(true),
|
||||||
|
(KeyCode::Char('?'), _) => {
|
||||||
|
self.show_help = !self.show_help;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(true),
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
self.next_tab();
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::BackTab, _) => {
|
||||||
|
self.prev_tab();
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('1'), _) => {
|
||||||
|
self.active_tab = TAB_ARTISTS;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('2'), _) => {
|
||||||
|
self.active_tab = TAB_ALBUMS;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('3'), _) => {
|
||||||
|
self.active_tab = TAB_SONGS;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
(KeyCode::Char('4'), _) => {
|
||||||
|
self.active_tab = TAB_QUEUE;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('5'), _) => {
|
||||||
|
self.active_tab = TAB_SEARCH;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
(KeyCode::Char('r'), _) => {
|
||||||
|
// Reset filters
|
||||||
|
self.clear_filters();
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player controls (only if not showing help and not in search mode)
|
||||||
|
if !self.show_help && self.active_tab != TAB_SEARCH {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
self.player.send_command(PlayerCommand::PlayPause).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.player.send_command(PlayerCommand::Stop).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
self.player.send_command(PlayerCommand::Next).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Char('p') => {
|
||||||
|
self.player.send_command(PlayerCommand::Previous).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
|
let vol = (self.player.get_volume() + 0.05).min(1.0);
|
||||||
|
self.player.send_command(PlayerCommand::SetVolume(vol)).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Char('-') => {
|
||||||
|
let vol = (self.player.get_volume() - 0.05).max(0.0);
|
||||||
|
self.player.send_command(PlayerCommand::SetVolume(vol)).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Left => {
|
||||||
|
let pos = (self.player.get_position() - 0.05).max(0.0);
|
||||||
|
self.player.send_command(PlayerCommand::Seek(pos)).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
KeyCode::Right => {
|
||||||
|
let pos = (self.player.get_position() + 0.05).min(1.0);
|
||||||
|
self.player.send_command(PlayerCommand::Seek(pos)).await?;
|
||||||
|
return Ok(false);
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-specific input handling
|
||||||
|
if !self.show_help {
|
||||||
|
match self.active_tab {
|
||||||
|
TAB_ARTISTS => self.handle_artists_input(key).await?,
|
||||||
|
TAB_ALBUMS => self.handle_albums_input(key).await?,
|
||||||
|
TAB_SONGS => self.handle_songs_input(key).await?,
|
||||||
|
TAB_QUEUE => self.handle_queue_input(key).await?,
|
||||||
|
TAB_SEARCH => self.handle_search_input(key).await?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_artists_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(selected) = self.artists_state.selected() {
|
||||||
|
if selected > 0 {
|
||||||
|
self.artists_state.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(selected) = self.artists_state.selected() {
|
||||||
|
if selected < self.artists.len().saturating_sub(1) {
|
||||||
|
self.artists_state.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.artists_state.selected() {
|
||||||
|
if selected < self.artists.len() {
|
||||||
|
let artist = self.artists[selected].clone();
|
||||||
|
self.current_artist_filter = Some(artist.clone());
|
||||||
|
self.filter_albums_by_artist(&artist);
|
||||||
|
self.active_tab = TAB_ALBUMS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_albums_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(selected) = self.albums_state.selected() {
|
||||||
|
if selected > 0 {
|
||||||
|
self.albums_state.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(selected) = self.albums_state.selected() {
|
||||||
|
if selected < self.albums.len().saturating_sub(1) {
|
||||||
|
self.albums_state.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.albums_state.selected() {
|
||||||
|
if selected < self.albums.len() {
|
||||||
|
let album = self.albums[selected].clone();
|
||||||
|
self.filter_songs_by_album(&album);
|
||||||
|
self.active_tab = TAB_SONGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_songs_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(selected) = self.songs_state.selected() {
|
||||||
|
if selected > 0 {
|
||||||
|
self.songs_state.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(selected) = self.songs_state.selected() {
|
||||||
|
if selected < self.songs.len().saturating_sub(1) {
|
||||||
|
self.songs_state.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.songs_state.selected() {
|
||||||
|
if selected < self.songs.len() {
|
||||||
|
let song = &self.songs[selected];
|
||||||
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
||||||
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
||||||
|
|
||||||
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
||||||
|
self.queue.push(song.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
if let Some(selected) = self.songs_state.selected() {
|
||||||
|
if selected < self.songs.len() {
|
||||||
|
let song = &self.songs[selected];
|
||||||
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
||||||
|
self.queue.push(song.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_queue_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(selected) = self.queue_state.selected() {
|
||||||
|
if selected > 0 {
|
||||||
|
self.queue_state.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(selected) = self.queue_state.selected() {
|
||||||
|
if selected < self.queue.len().saturating_sub(1) {
|
||||||
|
self.queue_state.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.queue_state.selected() {
|
||||||
|
if selected < self.queue.len() {
|
||||||
|
let song = &self.queue[selected];
|
||||||
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
||||||
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete | KeyCode::Char('d') => {
|
||||||
|
if let Some(selected) = self.queue_state.selected() {
|
||||||
|
if selected < self.queue.len() {
|
||||||
|
self.queue.remove(selected);
|
||||||
|
if self.queue.is_empty() {
|
||||||
|
self.queue_state.select(None);
|
||||||
|
} else if selected >= self.queue.len() {
|
||||||
|
self.queue_state.select(Some(self.queue.len() - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
self.queue.clear();
|
||||||
|
self.queue_state.select(None);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_search_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.search_query.push(c);
|
||||||
|
self.perform_search().await?;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.search_query.pop();
|
||||||
|
self.perform_search().await?;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(selected) = self.search_state.selected() {
|
||||||
|
if selected > 0 {
|
||||||
|
self.search_state.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(selected) = self.search_state.selected() {
|
||||||
|
if selected < self.search_results.len().saturating_sub(1) {
|
||||||
|
self.search_state.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = self.search_state.selected() {
|
||||||
|
if selected < self.search_results.len() {
|
||||||
|
let song = &self.search_results[selected];
|
||||||
|
if song.item_type == "Audio" {
|
||||||
|
let url = self.jellyfin.get_stream_url(&song.id)?;
|
||||||
|
self.player.send_command(PlayerCommand::Play(url)).await?;
|
||||||
|
|
||||||
|
if !self.queue.iter().any(|item| item.id == song.id) {
|
||||||
|
self.queue.push(song.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn perform_search(&mut self) -> Result<()> {
|
||||||
|
if self.search_query.is_empty() {
|
||||||
|
self.search_results.clear();
|
||||||
|
self.search_state.select(None);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = self.search_query.to_lowercase();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Search in all songs
|
||||||
|
for item in &self.all_songs {
|
||||||
|
if item.name.to_lowercase().contains(&query) ||
|
||||||
|
item.artist_name().map_or(false, |a| a.to_lowercase().contains(&query)) ||
|
||||||
|
item.album_name().map_or(false, |a| a.to_lowercase().contains(&query)) {
|
||||||
|
results.push(item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in all albums
|
||||||
|
for item in &self.all_albums {
|
||||||
|
if item.name.to_lowercase().contains(&query) ||
|
||||||
|
item.artist_name().map_or(false, |a| a.to_lowercase().contains(&query)) {
|
||||||
|
results.push(item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.search_results = results;
|
||||||
|
|
||||||
|
if !self.search_results.is_empty() {
|
||||||
|
self.search_state.select(Some(0));
|
||||||
|
} else {
|
||||||
|
self.search_state.select(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_tab(&mut self) {
|
||||||
|
self.active_tab = (self.active_tab + 1) % 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_tab(&mut self) {
|
||||||
|
self.active_tab = (self.active_tab + 4) % 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_ui_data(&self) -> UiData {
|
||||||
|
UiData {
|
||||||
|
active_tab: self.active_tab,
|
||||||
|
show_help: self.show_help,
|
||||||
|
artists: self.artists.clone(),
|
||||||
|
artists_state: self.artists_state.clone(),
|
||||||
|
albums: self.albums.clone(),
|
||||||
|
albums_state: self.albums_state.clone(),
|
||||||
|
songs: self.songs.clone(),
|
||||||
|
songs_state: self.songs_state.clone(),
|
||||||
|
queue: self.queue.clone(),
|
||||||
|
queue_state: self.queue_state.clone(),
|
||||||
|
search_query: self.search_query.clone(),
|
||||||
|
search_results: self.search_results.clone(),
|
||||||
|
search_state: self.search_state.clone(),
|
||||||
|
current_artist_filter: self.current_artist_filter.clone(),
|
||||||
|
current_album_filter: self.current_album_filter.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_ui_static(f: &mut Frame, ui_data: &UiData, player: &Player) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(1)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Tabs
|
||||||
|
Constraint::Min(0), // Content
|
||||||
|
Constraint::Length(3), // Status bar
|
||||||
|
])
|
||||||
|
.split(f.size());
|
||||||
|
|
||||||
|
// Draw tabs
|
||||||
|
let tab_titles: Vec<Span> = vec!["Artists", "Albums", "Songs", "Queue", "Search"]
|
||||||
|
.iter()
|
||||||
|
.map(|t| Span::styled(*t, Style::default().fg(Color::White)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut title = "Jellyfin ASIO Client".to_string();
|
||||||
|
if let Some(ref artist) = ui_data.current_artist_filter {
|
||||||
|
title.push_str(&format!(" - Artist: {}", artist));
|
||||||
|
}
|
||||||
|
if let Some(ref album) = ui_data.current_album_filter {
|
||||||
|
title.push_str(&format!(" - Album: {}", album));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabs = Tabs::new(tab_titles)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
|
||||||
|
.select(ui_data.active_tab);
|
||||||
|
|
||||||
|
f.render_widget(tabs, chunks[0]);
|
||||||
|
|
||||||
|
if ui_data.show_help {
|
||||||
|
Self::draw_help(f, chunks[1]);
|
||||||
|
} else {
|
||||||
|
match ui_data.active_tab {
|
||||||
|
TAB_ARTISTS => Self::draw_artists(f, chunks[1], ui_data),
|
||||||
|
TAB_ALBUMS => Self::draw_albums(f, chunks[1], ui_data),
|
||||||
|
TAB_SONGS => Self::draw_songs(f, chunks[1], ui_data),
|
||||||
|
TAB_QUEUE => Self::draw_queue(f, chunks[1], ui_data),
|
||||||
|
TAB_SEARCH => Self::draw_search(f, chunks[1], ui_data),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::draw_status_bar(f, chunks[2], player);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_artists(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
let items: Vec<ListItem> = ui_data.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| {
|
||||||
|
ListItem::new(Line::from(vec![Span::raw(artist.clone())]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Artists"))
|
||||||
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
let mut state = ui_data.artists_state.clone();
|
||||||
|
f.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_albums(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
let items: Vec<ListItem> = ui_data.albums
|
||||||
|
.iter()
|
||||||
|
.map(|album| {
|
||||||
|
let artist = album.artist_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(album.name.clone()),
|
||||||
|
Span::raw(" - ".to_string()),
|
||||||
|
Span::raw(artist),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut title = "Albums".to_string();
|
||||||
|
if let Some(ref artist) = ui_data.current_artist_filter {
|
||||||
|
title.push_str(&format!(" ({})", artist));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
let mut state = ui_data.albums_state.clone();
|
||||||
|
f.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_songs(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
let items: Vec<ListItem> = ui_data.songs
|
||||||
|
.iter()
|
||||||
|
.map(|song| {
|
||||||
|
let artist = song.artist_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
let album = song.album_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Album".to_string());
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(song.name.clone()),
|
||||||
|
Span::raw(" - ".to_string()),
|
||||||
|
Span::raw(artist),
|
||||||
|
Span::raw(" (Album: ".to_string()),
|
||||||
|
Span::raw(album),
|
||||||
|
Span::raw(")".to_string()),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut title = "Songs".to_string();
|
||||||
|
if let Some(ref album) = ui_data.current_album_filter {
|
||||||
|
title.push_str(&format!(" ({})", album));
|
||||||
|
} else if let Some(ref artist) = ui_data.current_artist_filter {
|
||||||
|
title.push_str(&format!(" ({})", artist));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
let mut state = ui_data.songs_state.clone();
|
||||||
|
f.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_queue(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
let items: Vec<ListItem> = ui_data.queue
|
||||||
|
.iter()
|
||||||
|
.map(|song| {
|
||||||
|
let artist = song.artist_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(song.name.clone()),
|
||||||
|
Span::raw(" - ".to_string()),
|
||||||
|
Span::raw(artist),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Queue"))
|
||||||
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
let mut state = ui_data.queue_state.clone();
|
||||||
|
f.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_search(f: &mut Frame, area: Rect, ui_data: &UiData) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Search input
|
||||||
|
Constraint::Min(0), // Search results
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
let search_text = Paragraph::new(ui_data.search_query.clone())
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Search"));
|
||||||
|
f.render_widget(search_text, chunks[0]);
|
||||||
|
|
||||||
|
// Search results
|
||||||
|
let items: Vec<ListItem> = ui_data.search_results
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
// Fixed: Clone the artist string to avoid temporary value issues
|
||||||
|
let artist = item.artist_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
let type_str = match item.item_type.as_str() {
|
||||||
|
"Audio" => "Song",
|
||||||
|
"MusicAlbum" => "Album",
|
||||||
|
_ => &item.item_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed: use Line::from instead of Span::from with Vec
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(format!("[{}] ", type_str)),
|
||||||
|
Span::raw(item.name.clone()),
|
||||||
|
Span::raw(" - ".to_string()),
|
||||||
|
Span::raw(artist),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Results"))
|
||||||
|
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
let mut state = ui_data.search_state.clone();
|
||||||
|
f.render_stateful_widget(list, chunks[1], &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_bar(f: &mut Frame, area: Rect, player: &Player) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(70), // Now playing
|
||||||
|
Constraint::Percentage(30), // Controls help
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Now playing info
|
||||||
|
let now_playing = match player.get_current_item() {
|
||||||
|
Some(item) => {
|
||||||
|
// Fixed: Clone the artist string to avoid temporary value issues
|
||||||
|
let artist = item.artist_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
let state = match player.get_playback_state() {
|
||||||
|
PlaybackState::Playing => "▶",
|
||||||
|
PlaybackState::Paused => "⏸",
|
||||||
|
PlaybackState::Stopped => "⏹",
|
||||||
|
};
|
||||||
|
|
||||||
|
let position = player.get_position();
|
||||||
|
let position_str = format!("{:.0}%", position * 100.0);
|
||||||
|
|
||||||
|
format!("{} {} - {} ({})", state, item.name, artist, position_str)
|
||||||
|
},
|
||||||
|
None => "Not playing".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let playing_text = Paragraph::new(now_playing)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Now Playing"));
|
||||||
|
f.render_widget(playing_text, chunks[0]);
|
||||||
|
|
||||||
|
// Controls help
|
||||||
|
let controls = "Space: Play/Pause | s: Stop | n/p: Next/Prev";
|
||||||
|
let controls_text = Paragraph::new(controls)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Controls"));
|
||||||
|
f.render_widget(controls_text, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame, area: Rect) {
|
||||||
|
// Fixed: use Text::from_iter or convert to proper Text format
|
||||||
|
let help_text = Text::from_iter([
|
||||||
|
"Keyboard Controls:",
|
||||||
|
"",
|
||||||
|
"Tab/Shift+Tab: Switch tabs",
|
||||||
|
"1-5: Switch to specific tab",
|
||||||
|
"Up/Down: Navigate lists",
|
||||||
|
"Enter: Select/Play item",
|
||||||
|
"",
|
||||||
|
"Space: Play/Pause",
|
||||||
|
"s: Stop playback",
|
||||||
|
"n: Next track",
|
||||||
|
"p: Previous track",
|
||||||
|
"Left/Right: Seek backward/forward",
|
||||||
|
"+/-: Adjust volume",
|
||||||
|
"",
|
||||||
|
"In Songs tab:",
|
||||||
|
" a: Add to queue",
|
||||||
|
"",
|
||||||
|
"In Queue tab:",
|
||||||
|
" d: Delete selected item",
|
||||||
|
" c: Clear queue",
|
||||||
|
"",
|
||||||
|
"?: Toggle help",
|
||||||
|
"q: Quit",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let help = Paragraph::new(help_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Help"));
|
||||||
|
|
||||||
|
f.render_widget(help, area);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue