Added --status to get simple status

This commit is contained in:
Ultradesu
2026-07-28 11:16:38 +01:00
parent 28189bae95
commit abf9ad782c
5 changed files with 228 additions and 2 deletions
+1 -1
View File
@@ -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"
+23
View File
@@ -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:
+5
View File
@@ -66,6 +66,7 @@ pub struct Runtime {
pub player_start_pending: bool,
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
pub last_media_push: Option<std::time::Instant>,
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);
}
+9 -1
View File
@@ -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() {
+190
View File
@@ -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<Self> {
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<SyncSender<Option<PlaybackStatus>>>,
}
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<PlaybackStatus>) {
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<Option<PlaybackStatus>> {
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<PathBuf> {
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");
}
}