Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29466d70e8 | ||
|
|
abf9ad782c |
Generated
+1
-1
@@ -1566,7 +1566,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
description = "A federated P2P player for personal music libraries"
|
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
|
Federation, trusted-device pairing, and key bindings are configured directly
|
||||||
inside the player.
|
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
|
## Architecture
|
||||||
|
|
||||||
Furumi is a Rust application built with:
|
Furumi is a Rust application built with:
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub struct Runtime {
|
|||||||
pub player_start_pending: bool,
|
pub player_start_pending: bool,
|
||||||
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
||||||
pub last_media_push: Option<std::time::Instant>,
|
pub last_media_push: Option<std::time::Instant>,
|
||||||
|
pub status_publisher: crate::status::Publisher,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -286,6 +287,7 @@ pub async fn run(
|
|||||||
player_start_pending: false,
|
player_start_pending: false,
|
||||||
media_tx,
|
media_tx,
|
||||||
last_media_push: None,
|
last_media_push: None,
|
||||||
|
status_publisher: crate::status::Publisher::spawn(),
|
||||||
};
|
};
|
||||||
spawn_content_id_backfill(&runtime);
|
spawn_content_id_backfill(&runtime);
|
||||||
|
|
||||||
@@ -325,6 +327,9 @@ pub async fn run(
|
|||||||
state.advance_spinner();
|
state.advance_spinner();
|
||||||
expire_quit_confirmation(&mut state);
|
expire_quit_confirmation(&mut state);
|
||||||
sync_player_shared(&mut state, &runtime);
|
sync_player_shared(&mut state, &runtime);
|
||||||
|
runtime
|
||||||
|
.status_publisher
|
||||||
|
.publish(crate::status::PlaybackStatus::from_player(&state.player));
|
||||||
maybe_prefetch_next(&mut state, &runtime);
|
maybe_prefetch_next(&mut state, &runtime);
|
||||||
push_media_update(&state, &mut runtime, false);
|
push_media_update(&state, &mut runtime, false);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -7,6 +7,7 @@ mod library;
|
|||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
mod share;
|
mod share;
|
||||||
|
mod status;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod visualizer;
|
mod visualizer;
|
||||||
@@ -19,11 +20,39 @@ use crossterm::event::{
|
|||||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HELP: &str = "\
|
||||||
|
Furumi — federated terminal music player
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
furumi [OPTION]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Show this help
|
||||||
|
-V, --version Show version
|
||||||
|
--status Print a one-line now-playing status
|
||||||
|
--status-json
|
||||||
|
Print now-playing status as JSON
|
||||||
|
|
||||||
|
tmux:
|
||||||
|
set -g status-right '#(furumi --status)'
|
||||||
|
";
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
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 == "--help" || arg == "-h") {
|
||||||
|
print!("{HELP}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
|
||||||
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
println!("furumi {}", env!("CARGO_PKG_VERSION"));
|
||||||
return Ok(());
|
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;
|
let mut startup_warning = None;
|
||||||
if let Err(err) = config::logging::init() {
|
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