Initial Commit

This commit is contained in:
t.bassi 2025-06-20 15:45:52 +02:00
parent 01258c9b03
commit 725252f244
10 changed files with 5185 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2905
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}