diff options
| author | salmonik <raisersoft@proton.me> | 2026-06-07 21:19:46 +0300 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-06-07 21:19:46 +0300 |
| commit | 45a1728ea4a3812120728562084fd416b35b074c (patch) | |
| tree | ae2f74cd08e97102ff1dda4c3c5006bde15d7bea | |
| parent | Merge pull request 'autocompletion' (#1) from autocompletion into main (diff) | |
| download | rsplayer-45a1728ea4a3812120728562084fd416b35b074c.tar.gz rsplayer-45a1728ea4a3812120728562084fd416b35b074c.zip | |
Add lyrics support
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/audio.rs | 23 | ||||
| -rw-r--r-- | src/cli.rs | 5 | ||||
| -rw-r--r-- | src/daemon.rs | 126 |
5 files changed, 143 insertions, 15 deletions
| @@ -1948,7 +1948,7 @@ dependencies = [ | |||
| 1948 | 1948 | ||
| 1949 | [[package]] | 1949 | [[package]] |
| 1950 | name = "rsp" | 1950 | name = "rsp" |
| 1951 | version = "0.8.4" | 1951 | version = "0.8.5" |
| 1952 | dependencies = [ | 1952 | dependencies = [ |
| 1953 | "clap", | 1953 | "clap", |
| 1954 | "clap_complete", | 1954 | "clap_complete", |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "rsp" | 2 | name = "rsp" |
| 3 | version = "0.8.4" | 3 | version = "0.8.5" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | description = "Rust music player daemon with Unix socket IPC" | 5 | description = "Rust music player daemon with Unix socket IPC" |
| 6 | license = "GPL-3.0 or later" | 6 | license = "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 | ||
| 204 | struct AudioData { | 214 | struct 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); |
| @@ -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}; | |||
| 15 | use std::path::{Path, PathBuf}; | 15 | use std::path::{Path, PathBuf}; |
| 16 | use std::sync::atomic::{AtomicBool, Ordering}; | 16 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 17 | use std::sync::{Arc, Mutex, RwLock}; | 17 | use std::sync::{Arc, Mutex, RwLock}; |
| 18 | use std::thread; | ||
| 19 | use std::time::{Duration, Instant, SystemTime}; | 18 | use std::time::{Duration, Instant, SystemTime}; |
| 19 | use std::{fs, thread}; | ||
| 20 | 20 | ||
| 21 | pub struct ScrobbleState { | 21 | pub 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 | |||
| 467 | struct LrcLine { | ||
| 468 | time: f64, | ||
| 469 | text: String, | ||
| 470 | } | ||
| 471 | |||
| 472 | fn 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 | |||
| 497 | fn 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 | |||
| 508 | fn 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() { |
