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