From abf9ad782c7cfca71afc48a46640cbd7e9469977 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 28 Jul 2026 11:16:38 +0100 Subject: [PATCH] Added --status to get simple status --- Cargo.toml | 2 +- README.md | 23 ++++++ src/app/mod.rs | 5 ++ src/main.rs | 10 ++- src/status.rs | 190 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/status.rs diff --git a/Cargo.toml b/Cargo.toml index 5189b69..26dfc40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.97" description = "A federated P2P player for personal music libraries" diff --git a/README.md b/README.md index a9915ec..399c0ea 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,29 @@ Import a music directory from Furumi's command line: Federation, trusted-device pairing, and key bindings are configured directly inside the player. +### Now playing in tmux + +While Furumi is running, a second invocation can print a cheap, single-line +playback snapshot without opening the TUI or library: + +```bash +furumi --status +# ▶ Artist — Track 1:23/4:05 +``` + +For example, add this to `.tmux.conf`: + +```tmux +set -g status-interval 1 +set -g status-right '#(furumi --status) | %H:%M' +``` + +`furumi --status-json` returns the same snapshot as JSON, including playback +state, title, artist, album, position, duration, and volume. Both commands +print nothing when Furumi is stopped or no track is loaded. On Linux, Furumi +also exposes the existing MPRIS player `cy.hexor.furumi`, which can be queried +with tools such as `playerctl`. + ## Architecture Furumi is a Rust application built with: diff --git a/src/app/mod.rs b/src/app/mod.rs index a032b96..cf17749 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,6 +66,7 @@ pub struct Runtime { pub player_start_pending: bool, pub media_tx: std::sync::mpsc::Sender, pub last_media_push: Option, + pub status_publisher: crate::status::Publisher, } #[derive(Debug, Clone, Copy)] @@ -286,6 +287,7 @@ pub async fn run( player_start_pending: false, media_tx, last_media_push: None, + status_publisher: crate::status::Publisher::spawn(), }; spawn_content_id_backfill(&runtime); @@ -325,6 +327,9 @@ pub async fn run( state.advance_spinner(); expire_quit_confirmation(&mut state); sync_player_shared(&mut state, &runtime); + runtime + .status_publisher + .publish(crate::status::PlaybackStatus::from_player(&state.player)); maybe_prefetch_next(&mut state, &runtime); push_media_update(&state, &mut runtime, false); } diff --git a/src/main.rs b/src/main.rs index 8f2b6eb..f01bc80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod library; mod media; mod player; mod share; +mod status; mod streaming; mod ui; mod visualizer; @@ -20,10 +21,17 @@ use crossterm::event::{ }; fn main() -> Result<()> { - if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") { + let args: Vec<_> = std::env::args_os().collect(); + if args.iter().any(|arg| arg == "--version" || arg == "-V") { println!("furumi {}", env!("CARGO_PKG_VERSION")); return Ok(()); } + if args + .iter() + .any(|arg| arg == "--status" || arg == "--status-json") + { + return status::print(args.iter().any(|arg| arg == "--status-json")); + } let mut startup_warning = None; if let Err(err) = config::logging::init() { diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..9e56cf6 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,190 @@ +//! Cheap now-playing export for status bars and other polling clients. +//! +//! The UI only sends snapshots through a bounded channel. A dedicated thread +//! performs the filesystem writes so a slow filesystem cannot stall drawing +//! or input handling. + +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::mpsc::{self, SyncSender, TrySendError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::app::state::PlayerBar; + +const STALE_AFTER_MS: u64 = 5_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PlaybackStatus { + pub playing: bool, + pub paused: bool, + pub title: String, + pub artist: String, + pub album: String, + pub position_secs: f64, + pub duration_secs: f64, + pub volume: u8, + pub updated_at_ms: u64, +} + +impl PlaybackStatus { + pub fn from_player(player: &PlayerBar) -> Option { + let track = player.current.as_ref()?; + player.playing.then(|| Self { + playing: true, + paused: player.paused, + title: track.title.clone(), + artist: track.artist_line(), + album: track.release_title.clone(), + position_secs: player.position_secs.max(0.0), + duration_secs: track.duration_seconds.max(0.0), + volume: player.volume, + updated_at_ms: now_ms(), + }) + } + + pub fn one_line(&self) -> String { + let icon = if self.paused { "⏸" } else { "▶" }; + let track = if self.artist.is_empty() { + self.title.clone() + } else { + format!("{} — {}", self.artist, self.title) + }; + format!( + "{icon} {track} {}/{}", + duration(self.position_secs), + duration(self.duration_secs) + ) + } +} + +pub struct Publisher { + tx: Option>>, +} + +impl Publisher { + pub fn spawn() -> Self { + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("playback-status".to_string()) + .spawn(move || { + while let Ok(snapshot) = rx.recv() { + if let Some(snapshot) = snapshot { + if let Err(err) = write(&snapshot) { + tracing::debug!(?err, "writing playback status failed"); + } + } else { + remove(); + } + } + remove(); + }) + .ok(); + Self { tx: Some(tx) } + } + + pub fn publish(&self, snapshot: Option) { + let Some(tx) = &self.tx else { return }; + match tx.try_send(snapshot) { + Ok(()) | Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => {} + } + } +} + +impl Drop for Publisher { + fn drop(&mut self) { + self.tx.take(); + } +} + +pub fn print(json: bool) -> anyhow::Result<()> { + let Some(status) = read()? else { + return Ok(()); + }; + if json { + println!("{}", serde_json::to_string(&status)?); + } else { + println!("{}", status.one_line()); + } + Ok(()) +} + +fn read() -> anyhow::Result> { + let Some(path) = path() else { + return Ok(None); + }; + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let status: PlaybackStatus = serde_json::from_slice(&bytes)?; + if now_ms().saturating_sub(status.updated_at_ms) > STALE_AFTER_MS { + return Ok(None); + } + Ok(Some(status)) +} + +fn write(status: &PlaybackStatus) -> anyhow::Result<()> { + let Some(path) = path() else { + return Ok(()); + }; + let Some(dir) = path.parent() else { + return Ok(()); + }; + fs::create_dir_all(dir)?; + let temporary = dir.join("playback-status.tmp"); + fs::write(&temporary, serde_json::to_vec(status)?)?; + #[cfg(windows)] + if path.exists() { + let _ = fs::remove_file(&path); + } + fs::rename(temporary, path)?; + Ok(()) +} + +fn remove() { + if let Some(path) = path() { + let _ = fs::remove_file(path); + } +} + +fn path() -> Option { + crate::config::project_dirs().map(|dirs| dirs.cache_dir().join("playback-status.json")) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn duration(seconds: f64) -> String { + let total = seconds.max(0.0).round() as u64; + format!("{}:{:02}", total / 60, total % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_line_contains_state_metadata_and_progress() { + let status = PlaybackStatus { + playing: true, + paused: true, + title: "Track".into(), + artist: "Artist".into(), + album: "Release".into(), + position_secs: 83.0, + duration_secs: 245.0, + volume: 80, + updated_at_ms: 0, + }; + assert_eq!(status.one_line(), "⏸ Artist — Track 1:23/4:05"); + } +}