Added playback history window
This commit is contained in:
Generated
+1
-1
@@ -1566,7 +1566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.9"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
+22
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+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