Files
furumi_tui/src/status.rs
T

191 lines
5.1 KiB
Rust
Raw Normal View History

2026-07-28 11:16:38 +01:00
//! 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");
}
}