Added --status to get simple status
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user