Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29466d70e8 | ||
|
|
abf9ad782c | ||
|
|
28189bae95 |
Generated
+1
-1
@@ -1566,7 +1566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.9"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.9"
|
||||
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:
|
||||
|
||||
+5
-1
@@ -43,6 +43,7 @@ pub enum Action {
|
||||
RemoveFromQueue,
|
||||
ClearQueue,
|
||||
OpenConnectedDevices,
|
||||
OpenListenHistory,
|
||||
GoToRelease,
|
||||
AddToPlaylist,
|
||||
NewPlaylist,
|
||||
@@ -102,7 +103,8 @@ impl Action {
|
||||
| Action::ToggleShuffle
|
||||
| Action::CycleRepeat
|
||||
| Action::ToggleVisualizer
|
||||
| Action::OpenConnectedDevices => Category::Playback,
|
||||
| Action::OpenConnectedDevices
|
||||
| Action::OpenListenHistory => Category::Playback,
|
||||
Action::QueueAddNext
|
||||
| Action::QueueAddLast
|
||||
| Action::DownloadSelected
|
||||
@@ -152,6 +154,7 @@ impl Action {
|
||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||
Action::ClearQueue => Some(":clear"),
|
||||
Action::OpenConnectedDevices => None,
|
||||
Action::OpenListenHistory => None,
|
||||
Action::ToggleHelp => Some(":help"),
|
||||
Action::OpenSearch => Some("/text"),
|
||||
_ => None,
|
||||
@@ -194,6 +197,7 @@ impl Action {
|
||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||
Action::ClearQueue => "Queue: clear".into(),
|
||||
Action::OpenConnectedDevices => "Connected devices…".into(),
|
||||
Action::OpenListenHistory => "Listening history…".into(),
|
||||
Action::GoToRelease => "Open the track's release".into(),
|
||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||
Action::NewPlaylist => "Create a playlist".into(),
|
||||
|
||||
@@ -56,6 +56,7 @@ pub enum AppEvent {
|
||||
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
||||
/// Counts and storage footprint of the local library/database.
|
||||
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
||||
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
|
||||
/// One content id became available locally while the UI is open.
|
||||
LocalContentAvailable {
|
||||
content_id: String,
|
||||
|
||||
+27
-1
@@ -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);
|
||||
}
|
||||
@@ -1332,6 +1337,18 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::LoadListenHistory => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.listen_history(500)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::ListenHistoryLoaded(result));
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
});
|
||||
}
|
||||
Effect::ToggleLikes {
|
||||
track_ids,
|
||||
fed_tracks,
|
||||
@@ -1685,7 +1702,10 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
|
||||
state.player.volume = volume.min(100);
|
||||
save_app_settings(state);
|
||||
}
|
||||
Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged => {}
|
||||
Effect::SetOptions
|
||||
| Effect::RemoveQueueIndices { .. }
|
||||
| Effect::PlaybackQueueChanged
|
||||
| Effect::LoadListenHistory => {}
|
||||
_ => {}
|
||||
}
|
||||
record_control_playback_state(state, runtime, seek);
|
||||
@@ -2831,6 +2851,12 @@ fn handle_playback_command(
|
||||
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::StatusMessage(message) => state.status_message = Some(message),
|
||||
AppEvent::ListenHistoryLoaded(result) => {
|
||||
state.listen_history = Some(match result {
|
||||
Ok(entries) => state::Loadable::Ready(entries),
|
||||
Err(err) => state::Loadable::Failed(err),
|
||||
});
|
||||
}
|
||||
AppEvent::FederationStatus(status) => {
|
||||
state.federation.status = Some(status);
|
||||
}
|
||||
|
||||
@@ -196,9 +196,28 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
Popup::ConnectedDevices { cursor } => {
|
||||
handle_connected_devices(state, runtime, cursor, key);
|
||||
}
|
||||
Popup::ListenHistory { cursor } => handle_listen_history(state, cursor, key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_listen_history(state: &mut AppState, cursor: usize, key: KeyEvent) {
|
||||
let len = match state.listen_history.as_ref() {
|
||||
Some(crate::app::state::Loadable::Ready(entries)) => entries.len(),
|
||||
_ => 0,
|
||||
};
|
||||
let cursor = match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||
KeyCode::Up | KeyCode::Char('k') => cursor.saturating_sub(1),
|
||||
KeyCode::Down | KeyCode::Char('j') => (cursor + 1).min(len.saturating_sub(1)),
|
||||
KeyCode::PageUp => cursor.saturating_sub(10),
|
||||
KeyCode::PageDown => (cursor + 10).min(len.saturating_sub(1)),
|
||||
KeyCode::Home | KeyCode::Char('g') => 0,
|
||||
KeyCode::End | KeyCode::Char('G') => len.saturating_sub(1),
|
||||
_ => cursor,
|
||||
};
|
||||
state.popup = Some(Popup::ListenHistory { cursor });
|
||||
}
|
||||
|
||||
fn handle_federation_status_details(
|
||||
state: &mut AppState,
|
||||
mut parent: FederationStatusPopupState,
|
||||
|
||||
@@ -732,6 +732,8 @@ pub enum Popup {
|
||||
ConfirmDeviceLeave,
|
||||
/// Connected playback devices and their current role/status.
|
||||
ConnectedDevices { cursor: usize },
|
||||
/// Qualified listening history from every trusted device.
|
||||
ListenHistory { cursor: usize },
|
||||
/// Full federation, transport and device status details.
|
||||
FederationStatusDetails {
|
||||
focus: StatusDetailFocus,
|
||||
@@ -1280,6 +1282,7 @@ pub struct AppState {
|
||||
pub likes_loaded: bool,
|
||||
pub local_content_ids_loaded: bool,
|
||||
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
|
||||
pub listen_history: Option<Loadable<Vec<crate::library::ListenHistoryEntry>>>,
|
||||
pub logs: LogsTab,
|
||||
pub queue_tab: QueueTab,
|
||||
pub federation: FederationTab,
|
||||
|
||||
@@ -82,6 +82,8 @@ pub enum Effect {
|
||||
OpenVisualizerEditor {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
/// Load qualified listening history without blocking the UI thread.
|
||||
LoadListenHistory,
|
||||
}
|
||||
|
||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
@@ -114,6 +116,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
|
||||
None
|
||||
}
|
||||
Action::OpenListenHistory => {
|
||||
state.popup = Some(super::state::Popup::ListenHistory { cursor: 0 });
|
||||
state.listen_history = Some(Loadable::Loading);
|
||||
Some(Effect::LoadListenHistory)
|
||||
}
|
||||
Action::NextTab => {
|
||||
switch_tab(state, state.active_tab.next());
|
||||
None
|
||||
|
||||
@@ -16,6 +16,23 @@ fn with_artists(n: usize) -> AppState {
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listening_history_popup_requests_a_background_load() {
|
||||
let mut state = AppState::default();
|
||||
assert_eq!(
|
||||
update(&mut state, Action::OpenListenHistory),
|
||||
Some(Effect::LoadListenHistory)
|
||||
);
|
||||
assert!(matches!(
|
||||
state.popup,
|
||||
Some(crate::app::state::Popup::ListenHistory { cursor: 0 })
|
||||
));
|
||||
assert!(matches!(
|
||||
state.listen_history,
|
||||
Some(crate::app::state::Loadable::Loading)
|
||||
));
|
||||
}
|
||||
|
||||
fn test_track(id: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
id,
|
||||
|
||||
@@ -197,6 +197,10 @@ command = "CycleRepeat"
|
||||
key_sequence = "shift-l"
|
||||
command = "ToggleVisualizer"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-h"
|
||||
command = "OpenListenHistory"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "x"
|
||||
command = "ToggleLike"
|
||||
|
||||
@@ -596,4 +596,13 @@ mod tests {
|
||||
KeyResolution::Action(Action::SeekForward { seconds: 10 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_listening_history_key_resolves() {
|
||||
let mut km = keymap_from(DEFAULT_KEYMAP);
|
||||
assert_eq!(
|
||||
km.resolve(key!(shift - h), KeyContext::Library),
|
||||
KeyResolution::Action(Action::OpenListenHistory)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ use models::{
|
||||
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
||||
const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListenHistoryEntry {
|
||||
pub listen_id: String,
|
||||
pub content_id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub origin_device_id: String,
|
||||
pub started_at_ms: i64,
|
||||
}
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS artists (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -2277,6 +2287,49 @@ impl Library {
|
||||
Ok(inserted > 0)
|
||||
}
|
||||
|
||||
/// Most recent qualified listens, including tracks that are not present
|
||||
/// in this device's local library.
|
||||
pub fn listen_history(&self, limit: usize) -> Result<Vec<ListenHistoryEntry>> {
|
||||
let conn = self.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT listen_id, content_id, origin_device_id, started_at_ms, metadata_json
|
||||
FROM listen_events
|
||||
WHERE qualified = 1
|
||||
ORDER BY started_at_ms DESC, listen_id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([limit.min(i64::MAX as usize) as i64], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(listen_id, content_id, origin_device_id, started_at_ms, metadata_json)| {
|
||||
let metadata: music_dht::device_sync::ListenTrackMetadata =
|
||||
serde_json::from_str(&metadata_json)
|
||||
.context("invalid listen history metadata")?;
|
||||
let mut artists = metadata.artist_names;
|
||||
artists.extend(metadata.featured_artist_names);
|
||||
Ok(ListenHistoryEntry {
|
||||
listen_id,
|
||||
content_id,
|
||||
title: metadata.title,
|
||||
artist: artists.join(", "),
|
||||
origin_device_id,
|
||||
started_at_ms,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Editing & deleting
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -526,4 +526,31 @@ fn history_counts_completed_plays() {
|
||||
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
|
||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||
assert_eq!(track.play_count, 1);
|
||||
let history = lib.listen_history(20).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].listen_id, "listen-1");
|
||||
assert_eq!(history[0].title, "Song");
|
||||
assert_eq!(history[0].artist, "Artist");
|
||||
assert_eq!(history[0].origin_device_id, "device-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
|
||||
let lib = test_library();
|
||||
let event = music_dht::device_sync::ListenEvent {
|
||||
listen_id: "remote-listen".to_string(),
|
||||
content_id: format!("b3:{}", "a".repeat(64)),
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
listened_ms: 10_000,
|
||||
track_duration_ms: Some(120_000),
|
||||
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
|
||||
track: music_dht::device_sync::ListenTrackMetadata {
|
||||
title: "Remote song".to_string(),
|
||||
artist_names: vec!["Remote artist".to_string()],
|
||||
featured_artist_names: vec!["Guest".to_string()],
|
||||
release_title: None,
|
||||
},
|
||||
};
|
||||
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
|
||||
assert!(lib.listen_history(20).unwrap().is_empty());
|
||||
}
|
||||
|
||||
+30
-1
@@ -7,6 +7,7 @@ mod library;
|
||||
mod media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod status;
|
||||
mod streaming;
|
||||
mod ui;
|
||||
mod visualizer;
|
||||
@@ -19,11 +20,39 @@ use crossterm::event::{
|
||||
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<()> {
|
||||
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"));
|
||||
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");
|
||||
}
|
||||
}
|
||||
+112
-1
@@ -1,7 +1,7 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use super::theme;
|
||||
@@ -88,10 +88,121 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state),
|
||||
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
||||
Some(Popup::ListenHistory { cursor }) => draw_listen_history(frame, state, *cursor),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let area = centered(
|
||||
frame.area(),
|
||||
100,
|
||||
frame.area().height.saturating_sub(4).clamp(10, 30),
|
||||
);
|
||||
let block = Block::bordered()
|
||||
.title(" Listening history ")
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::strong_border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||
|
||||
match state.listen_history.as_ref() {
|
||||
Some(Loadable::Loading) | None => {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{} Loading history…", state.spinner()))
|
||||
.alignment(Alignment::Center),
|
||||
body,
|
||||
);
|
||||
}
|
||||
Some(Loadable::Failed(err)) => {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("History unavailable: {err}"))
|
||||
.style(theme::dim())
|
||||
.wrap(Wrap { trim: true }),
|
||||
body,
|
||||
);
|
||||
}
|
||||
Some(Loadable::Ready(entries)) if entries.is_empty() => {
|
||||
frame.render_widget(
|
||||
Paragraph::new("No qualified listens yet.")
|
||||
.style(theme::dim())
|
||||
.alignment(Alignment::Center),
|
||||
body,
|
||||
);
|
||||
}
|
||||
Some(Loadable::Ready(entries)) => {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or_default();
|
||||
let rows = entries.iter().map(|entry| {
|
||||
Row::new(vec![
|
||||
Cell::from(entry.title.clone()),
|
||||
Cell::from(entry.artist.clone()),
|
||||
Cell::from(relative_listen_time(entry.started_at_ms, now_ms)),
|
||||
Cell::from(history_device_name(state, &entry.origin_device_id)),
|
||||
])
|
||||
});
|
||||
let mut table_state =
|
||||
TableState::default().with_selected(cursor.min(entries.len() - 1));
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(28),
|
||||
Constraint::Length(12),
|
||||
Constraint::Percentage(26),
|
||||
],
|
||||
)
|
||||
.header(Row::new(["Track", "Artist", "When", "Device"]).style(theme::header_for(state)))
|
||||
.row_highlight_style(theme::selection_for(state))
|
||||
.highlight_symbol("› ");
|
||||
frame.render_stateful_widget(table, body, &mut table_state);
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new("j/k scroll · pgup/pgdn page · esc close")
|
||||
.style(theme::dim())
|
||||
.alignment(Alignment::Center),
|
||||
footer,
|
||||
);
|
||||
}
|
||||
|
||||
fn history_device_name(state: &AppState, device_id: &str) -> String {
|
||||
state
|
||||
.federation
|
||||
.devices
|
||||
.as_ref()
|
||||
.and_then(|status| {
|
||||
status
|
||||
.devices
|
||||
.iter()
|
||||
.find(|device| device.device_id == device_id)
|
||||
.map(|device| device.name.clone())
|
||||
})
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if device_id == state.device_playback.self_device_id {
|
||||
state.device_playback.self_device_name.clone()
|
||||
} else {
|
||||
device_id.chars().take(10).collect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn relative_listen_time(started_at_ms: i64, now_ms: i64) -> String {
|
||||
let elapsed = now_ms.saturating_sub(started_at_ms).max(0) / 1_000;
|
||||
match elapsed {
|
||||
0..=59 => "now".to_string(),
|
||||
60..=3_599 => format!("{}m ago", elapsed / 60),
|
||||
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
|
||||
86_400..=604_799 => format!("{}d ago", elapsed / 86_400),
|
||||
_ => format!("{}w ago", elapsed / 604_800),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_federation_status_details(
|
||||
frame: &mut Frame,
|
||||
state: &AppState,
|
||||
|
||||
Reference in New Issue
Block a user