7 Commits
Author SHA1 Message Date
Ultradesu 28189bae95 Added playback history window 2026-07-27 23:37:46 +01:00
Ultradesu a7f41ff205 Bump libs 2026-07-27 23:20:21 +01:00
Ultradesu 40c318a1d0 Update frid dependency for listening history 2026-07-27 23:19:45 +01:00
Ultradesu 0d6c89761f Sync listening history across devices 2026-07-27 23:15:57 +01:00
Ultradesu f55571ab34 Fixed status icon background 2026-07-27 17:38:14 +01:00
Ultradesu eb97c6469e Fixed liked track sorting 2026-07-27 17:16:39 +01:00
Ultradesu 7ebf07161e Fixed liked track sorting 2026-07-27 17:09:33 +01:00
19 changed files with 531 additions and 74 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ permissions:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs: jobs:
build: build:
@@ -43,8 +44,11 @@ jobs:
- name: Show Rust version - name: Show Rust version
run: rustc --version && cargo --version run: rustc --version && cargo --version
- name: Fetch locked dependencies
run: cargo fetch --locked
- name: Build - name: Build
run: cargo build --release --locked run: cargo build --release --locked --offline
- name: Package - name: Package
shell: bash shell: bash
Generated
+12 -13
View File
@@ -240,7 +240,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [ dependencies = [
"event-listener 5.4.1", "event-listener 5.4.2",
"event-listener-strategy", "event-listener-strategy",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1376,11 +1376,10 @@ dependencies = [
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [ dependencies = [
"concurrent-queue",
"parking", "parking",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1391,7 +1390,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [ dependencies = [
"event-listener 5.4.1", "event-listener 5.4.2",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1456,7 +1455,7 @@ dependencies = [
[[package]] [[package]]
name = "federation-net" name = "federation-net"
version = "0.1.0" version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [ dependencies = [
"blake3", "blake3",
"data-encoding", "data-encoding",
@@ -1567,7 +1566,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.6" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
@@ -2989,8 +2988,8 @@ dependencies = [
[[package]] [[package]]
name = "music-dht" name = "music-dht"
version = "0.1.0" version = "0.2.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"blake3", "blake3",
@@ -5370,9 +5369,9 @@ dependencies = [
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.18" version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
@@ -5632,9 +5631,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow 1.0.4", "winnow 1.0.4",
] ]
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.7" 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"
@@ -21,7 +21,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
lofty = "0.22" lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between # P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd). # peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" } music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" }
ratatui = "0.30.1" ratatui = "0.30.1"
rhai = { version = "1", features = ["sync"] } rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
+5 -1
View File
@@ -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(),
+1
View File
@@ -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,
+59 -16
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 { 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);
@@ -1769,6 +1784,7 @@ fn start_current_audio(
state.player.position_secs = position_secs.max(0.0); state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default(); state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = Some(now_epoch_seconds()); state.player.track_started_at = Some(now_epoch_seconds());
state.player.listen_id = Some(runtime.devices.new_listen_id());
state.player.prefetched_pos = None; state.player.prefetched_pos = None;
runtime.player_start_pending = true; runtime.player_start_pending = true;
runtime.player.stop(); runtime.player.stop();
@@ -1796,16 +1812,19 @@ fn start_current_audio(
} }
// The track that was playing until now was cut short by this switch. // The track that was playing until now was cut short by this switch.
let previous_started_at = state.player.track_started_at; let previous_started_at = state.player.track_started_at;
let previous_listen_id = state.player.listen_id.take();
let next_key = track_playback_key(&track); let next_key = track_playback_key(&track);
let mut same_track = false;
let same_track_started_at = if let Some(previous) = state.player.current.take() { let same_track_started_at = if let Some(previous) = state.player.current.take() {
let same_track = track_playback_key(&previous) == next_key; same_track = track_playback_key(&previous) == next_key;
if state.player.playing && !same_track { if state.player.playing && !same_track {
report_history( report_history(
runtime, runtime,
previous.id, &previous,
previous_listen_id.as_deref(),
state.player.track_started_at, state.player.track_started_at,
state.player.position_secs.round() as i32, (state.player.position_secs * 1_000.0).round() as i64,
false, music_dht::device_sync::ListenEndReason::Replaced,
); );
} }
same_track.then_some(previous_started_at).flatten() same_track.then_some(previous_started_at).flatten()
@@ -1818,6 +1837,11 @@ fn start_current_audio(
state.player.position_secs = position_secs.max(0.0); state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default(); state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds())); state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds()));
state.player.listen_id = if same_track {
previous_listen_id
} else {
Some(runtime.devices.new_listen_id())
};
state.player.prefetched_pos = None; state.player.prefetched_pos = None;
state.status_message = Some(format!("{}{}", track.title, track.artist_line())); state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
@@ -1972,18 +1996,28 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
/// than 5s are noise. /// than 5s are noise.
fn report_history( fn report_history(
runtime: &Runtime, runtime: &Runtime,
track_id: i64, track: &crate::library::models::TrackItem,
listen_id: Option<&str>,
started_at: Option<i64>, started_at: Option<i64>,
listened: i32, listened_ms: i64,
completed: bool, ended_reason: music_dht::device_sync::ListenEndReason,
) { ) {
// Ephemeral federated tracks are not library rows; no history for them. let Some(listen_id) = listen_id else {
if listened < 5 || track_id < 0 {
return; return;
} };
let library = Arc::clone(&runtime.library); let Some(event) = runtime.devices.listen_event_for_track(
listen_id.to_string(),
track,
started_at.unwrap_or_else(now_epoch_seconds) * 1_000,
listened_ms,
ended_reason,
) else {
tracing::warn!(title = %track.title, "history skipped: track has no content id");
return;
};
let devices = Arc::clone(&runtime.devices);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Err(err) = library.add_history(track_id, started_at, listened, completed) { if let Err(err) = devices.record_listen(event) {
tracing::warn!(%err, "history write failed"); tracing::warn!(%err, "history write failed");
} }
}); });
@@ -2812,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);
} }
@@ -3292,10 +3332,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
if let Some(finished) = state.player.current.clone() { if let Some(finished) = state.player.current.clone() {
report_history( report_history(
runtime, runtime,
finished.id, &finished,
state.player.listen_id.as_deref(),
state.player.track_started_at, state.player.track_started_at,
finished.duration_seconds.round() as i32, (finished.duration_seconds * 1_000.0).round() as i64,
true, music_dht::device_sync::ListenEndReason::Finished,
); );
} }
if has_next { if has_next {
@@ -3309,10 +3350,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0; state.player.position_secs = 0.0;
state.player.track_started_at = Some(now_epoch_seconds()); state.player.track_started_at = Some(now_epoch_seconds());
state.player.listen_id = Some(runtime.devices.new_listen_id());
push_media_metadata(state, runtime); push_media_metadata(state, runtime);
push_media_update(state, runtime, true); push_media_update(state, runtime, true);
} else { } else {
state.player.current = None; state.player.current = None;
state.player.listen_id = None;
state.player.prefetched_pos = None; state.player.prefetched_pos = None;
if let Some(effect) = update::advance_after_finish(state) { if let Some(effect) = update::advance_after_finish(state) {
perform_effect(state, runtime, effect); perform_effect(state, runtime, effect);
+19
View File
@@ -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,
+6
View File
@@ -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,
@@ -1170,6 +1172,8 @@ pub struct PlayerBar {
pub audio_analysis: crate::player::AudioAnalysisSnapshot, pub audio_analysis: crate::player::AudioAnalysisSnapshot,
/// Epoch seconds when the current track started (for history reports). /// Epoch seconds when the current track started (for history reports).
pub track_started_at: Option<i64>, pub track_started_at: Option<i64>,
/// Stable id reused for every report of the current playback session.
pub listen_id: Option<String>,
/// Queue index already enqueued in the audio thread for gapless play. /// Queue index already enqueued in the audio thread for gapless play.
pub prefetched_pos: Option<usize>, pub prefetched_pos: Option<usize>,
pub volume: u8, pub volume: u8,
@@ -1191,6 +1195,7 @@ impl Default for PlayerBar {
position_secs: 0.0, position_secs: 0.0,
audio_analysis: crate::player::AudioAnalysisSnapshot::default(), audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
track_started_at: None, track_started_at: None,
listen_id: None,
prefetched_pos: None, prefetched_pos: None,
original_order: None, original_order: None,
volume: 80, volume: 80,
@@ -1277,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,
+8
View File
@@ -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
@@ -1092,6 +1099,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0; state.player.position_secs = 0.0;
state.player.track_started_at = None; state.player.track_started_at = None;
state.player.listen_id = None;
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
return QueueRemovalOutcome { return QueueRemovalOutcome {
restart_paused: was_loaded.then_some(was_paused), restart_paused: was_loaded.then_some(was_paused),
+17
View File
@@ -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,
+4
View File
@@ -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"
+9
View File
@@ -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)
);
}
} }
+60 -2
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use music_dht::device_sync::{ListenEvent, ListenTrackMetadata};
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor}; use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -21,9 +22,9 @@ use crate::app::event::AppEvent;
use crate::library::Library; use crate::library::Library;
use crate::library::models::{ArtistRef, TrackItem}; use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1"; pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 1; const PROTOCOL_VERSION: u16 = 2;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000; const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000; const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1); const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -366,6 +367,9 @@ pub enum SyncOpPayload {
target_device_id: String, target_device_id: String,
command: PlaybackCommand, command: PlaybackCommand,
}, },
ListenRecorded {
event: ListenEvent,
},
} }
impl SyncOpPayload { impl SyncOpPayload {
@@ -637,6 +641,10 @@ impl DeviceSync {
Ok((identity.device_id, identity.name)) Ok((identity.device_id, identity.name))
} }
pub fn new_listen_id(&self) -> String {
format!("{}-{}", now_ms(), random_hex(12))
}
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) { pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
if snapshot.updated_at_ms <= 0 { if snapshot.updated_at_ms <= 0 {
snapshot.updated_at_ms = now_ms(); snapshot.updated_at_ms = now_ms();
@@ -1073,6 +1081,52 @@ impl DeviceSync {
Ok(()) Ok(())
} }
pub fn record_listen(&self, event: ListenEvent) -> Result<()> {
if !event.should_record() {
return Ok(());
}
self.record_local_op(SyncOpPayload::ListenRecorded { event })
}
pub fn listen_event_for_track(
&self,
listen_id: String,
track: &TrackItem,
started_at_ms: i64,
listened_ms: i64,
ended_reason: music_dht::device_sync::ListenEndReason,
) -> Option<ListenEvent> {
let content_id = track
.content_id
.as_deref()
.or_else(|| track.fed.as_ref()?.content_id.as_deref())
.and_then(music_dht::normalize_content_id)?;
Some(ListenEvent {
listen_id,
content_id,
started_at_ms,
listened_ms,
track_duration_ms: (track.duration_seconds > 0.0)
.then_some((track.duration_seconds * 1_000.0).round() as i64),
ended_reason,
track: ListenTrackMetadata {
title: track.title.clone(),
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
release_title: (!track.release_title.trim().is_empty())
.then(|| track.release_title.clone()),
},
})
}
pub fn record_playlist_created(&self, playlist_id: i64, title: &str) -> Result<()> { pub fn record_playlist_created(&self, playlist_id: i64, title: &str) -> Result<()> {
let playlist_id = self.library.ensure_playlist_sync_id(playlist_id)?; let playlist_id = self.library.ensure_playlist_sync_id(playlist_id)?;
self.record_local_op(SyncOpPayload::PlaylistCreated { self.record_local_op(SyncOpPayload::PlaylistCreated {
@@ -1584,6 +1638,9 @@ impl DeviceSync {
self.apply_playback_command(target_device_id, command, &op.op_id)?; self.apply_playback_command(target_device_id, command, &op.op_id)?;
false false
} }
SyncOpPayload::ListenRecorded { event } => self
.library
.apply_listen_event(event, &op.origin_device_id)?,
}; };
Ok(changed) Ok(changed)
} }
@@ -3229,6 +3286,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
SyncOpPayload::DeviceTrusted { .. } => "device_trusted", SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
SyncOpPayload::DeviceRevoked { .. } => "device_revoked", SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
SyncOpPayload::PlaybackCommand { .. } => "playback_command", SyncOpPayload::PlaybackCommand { .. } => "playback_command",
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
} }
} }
+143 -21
View File
@@ -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,
@@ -126,10 +136,43 @@ CREATE TABLE IF NOT EXISTS history (
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
played_at TEXT NOT NULL DEFAULT (datetime('now')) played_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS listen_events (
listen_id TEXT PRIMARY KEY,
content_id TEXT NOT NULL,
local_track_id INTEGER REFERENCES tracks(id) ON DELETE SET NULL,
origin_device_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_ms INTEGER NOT NULL,
track_duration_ms INTEGER,
ended_reason TEXT NOT NULL,
qualified INTEGER NOT NULL,
metadata_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id); CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id);
CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id);
CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id); CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id);
CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id); CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
CREATE INDEX IF NOT EXISTS idx_listen_events_content
ON listen_events(content_id, started_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_listen_events_local_track
ON listen_events(local_track_id, qualified);
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_insert
AFTER INSERT ON tracks
WHEN NEW.content_id IS NOT NULL
BEGIN
UPDATE listen_events
SET local_track_id = NEW.id
WHERE content_id = NEW.content_id;
END;
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_content_id
AFTER UPDATE OF content_id ON tracks
WHEN NEW.content_id IS NOT NULL
BEGIN
UPDATE listen_events
SET local_track_id = NEW.id
WHERE content_id = NEW.content_id;
END;
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id); CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist
ON fed_playlist_tracks(playlist_sync_id, position); ON fed_playlist_tracks(playlist_sync_id, position);
@@ -159,7 +202,9 @@ const TRACK_COLUMNS: &str = "
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate, t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
t.audio_bit_depth, t.file_size_bytes, t.audio_bit_depth, t.file_size_bytes,
t.content_id, t.content_id,
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1) ((SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
+ (SELECT COUNT(*) FROM listen_events le
WHERE le.local_track_id = t.id AND le.qualified = 1))
"; ";
/// Plain rows handed to the federation for publishing (see /// Plain rows handed to the federation for publishing (see
@@ -798,9 +843,14 @@ impl Library {
|row| row.get(0), |row| row.get(0),
)?; )?;
let total_play_count: i64 = conn.query_row( let total_play_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM history h "SELECT
WHERE h.completed = 1 AND h.track_id IN (SELECT COUNT(*) FROM history h
(SELECT track_id FROM track_artists WHERE artist_id = ?1)", WHERE h.completed = 1 AND h.track_id IN
(SELECT track_id FROM track_artists WHERE artist_id = ?1))
+
(SELECT COUNT(*) FROM listen_events le
WHERE le.qualified = 1 AND le.local_track_id IN
(SELECT track_id FROM track_artists WHERE artist_id = ?1))",
[id], [id],
|row| row.get(0), |row| row.get(0),
)?; )?;
@@ -1201,7 +1251,11 @@ impl Library {
.get(&track.id) .get(&track.id)
.copied() .copied()
.unwrap_or_default(); .unwrap_or_default();
(liked_at, track.title.clone(), track) let stable_key = track
.content_id
.clone()
.unwrap_or_else(|| format!("local:{:020}", track.id));
(liked_at, stable_key, track)
}) })
.collect(); .collect();
@@ -1218,11 +1272,11 @@ impl Library {
})?; })?;
for row in fed_rows { for row in fed_rows {
let (liked_at, fed) = row?; let (liked_at, fed) = row?;
entries.push(( let stable_key = fed
liked_at, .content_id
fed.title.clone(), .clone()
crate::federation::pending_track(&fed), .unwrap_or_else(|| format!("fed:{}", fed.item_id));
)); entries.push((liked_at, stable_key, crate::federation::pending_track(&fed)));
} }
entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
return Ok(PlaylistDetail { return Ok(PlaylistDetail {
@@ -2192,20 +2246,88 @@ impl Library {
Ok(true) Ok(true)
} }
pub fn add_history( /// Idempotently materialize a portable trusted-device listen event.
pub fn apply_listen_event(
&self, &self,
track_id: i64, event: &music_dht::device_sync::ListenEvent,
started_at: Option<i64>, origin_device_id: &str,
listened_seconds: i32, ) -> Result<bool> {
completed: bool, if !event.should_record() || origin_device_id.trim().is_empty() {
) -> Result<()> { return Ok(false);
}
let content_id = music_dht::normalize_content_id(&event.content_id)
.context("invalid listen content id")?;
let conn = self.lock(); let conn = self.lock();
conn.execute( let local_track_id: Option<i64> = conn
"INSERT INTO history (track_id, started_at, listened_seconds, completed) .query_row(
VALUES (?1, ?2, ?3, ?4)", "SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1",
params![track_id, started_at, listened_seconds, completed], [&content_id],
|row| row.get(0),
)
.optional()?;
let inserted = conn.execute(
"INSERT OR IGNORE INTO listen_events
(listen_id, content_id, local_track_id, origin_device_id,
started_at_ms, listened_ms, track_duration_ms, ended_reason,
qualified, metadata_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
event.listen_id,
content_id,
local_track_id,
origin_device_id,
event.started_at_ms,
event.listened_ms,
event.track_duration_ms,
serde_json::to_string(&event.ended_reason)?,
i64::from(event.qualifies_as_play()),
serde_json::to_string(&event.track)?,
],
)?; )?;
Ok(()) 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()
} }
// ----------------------------------------------------------------- // -----------------------------------------------------------------
+49 -2
View File
@@ -502,8 +502,55 @@ fn delete_track_drops_empty_release() {
fn history_counts_completed_plays() { fn history_counts_completed_plays() {
let lib = test_library(); let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album"); let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.add_history(track_id, None, 60, true).unwrap(); let content_id = lib
lib.add_history(track_id, None, 10, false).unwrap(); .tracks_by_ids(&[track_id])
.unwrap()
.remove(0)
.content_id
.unwrap();
let event = music_dht::device_sync::ListenEvent {
listen_id: "listen-1".to_string(),
content_id,
started_at_ms: 1_700_000_000_000,
listened_ms: 60_000,
track_duration_ms: Some(60_000),
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Song".to_string(),
artist_names: vec!["Artist".to_string()],
featured_artist_names: Vec::new(),
release_title: Some("Album".to_string()),
},
};
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());
} }
+2 -1
View File
@@ -152,7 +152,8 @@ fn draw_tile_meta(
availability: Option<Availability>, availability: Option<Availability>,
selected: bool, selected: bool,
) { ) {
let marker = availability.map(|availability| availability_marker(availability, selected)); let selected_style = selected.then(|| theme::tab_active_for(state));
let marker = availability.map(|availability| availability_marker(availability, selected_style));
let marker_width = marker let marker_width = marker
.map(|(label, _)| UnicodeWidthStr::width(label) as u16) .map(|(label, _)| UnicodeWidthStr::width(label) as u16)
.unwrap_or(0) .unwrap_or(0)
+18 -7
View File
@@ -19,25 +19,36 @@ use crate::library::models::Availability;
pub(crate) fn availability_marker( pub(crate) fn availability_marker(
availability: Availability, availability: Availability,
selected: bool, selected_style: Option<Style>,
) -> (&'static str, Style) { ) -> (&'static str, Style) {
let (label, style) = match availability { let (label, style) = match availability {
Availability::Local => ("", Style::new().fg(Color::Green)), Availability::Local => ("", Style::new().fg(Color::Green)),
Availability::Mixed => ("", Style::new().fg(Color::Yellow)), Availability::Mixed => ("", Style::new().fg(Color::Yellow)),
Availability::Remote => ("", theme::accent()), Availability::Remote => ("", theme::accent()),
}; };
if selected { (label, selected_style.unwrap_or(style))
(label, theme::tab_active())
} else {
(label, style)
}
} }
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> { pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
let (label, style) = availability_marker(availability, false); let (label, style) = availability_marker(availability, None);
Span::styled(format!("{label} "), style) Span::styled(format!("{label} "), style)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_availability_marker_uses_the_supplied_mode_style() {
let client_style = Style::new().fg(Color::Black).bg(Color::Yellow);
let (label, style) = availability_marker(Availability::Local, Some(client_style));
assert_eq!(label, "");
assert_eq!(style, client_style);
}
}
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.visualizer.active { if state.visualizer.active {
crate::visualizer::draw(frame, state); crate::visualizer::draw(frame, state);
+112 -1
View File
@@ -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,
-7
View File
@@ -18,13 +18,6 @@ pub fn dim() -> Style {
Style::new().fg(DIM) Style::new().fg(DIM)
} }
pub fn tab_active() -> Style {
Style::new()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
}
pub fn tab_active_for(state: &AppState) -> Style { pub fn tab_active_for(state: &AppState) -> Style {
Style::new() Style::new()
.fg(Color::Black) .fg(Color::Black)