Added playback history window

This commit is contained in:
Ultradesu
2026-07-27 23:37:46 +01:00
parent a7f41ff205
commit 28189bae95
14 changed files with 281 additions and 5 deletions
Generated
+1 -1
View File
@@ -1566,7 +1566,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.1.9"
version = "0.2.0"
dependencies = [
"anyhow",
"blake3",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumi_tui"
version = "0.1.9"
version = "0.2.0"
edition = "2024"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
+5 -1
View File
@@ -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(),
+1
View File
@@ -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,
+22 -1
View File
@@ -1332,6 +1332,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 +1697,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 +2846,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);
}
+19
View File
@@ -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,
+3
View File
@@ -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,
+7
View File
@@ -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
+17
View File
@@ -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,
+4
View File
@@ -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"
+9
View File
@@ -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)
);
}
}
+53
View File
@@ -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
// -----------------------------------------------------------------
+27
View File
@@ -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());
}
+112 -1
View File
@@ -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,