diff --git a/Cargo.lock b/Cargo.lock index f121c9c..e3a16eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,7 +1566,7 @@ dependencies = [ [[package]] name = "furumi_tui" -version = "0.1.9" +version = "0.2.0" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 9a000e4..5189b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/action.rs b/src/app/action.rs index 2e8d8c6..ee66a6d 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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(), diff --git a/src/app/event.rs b/src/app/event.rs index dd05efd..71be605 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -56,6 +56,7 @@ pub enum AppEvent { LocalContentIdsLoaded(Result, String>), /// Counts and storage footprint of the local library/database. LocalLibraryStatsLoaded(Result), + ListenHistoryLoaded(Result, String>), /// One content id became available locally while the UI is open. LocalContentAvailable { content_id: String, diff --git a/src/app/mod.rs b/src/app/mod.rs index 5d5e2f5..a032b96 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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); } diff --git a/src/app/popup.rs b/src/app/popup.rs index 865bc11..22c1600 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -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, diff --git a/src/app/state.rs b/src/app/state.rs index d428737..35080f1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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>, + pub listen_history: Option>>, pub logs: LogsTab, pub queue_tab: QueueTab, pub federation: FederationTab, diff --git a/src/app/update.rs b/src/app/update.rs index b44c14b..9150788 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 { @@ -114,6 +116,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option { 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 diff --git a/src/app/update_tests.rs b/src/app/update_tests.rs index 3955cda..24159ea 100644 --- a/src/app/update_tests.rs +++ b/src/app/update_tests.rs @@ -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, diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 8582670..fcba394 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -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" diff --git a/src/config/keymap.rs b/src/config/keymap.rs index 21eb8de..444b1e2 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -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) + ); + } } diff --git a/src/library/mod.rs b/src/library/mod.rs index e913cf3..c483e10 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -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> { + 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::>>()?; + 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 // ----------------------------------------------------------------- diff --git a/src/library/tests.rs b/src/library/tests.rs index 57e1d44..dd923de 100644 --- a/src/library/tests.rs +++ b/src/library/tests.rs @@ -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()); } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index aba1eef..812cca2 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -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,