From 45a1728ea4a3812120728562084fd416b35b074c Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 7 Jun 2026 21:19:46 +0300 Subject: Add lyrics support --- src/audio.rs | 23 +++++++++++ src/cli.rs | 5 +++ src/daemon.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 141 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/audio.rs b/src/audio.rs index 6da47c3..f8eba04 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -122,6 +122,7 @@ pub struct AudioEngine { pub current_track: Arc>>, track_ended: Arc, volume: Arc>, + position: Arc>, } #[allow(clippy::new_without_default)] @@ -138,6 +139,9 @@ impl AudioEngine { let volume = Arc::new(Mutex::new(1.0f32)); let volume_clone = Arc::clone(&volume); + let position = Arc::new(Mutex::new(0.0)); + let position_clone = Arc::clone(&position); + thread::spawn(move || { audio_thread( rx, @@ -145,6 +149,7 @@ impl AudioEngine { current_track_clone, volume_clone, track_ended_clone, + position_clone, ); }); @@ -154,6 +159,7 @@ impl AudioEngine { current_track, track_ended, volume, + position, } } @@ -199,6 +205,10 @@ impl AudioEngine { pub fn poll_track_ended(&self) -> bool { self.track_ended.swap(false, Ordering::SeqCst) } + + pub fn position(&self) -> f64 { + self.position.lock().map(|p| *p).unwrap_or(0.0) + } } struct AudioData { @@ -245,6 +255,7 @@ fn audio_thread( current_track: Arc>>, volume: Arc>, track_ended: Arc, + position: Arc>, ) { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -282,6 +293,9 @@ fn audio_thread( if let Ok(mut track) = current_track.lock() { *track = Some(path.clone()); } + if let Ok(mut pos) = position.lock() { + *pos = 0.0; + } // Drop the current stream before creating a new one current_stream = None; @@ -334,6 +348,7 @@ fn audio_thread( let vol = Arc::clone(&volume); let ended = Arc::clone(&track_ended); let is_playing_clone = Arc::clone(&is_playing); + let position_clone = Arc::clone(&position); if let Ok(mut d) = audio_data.lock() { *d = Some(data); @@ -434,6 +449,14 @@ fn audio_thread( *sample = 0.0; } + // Track playback position + if offset > 0 + && let Ok(mut pos) = position_clone.lock() + { + *pos += offset as f64 + / (device_sample_rate as f64 * channels as f64); + } + // If no more data and offset is 0, track ended if offset == 0 && state.sample_buffer.is_empty() { ended.store(true, Ordering::SeqCst); diff --git a/src/cli.rs b/src/cli.rs index e4bb484..37b0825 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,6 +99,8 @@ pub enum Commands { #[arg(value_name = "QUERY")] query: String, }, + /// display lyrics + Lyrics, } #[derive(Subcommand)] @@ -320,6 +322,9 @@ impl Cli { } } } + Commands::Lyrics => { + let _ = crate::daemon::send_command("lyrics".to_string()); + } } Ok(()) } diff --git a/src/daemon.rs b/src/daemon.rs index 5ad6654..0590f71 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -15,8 +15,8 @@ use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -use std::thread; use std::time::{Duration, Instant, SystemTime}; +use std::{fs, thread}; pub struct ScrobbleState { pub track: Option, @@ -419,9 +419,19 @@ fn send_command_raw(cmd: String) -> Result<(), Box> { let mut stream = UnixStream::connect(socket_path)?; stream.write_all(cmd.as_bytes())?; - let mut response = String::new(); - stream.read_to_string(&mut response)?; - print!("{}", response); + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if let Ok(s) = std::str::from_utf8(&buf[..n]) { + print!("{}", s); + } + } + Err(_) => break, + } + } + let _ = std::io::stdout().flush(); Ok(()) } @@ -437,15 +447,104 @@ fn handle_client( let mut buffer = [0; 1024]; if let Ok(size) = stream.read(&mut buffer) { let cmd = String::from_utf8_lossy(&buffer[..size]); - let response = process_command( - cmd.trim(), - &audio, - &shutdown_flag, - &queue, - &scrobbler, - &scrobble_state, - ); - let _ = stream.write_all(response.as_bytes()); + let trimmed = cmd.trim(); + if trimmed == "lyrics" { + handle_lyrics_stream(&mut stream, &audio); + } else { + let response = process_command( + trimmed, + &audio, + &shutdown_flag, + &queue, + &scrobbler, + &scrobble_state, + ); + let _ = stream.write_all(response.as_bytes()); + } + } +} + +struct LrcLine { + time: f64, + text: String, +} + +fn parse_lrc(content: &str) -> Vec { + let mut lines: Vec = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix('[') + && let Some(idx) = rest.find(']') + { + let ts = &rest[..idx]; + let text = rest[idx + 1..].trim().to_string(); + if let Some(secs) = parse_lrc_timestamp(ts) { + lines.push(LrcLine { time: secs, text }); + } + } + } + lines.sort_by(|a, b| { + a.time + .partial_cmp(&b.time) + .unwrap_or(std::cmp::Ordering::Equal) + }); + lines +} + +fn parse_lrc_timestamp(ts: &str) -> Option { + let ts = ts.trim(); + if let Some((mm, rest)) = ts.split_once(':') { + let mm: f64 = mm.parse().ok()?; + let ss: f64 = rest.parse().ok()?; + Some(mm * 60.0 + ss) + } else { + None + } +} + +fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { + let track_path = match audio.current_track() { + Some(p) => p, + None => return, + }; + let mut lrc_path = PathBuf::from(&track_path); + if !lrc_path.set_extension("lrc") || !lrc_path.exists() { + return; + } + let content = match fs::read_to_string(&lrc_path) { + Ok(c) => c, + Err(_) => return, + }; + let lyrics = parse_lrc(&content); + if lyrics.is_empty() { + return; + } + + loop { + let pos = audio.position(); + let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0); + let start = current_idx.saturating_sub(1); + let end = (current_idx + 3).min(lyrics.len()); + + let mut output = String::new(); + output.push_str("\x1b[2J\x1b[H"); + for (idx, l) in lyrics.iter().enumerate().take(end).skip(start) { + if idx == current_idx { + output.push_str(&format!(" > {}\n", l.text)); + } else { + output.push_str(&format!(" {}\n", l.text)); + } + } + if stream.write_all(output.as_bytes()).is_err() { + break; + } + if stream.flush().is_err() { + break; + } + thread::sleep(Duration::from_millis(200)); } } @@ -592,6 +691,7 @@ fn process_command( "Error accessing queue\n".to_string() } } + "shuffle" => { if let Ok(q) = queue.lock() { match q.shuffle_mode() { -- cgit v1.2.3