aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-06-07 21:19:46 +0300
committersalmonik <raisersoft@proton.me>2026-06-07 21:19:46 +0300
commit45a1728ea4a3812120728562084fd416b35b074c (patch)
treeae2f74cd08e97102ff1dda4c3c5006bde15d7bea
parentMerge pull request 'autocompletion' (#1) from autocompletion into main (diff)
downloadrsplayer-45a1728ea4a3812120728562084fd416b35b074c.tar.gz
rsplayer-45a1728ea4a3812120728562084fd416b35b074c.zip
Add lyrics support
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--src/audio.rs23
-rw-r--r--src/cli.rs5
-rw-r--r--src/daemon.rs126
5 files changed, 143 insertions, 15 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 846ed82..2c284f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1948,7 +1948,7 @@ dependencies = [
1948 1948
1949[[package]] 1949[[package]]
1950name = "rsp" 1950name = "rsp"
1951version = "0.8.4" 1951version = "0.8.5"
1952dependencies = [ 1952dependencies = [
1953 "clap", 1953 "clap",
1954 "clap_complete", 1954 "clap_complete",
diff --git a/Cargo.toml b/Cargo.toml
index 8710c51..d40a19b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "rsp" 2name = "rsp"
3version = "0.8.4" 3version = "0.8.5"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "GPL-3.0 or later"
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 {
122 pub current_track: Arc<Mutex<Option<std::path::PathBuf>>>, 122 pub current_track: Arc<Mutex<Option<std::path::PathBuf>>>,
123 track_ended: Arc<AtomicBool>, 123 track_ended: Arc<AtomicBool>,
124 volume: Arc<Mutex<f32>>, 124 volume: Arc<Mutex<f32>>,
125 position: Arc<Mutex<f64>>,
125} 126}
126 127
127#[allow(clippy::new_without_default)] 128#[allow(clippy::new_without_default)]
@@ -138,6 +139,9 @@ impl AudioEngine {
138 let volume = Arc::new(Mutex::new(1.0f32)); 139 let volume = Arc::new(Mutex::new(1.0f32));
139 let volume_clone = Arc::clone(&volume); 140 let volume_clone = Arc::clone(&volume);
140 141
142 let position = Arc::new(Mutex::new(0.0));
143 let position_clone = Arc::clone(&position);
144
141 thread::spawn(move || { 145 thread::spawn(move || {
142 audio_thread( 146 audio_thread(
143 rx, 147 rx,
@@ -145,6 +149,7 @@ impl AudioEngine {
145 current_track_clone, 149 current_track_clone,
146 volume_clone, 150 volume_clone,
147 track_ended_clone, 151 track_ended_clone,
152 position_clone,
148 ); 153 );
149 }); 154 });
150 155
@@ -154,6 +159,7 @@ impl AudioEngine {
154 current_track, 159 current_track,
155 track_ended, 160 track_ended,
156 volume, 161 volume,
162 position,
157 } 163 }
158 } 164 }
159 165
@@ -199,6 +205,10 @@ impl AudioEngine {
199 pub fn poll_track_ended(&self) -> bool { 205 pub fn poll_track_ended(&self) -> bool {
200 self.track_ended.swap(false, Ordering::SeqCst) 206 self.track_ended.swap(false, Ordering::SeqCst)
201 } 207 }
208
209 pub fn position(&self) -> f64 {
210 self.position.lock().map(|p| *p).unwrap_or(0.0)
211 }
202} 212}
203 213
204struct AudioData { 214struct AudioData {
@@ -245,6 +255,7 @@ fn audio_thread(
245 current_track: Arc<Mutex<Option<std::path::PathBuf>>>, 255 current_track: Arc<Mutex<Option<std::path::PathBuf>>>,
246 volume: Arc<Mutex<f32>>, 256 volume: Arc<Mutex<f32>>,
247 track_ended: Arc<AtomicBool>, 257 track_ended: Arc<AtomicBool>,
258 position: Arc<Mutex<f64>>,
248) { 259) {
249 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 260 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
250 261
@@ -282,6 +293,9 @@ fn audio_thread(
282 if let Ok(mut track) = current_track.lock() { 293 if let Ok(mut track) = current_track.lock() {
283 *track = Some(path.clone()); 294 *track = Some(path.clone());
284 } 295 }
296 if let Ok(mut pos) = position.lock() {
297 *pos = 0.0;
298 }
285 299
286 // Drop the current stream before creating a new one 300 // Drop the current stream before creating a new one
287 current_stream = None; 301 current_stream = None;
@@ -334,6 +348,7 @@ fn audio_thread(
334 let vol = Arc::clone(&volume); 348 let vol = Arc::clone(&volume);
335 let ended = Arc::clone(&track_ended); 349 let ended = Arc::clone(&track_ended);
336 let is_playing_clone = Arc::clone(&is_playing); 350 let is_playing_clone = Arc::clone(&is_playing);
351 let position_clone = Arc::clone(&position);
337 352
338 if let Ok(mut d) = audio_data.lock() { 353 if let Ok(mut d) = audio_data.lock() {
339 *d = Some(data); 354 *d = Some(data);
@@ -434,6 +449,14 @@ fn audio_thread(
434 *sample = 0.0; 449 *sample = 0.0;
435 } 450 }
436 451
452 // Track playback position
453 if offset > 0
454 && let Ok(mut pos) = position_clone.lock()
455 {
456 *pos += offset as f64
457 / (device_sample_rate as f64 * channels as f64);
458 }
459
437 // If no more data and offset is 0, track ended 460 // If no more data and offset is 0, track ended
438 if offset == 0 && state.sample_buffer.is_empty() { 461 if offset == 0 && state.sample_buffer.is_empty() {
439 ended.store(true, Ordering::SeqCst); 462 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 {
99 #[arg(value_name = "QUERY")] 99 #[arg(value_name = "QUERY")]
100 query: String, 100 query: String,
101 }, 101 },
102 /// display lyrics
103 Lyrics,
102} 104}
103 105
104#[derive(Subcommand)] 106#[derive(Subcommand)]
@@ -320,6 +322,9 @@ impl Cli {
320 } 322 }
321 } 323 }
322 } 324 }
325 Commands::Lyrics => {
326 let _ = crate::daemon::send_command("lyrics".to_string());
327 }
323 } 328 }
324 Ok(()) 329 Ok(())
325 } 330 }
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};
15use std::path::{Path, PathBuf}; 15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicBool, Ordering}; 16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex, RwLock}; 17use std::sync::{Arc, Mutex, RwLock};
18use std::thread;
19use std::time::{Duration, Instant, SystemTime}; 18use std::time::{Duration, Instant, SystemTime};
19use std::{fs, thread};
20 20
21pub struct ScrobbleState { 21pub struct ScrobbleState {
22 pub track: Option<Track>, 22 pub track: Option<Track>,
@@ -419,9 +419,19 @@ fn send_command_raw(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
419 let mut stream = UnixStream::connect(socket_path)?; 419 let mut stream = UnixStream::connect(socket_path)?;
420 stream.write_all(cmd.as_bytes())?; 420 stream.write_all(cmd.as_bytes())?;
421 421
422 let mut response = String::new(); 422 let mut buf = [0u8; 4096];
423 stream.read_to_string(&mut response)?; 423 loop {
424 print!("{}", response); 424 match stream.read(&mut buf) {
425 Ok(0) => break,
426 Ok(n) => {
427 if let Ok(s) = std::str::from_utf8(&buf[..n]) {
428 print!("{}", s);
429 }
430 }
431 Err(_) => break,
432 }
433 }
434 let _ = std::io::stdout().flush();
425 435
426 Ok(()) 436 Ok(())
427} 437}
@@ -437,15 +447,104 @@ fn handle_client(
437 let mut buffer = [0; 1024]; 447 let mut buffer = [0; 1024];
438 if let Ok(size) = stream.read(&mut buffer) { 448 if let Ok(size) = stream.read(&mut buffer) {
439 let cmd = String::from_utf8_lossy(&buffer[..size]); 449 let cmd = String::from_utf8_lossy(&buffer[..size]);
440 let response = process_command( 450 let trimmed = cmd.trim();
441 cmd.trim(), 451 if trimmed == "lyrics" {
442 &audio, 452 handle_lyrics_stream(&mut stream, &audio);
443 &shutdown_flag, 453 } else {
444 &queue, 454 let response = process_command(
445 &scrobbler, 455 trimmed,
446 &scrobble_state, 456 &audio,
447 ); 457 &shutdown_flag,
448 let _ = stream.write_all(response.as_bytes()); 458 &queue,
459 &scrobbler,
460 &scrobble_state,
461 );
462 let _ = stream.write_all(response.as_bytes());
463 }
464 }
465}
466
467struct LrcLine {
468 time: f64,
469 text: String,
470}
471
472fn parse_lrc(content: &str) -> Vec<LrcLine> {
473 let mut lines: Vec<LrcLine> = Vec::new();
474 for line in content.lines() {
475 let line = line.trim();
476 if line.is_empty() {
477 continue;
478 }
479 if let Some(rest) = line.strip_prefix('[')
480 && let Some(idx) = rest.find(']')
481 {
482 let ts = &rest[..idx];
483 let text = rest[idx + 1..].trim().to_string();
484 if let Some(secs) = parse_lrc_timestamp(ts) {
485 lines.push(LrcLine { time: secs, text });
486 }
487 }
488 }
489 lines.sort_by(|a, b| {
490 a.time
491 .partial_cmp(&b.time)
492 .unwrap_or(std::cmp::Ordering::Equal)
493 });
494 lines
495}
496
497fn parse_lrc_timestamp(ts: &str) -> Option<f64> {
498 let ts = ts.trim();
499 if let Some((mm, rest)) = ts.split_once(':') {
500 let mm: f64 = mm.parse().ok()?;
501 let ss: f64 = rest.parse().ok()?;
502 Some(mm * 60.0 + ss)
503 } else {
504 None
505 }
506}
507
508fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) {
509 let track_path = match audio.current_track() {
510 Some(p) => p,
511 None => return,
512 };
513 let mut lrc_path = PathBuf::from(&track_path);
514 if !lrc_path.set_extension("lrc") || !lrc_path.exists() {
515 return;
516 }
517 let content = match fs::read_to_string(&lrc_path) {
518 Ok(c) => c,
519 Err(_) => return,
520 };
521 let lyrics = parse_lrc(&content);
522 if lyrics.is_empty() {
523 return;
524 }
525
526 loop {
527 let pos = audio.position();
528 let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0);
529 let start = current_idx.saturating_sub(1);
530 let end = (current_idx + 3).min(lyrics.len());
531
532 let mut output = String::new();
533 output.push_str("\x1b[2J\x1b[H");
534 for (idx, l) in lyrics.iter().enumerate().take(end).skip(start) {
535 if idx == current_idx {
536 output.push_str(&format!(" > {}\n", l.text));
537 } else {
538 output.push_str(&format!(" {}\n", l.text));
539 }
540 }
541 if stream.write_all(output.as_bytes()).is_err() {
542 break;
543 }
544 if stream.flush().is_err() {
545 break;
546 }
547 thread::sleep(Duration::from_millis(200));
449 } 548 }
450} 549}
451 550
@@ -592,6 +691,7 @@ fn process_command(
592 "Error accessing queue\n".to_string() 691 "Error accessing queue\n".to_string()
593 } 692 }
594 } 693 }
694
595 "shuffle" => { 695 "shuffle" => {
596 if let Ok(q) = queue.lock() { 696 if let Ok(q) = queue.lock() {
597 match q.shuffle_mode() { 697 match q.shuffle_mode() {