Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28189bae95 |
Generated
+1
-1
@@ -1566,7 +1566,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.9"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.9"
|
version = "0.2.0"
|
||||||
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"
|
||||||
|
|||||||
+5
-1
@@ -43,6 +43,7 @@ pub enum Action {
|
|||||||
RemoveFromQueue,
|
RemoveFromQueue,
|
||||||
ClearQueue,
|
ClearQueue,
|
||||||
OpenConnectedDevices,
|
OpenConnectedDevices,
|
||||||
|
OpenListenHistory,
|
||||||
GoToRelease,
|
GoToRelease,
|
||||||
AddToPlaylist,
|
AddToPlaylist,
|
||||||
NewPlaylist,
|
NewPlaylist,
|
||||||
@@ -102,7 +103,8 @@ impl Action {
|
|||||||
| Action::ToggleShuffle
|
| Action::ToggleShuffle
|
||||||
| Action::CycleRepeat
|
| Action::CycleRepeat
|
||||||
| Action::ToggleVisualizer
|
| Action::ToggleVisualizer
|
||||||
| Action::OpenConnectedDevices => Category::Playback,
|
| Action::OpenConnectedDevices
|
||||||
|
| Action::OpenListenHistory => Category::Playback,
|
||||||
Action::QueueAddNext
|
Action::QueueAddNext
|
||||||
| Action::QueueAddLast
|
| Action::QueueAddLast
|
||||||
| Action::DownloadSelected
|
| Action::DownloadSelected
|
||||||
@@ -152,6 +154,7 @@ impl Action {
|
|||||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||||
Action::ClearQueue => Some(":clear"),
|
Action::ClearQueue => Some(":clear"),
|
||||||
Action::OpenConnectedDevices => None,
|
Action::OpenConnectedDevices => None,
|
||||||
|
Action::OpenListenHistory => None,
|
||||||
Action::ToggleHelp => Some(":help"),
|
Action::ToggleHelp => Some(":help"),
|
||||||
Action::OpenSearch => Some("/text"),
|
Action::OpenSearch => Some("/text"),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -194,6 +197,7 @@ impl Action {
|
|||||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||||
Action::ClearQueue => "Queue: clear".into(),
|
Action::ClearQueue => "Queue: clear".into(),
|
||||||
Action::OpenConnectedDevices => "Connected devices…".into(),
|
Action::OpenConnectedDevices => "Connected devices…".into(),
|
||||||
|
Action::OpenListenHistory => "Listening history…".into(),
|
||||||
Action::GoToRelease => "Open the track's release".into(),
|
Action::GoToRelease => "Open the track's release".into(),
|
||||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||||
Action::NewPlaylist => "Create a playlist".into(),
|
Action::NewPlaylist => "Create a playlist".into(),
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub enum AppEvent {
|
|||||||
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
||||||
/// Counts and storage footprint of the local library/database.
|
/// Counts and storage footprint of the local library/database.
|
||||||
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
||||||
|
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
|
||||||
/// One content id became available locally while the UI is open.
|
/// One content id became available locally while the UI is open.
|
||||||
LocalContentAvailable {
|
LocalContentAvailable {
|
||||||
content_id: String,
|
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 {
|
Effect::ToggleLikes {
|
||||||
track_ids,
|
track_ids,
|
||||||
fed_tracks,
|
fed_tracks,
|
||||||
@@ -1685,7 +1697,10 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
|
|||||||
state.player.volume = volume.min(100);
|
state.player.volume = volume.min(100);
|
||||||
save_app_settings(state);
|
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);
|
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) {
|
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
|
||||||
match event {
|
match event {
|
||||||
AppEvent::StatusMessage(message) => state.status_message = Some(message),
|
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) => {
|
AppEvent::FederationStatus(status) => {
|
||||||
state.federation.status = Some(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 } => {
|
Popup::ConnectedDevices { cursor } => {
|
||||||
handle_connected_devices(state, runtime, cursor, key);
|
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(
|
fn handle_federation_status_details(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
mut parent: FederationStatusPopupState,
|
mut parent: FederationStatusPopupState,
|
||||||
|
|||||||
@@ -732,6 +732,8 @@ pub enum Popup {
|
|||||||
ConfirmDeviceLeave,
|
ConfirmDeviceLeave,
|
||||||
/// Connected playback devices and their current role/status.
|
/// Connected playback devices and their current role/status.
|
||||||
ConnectedDevices { cursor: usize },
|
ConnectedDevices { cursor: usize },
|
||||||
|
/// Qualified listening history from every trusted device.
|
||||||
|
ListenHistory { cursor: usize },
|
||||||
/// Full federation, transport and device status details.
|
/// Full federation, transport and device status details.
|
||||||
FederationStatusDetails {
|
FederationStatusDetails {
|
||||||
focus: StatusDetailFocus,
|
focus: StatusDetailFocus,
|
||||||
@@ -1280,6 +1282,7 @@ pub struct AppState {
|
|||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub local_content_ids_loaded: bool,
|
pub local_content_ids_loaded: bool,
|
||||||
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
|
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
|
||||||
|
pub listen_history: Option<Loadable<Vec<crate::library::ListenHistoryEntry>>>,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
pub federation: FederationTab,
|
pub federation: FederationTab,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ pub enum Effect {
|
|||||||
OpenVisualizerEditor {
|
OpenVisualizerEditor {
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Load qualified listening history without blocking the UI thread.
|
||||||
|
LoadListenHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
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 });
|
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
Action::OpenListenHistory => {
|
||||||
|
state.popup = Some(super::state::Popup::ListenHistory { cursor: 0 });
|
||||||
|
state.listen_history = Some(Loadable::Loading);
|
||||||
|
Some(Effect::LoadListenHistory)
|
||||||
|
}
|
||||||
Action::NextTab => {
|
Action::NextTab => {
|
||||||
switch_tab(state, state.active_tab.next());
|
switch_tab(state, state.active_tab.next());
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ fn with_artists(n: usize) -> AppState {
|
|||||||
state
|
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 {
|
fn test_track(id: i64) -> TrackItem {
|
||||||
TrackItem {
|
TrackItem {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ command = "CycleRepeat"
|
|||||||
key_sequence = "shift-l"
|
key_sequence = "shift-l"
|
||||||
command = "ToggleVisualizer"
|
command = "ToggleVisualizer"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "shift-h"
|
||||||
|
command = "OpenListenHistory"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "x"
|
key_sequence = "x"
|
||||||
command = "ToggleLike"
|
command = "ToggleLike"
|
||||||
|
|||||||
@@ -596,4 +596,13 @@ mod tests {
|
|||||||
KeyResolution::Action(Action::SeekForward { seconds: 10 })
|
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;
|
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
||||||
const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
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 = "
|
const SCHEMA: &str = "
|
||||||
CREATE TABLE IF NOT EXISTS artists (
|
CREATE TABLE IF NOT EXISTS artists (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -2277,6 +2287,49 @@ impl Library {
|
|||||||
Ok(inserted > 0)
|
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
|
// Editing & deleting
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
@@ -526,4 +526,31 @@ fn history_counts_completed_plays() {
|
|||||||
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
|
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
|
||||||
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
|
||||||
assert_eq!(track.play_count, 1);
|
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::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||||
use ratatui::text::{Line, Span};
|
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 unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use super::theme;
|
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::ConfirmDeviceLeave) => draw_device_leave(frame, state),
|
||||||
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
|
||||||
|
Some(Popup::ListenHistory { cursor }) => draw_listen_history(frame, state, *cursor),
|
||||||
None => {}
|
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(
|
fn draw_federation_status_details(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
Reference in New Issue
Block a user