12 Commits
Author SHA1 Message Date
Ultradesu 29466d70e8 Added --help 2026-07-28 11:18:14 +01:00
Ultradesu abf9ad782c Added --status to get simple status 2026-07-28 11:16:38 +01:00
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
Ultradesu d36f55b3f6 Reworked MODE switch 2026-07-27 14:13:56 +01:00
Ultradesu 5722e5aae6 prepared release 2026-07-26 03:51:36 +03:00
Ultradesu 4e1c52c4e6 fix readmi 2026-07-26 03:41:43 +03:00
25 changed files with 1132 additions and 174 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
+1 -1
View File
@@ -29,7 +29,7 @@ Preserve these architectural invariants:
## Toolchain and checks ## Toolchain and checks
The crate uses Rust edition 2024 and Rust 1.88 or newer. The crate uses Rust edition 2024 and Rust 1.97 or newer.
Run the checks relevant to every code change: Run the checks relevant to every code change:
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.1"
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",
] ]
+3 -3
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.6" version = "0.2.1"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.97"
description = "A federated P2P player for personal music libraries" description = "A federated P2P player for personal music libraries"
license = "WTFPL" license = "WTFPL"
@@ -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"] }
+42 -2
View File
@@ -71,8 +71,25 @@ without rebuilding the player.
## Install ## Install
Download a prebuilt archive from the project releases, or build Furumi from ### macOS
source with Rust 1.97 or newer:
On Apple Silicon Macs, install Furumi from the Homebrew tap:
```bash
brew install house-of-vanity/tap/furumi
```
Run it with:
```bash
furumi
```
### Linux, Windows, and other platforms
Download a prebuilt archive from the
[GitHub releases](https://github.com/house-of-vanity/furumi_tui/releases), or
build Furumi from source with Rust 1.97 or newer:
```bash ```bash
cargo build --release --locked cargo build --release --locked
@@ -97,6 +114,29 @@ Import a music directory from Furumi's command line:
Federation, trusted-device pairing, and key bindings are configured directly Federation, trusted-device pairing, and key bindings are configured directly
inside the player. inside the player.
### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line
playback snapshot without opening the TUI or library:
```bash
furumi --status
# ▶ Artist — Track 1:23/4:05
```
For example, add this to `.tmux.conf`:
```tmux
set -g status-interval 1
set -g status-right '#(furumi --status) | %H:%M'
```
`furumi --status-json` returns the same snapshot as JSON, including playback
state, title, artist, album, position, duration, and volume. Both commands
print nothing when Furumi is stopped or no track is loaded. On Linux, Furumi
also exposes the existing MPRIS player `cy.hexor.furumi`, which can be queried
with tools such as `playerctl`.
## Architecture ## Architecture
Furumi is a Rust application built with: Furumi is a Rust application built with:
+11 -4
View File
@@ -43,11 +43,13 @@ pub enum Action {
RemoveFromQueue, RemoveFromQueue,
ClearQueue, ClearQueue,
OpenConnectedDevices, OpenConnectedDevices,
OpenListenHistory,
GoToRelease, GoToRelease,
AddToPlaylist, AddToPlaylist,
NewPlaylist, NewPlaylist,
ToggleHelp, ToggleHelp,
ToggleViewMode, ToggleViewMode,
CycleSourceMode,
OpenLibraryFilters, OpenLibraryFilters,
OpenCommandLine, OpenCommandLine,
OpenSearch, OpenSearch,
@@ -101,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
@@ -128,9 +131,10 @@ impl Action {
| Action::GoToTab(_) | Action::GoToTab(_)
| Action::GoToRelease | Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation, | Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => { Action::EditSelected
Category::Library | Action::DeleteSelected
} | Action::CycleSourceMode
| Action::OpenLibraryFilters => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search, Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::ToggleHelp | Action::Quit => Category::System, Action::ToggleHelp | Action::Quit => Category::System,
} }
@@ -150,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,
@@ -192,11 +197,13 @@ 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(),
Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
Action::OpenLibraryFilters => "Library filters…".into(), Action::OpenLibraryFilters => "Library filters…".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(), Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(), Action::OpenSearch => "Search artists, releases, tracks".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,
+98 -22
View File
@@ -66,6 +66,7 @@ pub struct Runtime {
pub player_start_pending: bool, pub player_start_pending: bool,
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>, pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
pub last_media_push: Option<std::time::Instant>, pub last_media_push: Option<std::time::Instant>,
pub status_publisher: crate::status::Publisher,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -286,6 +287,7 @@ pub async fn run(
player_start_pending: false, player_start_pending: false,
media_tx, media_tx,
last_media_push: None, last_media_push: None,
status_publisher: crate::status::Publisher::spawn(),
}; };
spawn_content_id_backfill(&runtime); spawn_content_id_backfill(&runtime);
@@ -325,6 +327,9 @@ pub async fn run(
state.advance_spinner(); state.advance_spinner();
expire_quit_confirmation(&mut state); expire_quit_confirmation(&mut state);
sync_player_shared(&mut state, &runtime); sync_player_shared(&mut state, &runtime);
runtime
.status_publisher
.publish(crate::status::PlaybackStatus::from_player(&state.player));
maybe_prefetch_next(&mut state, &runtime); maybe_prefetch_next(&mut state, &runtime);
push_media_update(&state, &mut runtime, false); push_media_update(&state, &mut runtime, false);
} }
@@ -425,14 +430,21 @@ fn apply_playback_state_to_ui(
wire: &crate::devices::PlaybackStateWire, wire: &crate::devices::PlaybackStateWire,
library: Option<&Library>, library: Option<&Library>,
) { ) {
state.player.queue = wire let queue: Vec<_> = wire
.queue .queue
.iter() .iter()
.map(|track| playback_track_to_ui(track, library)) .map(|track| playback_track_to_ui(track, library))
.collect(); .collect();
state.player.queue_pos = wire let queue_pos = queue
.queue_pos .iter()
.min(state.player.queue.len().saturating_sub(1)); .take(wire.queue_pos)
.filter(|track| update::track_allowed_by_source_mode(state, track))
.count();
state.player.queue = queue
.into_iter()
.filter(|track| update::track_allowed_by_source_mode(state, track))
.collect();
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
state.player.playing = wire.playing && !state.player.queue.is_empty(); state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused; state.player.paused = wire.paused;
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused { state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
@@ -1291,6 +1303,24 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
} }
Effect::SetOptions => {} Effect::SetOptions => {}
Effect::PlaybackQueueChanged => {} Effect::PlaybackQueueChanged => {}
Effect::SourceModeChanged => {
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
save_app_settings(state);
reset_artist_pagination(state);
refresh_artists(state, runtime);
if let Some(effect) = update::apply_library_filter_change(state) {
perform_effect(state, runtime, effect);
}
}
Effect::EnqueueRelease { id, next } => { Effect::EnqueueRelease { id, next } => {
let library = Arc::clone(&runtime.library); let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone(); let tx = runtime.event_tx.clone();
@@ -1307,6 +1337,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,
@@ -1660,7 +1702,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);
@@ -1744,6 +1789,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();
@@ -1771,16 +1817,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()
@@ -1793,6 +1842,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()));
@@ -1947,18 +2001,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");
} }
}); });
@@ -2787,6 +2851,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);
} }
@@ -3267,10 +3337,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 {
@@ -3284,10 +3355,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);
@@ -3410,10 +3483,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}); });
} }
AppEvent::EnqueueTracks { tracks, next } => { AppEvent::EnqueueTracks { tracks, next } => {
let count = tracks.len(); let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next); update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len);
record_control_playback_state(state, runtime, false); record_control_playback_state(state, runtime, false);
state.status_message = Some(if next { state.status_message = Some(if count == 0 {
"no tracks available in the current source mode".to_string()
} else if next {
format!("{count} tracks queued next") format!("{count} tracks queued next")
} else { } else {
format!("{count} tracks queued") format!("{count} tracks queued")
+27 -27
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,
@@ -404,45 +423,26 @@ fn handle_connected_devices(
fn handle_library_filters( fn handle_library_filters(
state: &mut AppState, state: &mut AppState,
runtime: &mut Runtime, runtime: &mut Runtime,
cursor: usize, _cursor: usize,
key: KeyEvent, key: KeyEvent,
) { ) {
let max_cursor = crate::config::settings::LibrarySourceMode::ALL.len(); let cursor = 0;
let cursor = cursor.min(max_cursor);
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => {} KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::LibraryFilters { state.popup = Some(Popup::LibraryFilters { cursor: 0 });
cursor: cursor.saturating_sub(1),
});
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::LibraryFilters { state.popup = Some(Popup::LibraryFilters { cursor: 0 });
cursor: (cursor + 1).min(max_cursor),
});
} }
KeyCode::Enter | KeyCode::Char(' ') => { KeyCode::Enter | KeyCode::Char(' ') => {
if cursor == 0 { state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
} else if let Some(mode) =
crate::config::settings::LibrarySourceMode::ALL.get(cursor - 1)
{
state.global.filters.source_mode = *mode;
}
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
super::save_app_settings(state); super::save_app_settings(state);
super::reset_artist_pagination(state); super::reset_artist_pagination(state);
super::refresh_artists(state, runtime); super::refresh_artists(state, runtime);
super::update::apply_library_filter_change(state); if let Some(effect) = super::update::apply_library_filter_change(state) {
super::perform_effect(state, runtime, effect);
}
} }
_ => state.popup = Some(Popup::LibraryFilters { cursor }), _ => state.popup = Some(Popup::LibraryFilters { cursor }),
} }
+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,
+80 -16
View File
@@ -48,6 +48,8 @@ pub enum Effect {
}, },
/// Queue/options changed without a direct audio engine action. /// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged, PlaybackQueueChanged,
/// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged,
/// Persist the federation settings and start/stop the node. /// Persist the federation settings and start/stop the node.
FedApplySettings, FedApplySettings,
/// Force an immediate library publish into the DHT. /// Force an immediate library publish into the DHT.
@@ -80,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> {
@@ -112,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
@@ -239,6 +248,18 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
} }
None None
} }
Action::CycleSourceMode => {
if matches!(state.active_tab, Tab::Global | Tab::Playlists | Tab::Queue) {
state.global.filters.source_mode = state.global.filters.source_mode.next();
state.status_message = Some(format!(
"source mode: {}",
state.global.filters.source_mode.label()
));
Some(Effect::SourceModeChanged)
} else {
None
}
}
Action::OpenLibraryFilters => { Action::OpenLibraryFilters => {
if state.active_tab == Tab::Global { if state.active_tab == Tab::Global {
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 }); state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
@@ -920,7 +941,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
} }
} }
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &[TrackItem])> { fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, Vec<&TrackItem>)> {
match state.active_tab { match state.active_tab {
Tab::Global => match state.global.stack.last()? { Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -931,23 +952,25 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Some(( Some((
TrackSelectionScope::ArtistTop(*id), TrackSelectionScope::ArtistTop(*id),
*cursor, *cursor,
&detail.top_tracks, detail.top_tracks.iter().collect(),
)) ))
} else { } else {
let featured = cursor.checked_sub(tracks + releases)?; let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some(( (featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id), TrackSelectionScope::ArtistFeatured(*id),
featured, featured,
&detail.featured_tracks, detail.featured_tracks.iter().collect(),
)) ))
} }
} }
_ => None, _ => None,
}, },
GlobalView::Release { id, cursor } => match state.release_views.get(id)? { GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => { Loadable::Ready(detail) => Some((
Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks)) TrackSelectionScope::Release(*id),
} *cursor,
detail.tracks.iter().collect(),
)),
_ => None, _ => None,
}, },
_ => None, _ => None,
@@ -963,7 +986,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Tab::Queue => Some(( Tab::Queue => Some((
TrackSelectionScope::Queue, TrackSelectionScope::Queue,
state.queue_tab.cursor, state.queue_tab.cursor,
&state.player.queue, state.player.queue.iter().collect(),
)), )),
Tab::Federation | Tab::Logs => None, Tab::Federation | Tab::Logs => None,
} }
@@ -987,7 +1010,7 @@ pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]); .unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
indices indices
.into_iter() .into_iter()
.filter_map(|index| tracks.get(index).cloned()) .filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
.collect() .collect()
} }
@@ -1076,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),
@@ -1175,21 +1199,32 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
let opened = state.playlists.opened.as_ref()?; let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)? playlist_tracks(state, opened.id)?
.get(opened.cursor) .get(opened.cursor)
.cloned() .map(|track| (*track).clone())
} }
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(), Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Federation | Tab::Logs => None, Tab::Federation | Tab::Logs => None,
} }
} }
/// Tracks backing an opened playlist, if loaded. /// Visible tracks backing an opened playlist. Local mode excludes pending
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec<TrackItem>> { /// federation entries so they cannot be selected or copied into playback.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<Vec<&TrackItem>> {
match state.playlist_views.get(&id)? { match state.playlist_views.get(&id)? {
Loadable::Ready(detail) => Some(&detail.tracks), Loadable::Ready(detail) => Some(
detail
.tracks
.iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect(),
),
_ => None, _ => None,
} }
} }
pub(crate) fn track_allowed_by_source_mode(state: &AppState, track: &TrackItem) -> bool {
state.global.filters.source_mode.includes_network() || !track.is_fed_pending()
}
/// A *release* under the cursor (artist-view tile/row or a search release). /// A *release* under the cursor (artist-view tile/row or a search release).
fn selected_release_id(state: &AppState) -> Option<i64> { fn selected_release_id(state: &AppState) -> Option<i64> {
if state.active_tab != Tab::Global { if state.active_tab != Tab::Global {
@@ -1334,6 +1369,10 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
/// Insert tracks after the playing one (`next`) or at the end. Keeps the /// Insert tracks after the playing one (`next`) or at the end. Keeps the
/// gapless prefetch index pointing at the same track if items shift. /// gapless prefetch index pointing at the same track if items shift.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) { pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
let tracks: Vec<_> = tracks
.into_iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect();
let player = &mut state.player; let player = &mut state.player;
if tracks.is_empty() { if tracks.is_empty() {
return; return;
@@ -1790,7 +1829,7 @@ fn set_view_cursor(state: &mut AppState, value: usize) {
/// Items in the playlists tab's current view (list or opened playlist). /// Items in the playlists tab's current view (list or opened playlist).
fn playlists_view_len(state: &AppState) -> usize { fn playlists_view_len(state: &AppState) -> usize {
match &state.playlists.opened { match &state.playlists.opened {
Some(opened) => playlist_tracks(state, opened.id).map_or(0, Vec::len), Some(opened) => playlist_tracks(state, opened.id).map_or(0, |tracks| tracks.len()),
None => match &state.playlists.list { None => match &state.playlists.list {
Some(Loadable::Ready(list)) => list.len(), Some(Loadable::Ready(list)) => list.len(),
_ => 0, _ => 0,
@@ -1832,7 +1871,7 @@ fn current_view_len(state: &AppState) -> usize {
} }
} }
pub(crate) fn apply_library_filter_change(state: &mut AppState) { pub(crate) fn apply_library_filter_change(state: &mut AppState) -> Option<Effect> {
state.track_selection.clear(); state.track_selection.clear();
let len = current_view_len(state); let len = current_view_len(state);
if state.active_tab == Tab::Global { if state.active_tab == Tab::Global {
@@ -1848,11 +1887,32 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
}; };
*cursor = (*cursor).min(len.saturating_sub(1)); *cursor = (*cursor).min(len.saturating_sub(1));
} }
} else if state.active_tab == Tab::Playlists
&& let Some(opened) = &mut state.playlists.opened
{
opened.cursor = opened.cursor.min(len.saturating_sub(1));
} }
if state.global.filters.source_mode.includes_network() { if state.global.filters.source_mode.includes_network() {
return; return None;
} }
let remote_indices: Vec<_> = state
.player
.queue
.iter()
.enumerate()
.filter_map(|(index, track)| track.is_fed_pending().then_some(index))
.collect();
let queue_effect = if remote_indices.is_empty() {
None
} else {
let outcome = remove_queue_indices(state, &remote_indices);
Some(Effect::RemoveQueueIndices {
indices: remote_indices,
restart_paused: outcome.restart_paused,
stop: outcome.stop,
})
};
let message = match state.global.stack.last() { let message = match state.global.stack.last() {
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(detail)) Some(Loadable::Ready(detail))
@@ -1885,6 +1945,7 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
if let Some(message) = message { if let Some(message) = message {
state.status_message = Some(message); state.status_message = Some(message);
} }
queue_effect
} }
fn jump_selection(state: &mut AppState, first: bool) { fn jump_selection(state: &mut AppState, first: bool) {
@@ -1954,7 +2015,10 @@ fn jump_selection(state: &mut AppState, first: bool) {
fn select_playlist(state: &mut AppState) -> Option<Effect> { fn select_playlist(state: &mut AppState) -> Option<Effect> {
match state.playlists.opened { match state.playlists.opened {
Some(opened) => { Some(opened) => {
let tracks = playlist_tracks(state, opened.id)?.clone(); let tracks: Vec<_> = playlist_tracks(state, opened.id)?
.into_iter()
.cloned()
.collect();
if tracks.is_empty() { if tracks.is_empty() {
return None; return None;
} }
+176
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,
@@ -41,6 +58,23 @@ fn test_track(id: i64) -> TrackItem {
} }
} }
fn pending_fed_track(id: i64) -> TrackItem {
crate::federation::pending_track(&crate::federation::FedTrack {
item_id: format!("fed-{id}"),
owner: "peer".into(),
own: false,
title: format!("remote-{id}"),
artist_names: vec!["remote artist".into()],
featured_artist_names: vec![],
year: None,
duration_seconds: Some(1),
content_id: Some(format!("b3:{id:064x}")),
release_title: Some("remote release".into()),
track_number: None,
disc_number: None,
})
}
#[test] #[test]
fn quit_needs_double_press() { fn quit_needs_double_press() {
let mut state = AppState::default(); let mut state = AppState::default();
@@ -115,6 +149,36 @@ fn library_filters_popup_opens_on_library_screens() {
assert!(state.popup.is_none()); assert!(state.popup.is_none());
} }
#[test]
fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
use crate::config::settings::LibrarySourceMode;
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
state.active_tab = Tab::Playlists;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
state.active_tab = Tab::Queue;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
state.active_tab = Tab::Federation;
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
}
#[test] #[test]
fn back_closes_help_first() { fn back_closes_help_first() {
let mut state = AppState::default(); let mut state = AppState::default();
@@ -387,6 +451,118 @@ fn queue_tab_select_and_clear() {
assert!(!state.player.playing); assert!(!state.player.playing);
} }
#[test]
fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
let mut state = AppState {
active_tab: Tab::Playlists,
..AppState::default()
};
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2), test_track(3)],
}),
);
assert_eq!(
playlist_tracks(&state, 7)
.unwrap()
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
update(&mut state, Action::Select),
Some(Effect::PlayCurrent)
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
}
#[test]
fn network_modes_show_pending_federation_playlist_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2)],
}),
);
assert_eq!(playlist_tracks(&state, 7).unwrap().len(), 2);
}
#[test]
fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default();
enqueue_tracks(
&mut state,
vec![test_track(1), pending_fed_track(2), test_track(3)],
false,
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
}
#[test]
fn switching_to_local_mode_removes_pending_federation_queue_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.player.queue = vec![test_track(1), pending_fed_track(2), test_track(3)];
state.player.queue_pos = 1;
state.player.current = Some(state.player.queue[1].clone());
state.player.playing = true;
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
let effect = apply_library_filter_change(&mut state);
assert!(matches!(
effect,
Some(Effect::RemoveQueueIndices {
indices,
restart_paused: Some(false),
stop: false,
}) if indices == vec![1]
));
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3));
}
#[test] #[test]
fn current_track_info_uses_now_playing_track() { fn current_track_info_uses_now_playing_track() {
let mut state = AppState { let mut state = AppState {
+8
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"
@@ -225,6 +229,10 @@ command = "DeleteSelected"
key_sequence = "v" key_sequence = "v"
command = "ToggleViewMode" command = "ToggleViewMode"
[[keymaps]]
key_sequence = "m"
command = "CycleSourceMode"
[[keymaps]] [[keymaps]]
key_sequence = "f" key_sequence = "f"
command = "OpenLibraryFilters" command = "OpenLibraryFilters"
+24
View File
@@ -426,6 +426,21 @@ mod tests {
); );
} }
#[test]
fn default_source_mode_key_resolves_on_content_tabs() {
let mut km = keymap_from(DEFAULT_KEYMAP);
for context in [
KeyContext::Library,
KeyContext::Playlists,
KeyContext::Queue,
] {
assert_eq!(
km.resolve(key!(m), context),
KeyResolution::Action(Action::CycleSourceMode)
);
}
}
#[test] #[test]
fn default_shift_n_is_unbound() { fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP); let mut km = keymap_from(DEFAULT_KEYMAP);
@@ -581,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)
);
}
} }
+8 -21
View File
@@ -11,12 +11,6 @@ pub enum LibrarySourceMode {
} }
impl LibrarySourceMode { impl LibrarySourceMode {
pub const ALL: [LibrarySourceMode; 3] = [
LibrarySourceMode::Local,
LibrarySourceMode::My,
LibrarySourceMode::Global,
];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
LibrarySourceMode::Local => "Local", LibrarySourceMode::Local => "Local",
@@ -25,14 +19,6 @@ impl LibrarySourceMode {
} }
} }
pub fn description(self) -> &'static str {
match self {
LibrarySourceMode::Local => "only this device",
LibrarySourceMode::My => "this device + connected devices",
LibrarySourceMode::Global => "my devices + known federation peers",
}
}
pub fn includes_network(self) -> bool { pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local) !matches!(self, LibrarySourceMode::Local)
} }
@@ -40,6 +26,14 @@ impl LibrarySourceMode {
pub fn includes_global_peers(self) -> bool { pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global) matches!(self, LibrarySourceMode::Global)
} }
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -50,12 +44,6 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode, pub source_mode: LibrarySourceMode,
} }
impl LibraryFilters {
pub fn is_active(&self) -> bool {
self.hide_featured_only || self.source_mode != LibrarySourceMode::Local
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings { pub struct AppSettings {
#[serde(default = "default_volume")] #[serde(default = "default_volume")]
@@ -140,7 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100); assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only); assert!(settings.library.hide_featured_only);
assert!(settings.library.is_active());
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local); assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
} }
} }
+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());
} }
+30 -1
View File
@@ -7,6 +7,7 @@ mod library;
mod media; mod media;
mod player; mod player;
mod share; mod share;
mod status;
mod streaming; mod streaming;
mod ui; mod ui;
mod visualizer; mod visualizer;
@@ -19,11 +20,39 @@ use crossterm::event::{
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
}; };
const HELP: &str = "\
Furumi federated terminal music player
Usage:
furumi [OPTION]
Options:
-h, --help Show this help
-V, --version Show version
--status Print a one-line now-playing status
--status-json
Print now-playing status as JSON
tmux:
set -g status-right '#(furumi --status)'
";
fn main() -> Result<()> { fn main() -> Result<()> {
if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") { let args: Vec<_> = std::env::args_os().collect();
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
print!("{HELP}");
return Ok(());
}
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
println!("furumi {}", env!("CARGO_PKG_VERSION")); println!("furumi {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
} }
if args
.iter()
.any(|arg| arg == "--status" || arg == "--status-json")
{
return status::print(args.iter().any(|arg| arg == "--status-json"));
}
let mut startup_warning = None; let mut startup_warning = None;
if let Err(err) = config::logging::init() { if let Err(err) = config::logging::init() {
+190
View File
@@ -0,0 +1,190 @@
//! Cheap now-playing export for status bars and other polling clients.
//!
//! The UI only sends snapshots through a bounded channel. A dedicated thread
//! performs the filesystem writes so a slow filesystem cannot stall drawing
//! or input handling.
use std::fs;
use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::app::state::PlayerBar;
const STALE_AFTER_MS: u64 = 5_000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaybackStatus {
pub playing: bool,
pub paused: bool,
pub title: String,
pub artist: String,
pub album: String,
pub position_secs: f64,
pub duration_secs: f64,
pub volume: u8,
pub updated_at_ms: u64,
}
impl PlaybackStatus {
pub fn from_player(player: &PlayerBar) -> Option<Self> {
let track = player.current.as_ref()?;
player.playing.then(|| Self {
playing: true,
paused: player.paused,
title: track.title.clone(),
artist: track.artist_line(),
album: track.release_title.clone(),
position_secs: player.position_secs.max(0.0),
duration_secs: track.duration_seconds.max(0.0),
volume: player.volume,
updated_at_ms: now_ms(),
})
}
pub fn one_line(&self) -> String {
let icon = if self.paused { "" } else { "" };
let track = if self.artist.is_empty() {
self.title.clone()
} else {
format!("{}{}", self.artist, self.title)
};
format!(
"{icon} {track} {}/{}",
duration(self.position_secs),
duration(self.duration_secs)
)
}
}
pub struct Publisher {
tx: Option<SyncSender<Option<PlaybackStatus>>>,
}
impl Publisher {
pub fn spawn() -> Self {
let (tx, rx) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("playback-status".to_string())
.spawn(move || {
while let Ok(snapshot) = rx.recv() {
if let Some(snapshot) = snapshot {
if let Err(err) = write(&snapshot) {
tracing::debug!(?err, "writing playback status failed");
}
} else {
remove();
}
}
remove();
})
.ok();
Self { tx: Some(tx) }
}
pub fn publish(&self, snapshot: Option<PlaybackStatus>) {
let Some(tx) = &self.tx else { return };
match tx.try_send(snapshot) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => {}
}
}
}
impl Drop for Publisher {
fn drop(&mut self) {
self.tx.take();
}
}
pub fn print(json: bool) -> anyhow::Result<()> {
let Some(status) = read()? else {
return Ok(());
};
if json {
println!("{}", serde_json::to_string(&status)?);
} else {
println!("{}", status.one_line());
}
Ok(())
}
fn read() -> anyhow::Result<Option<PlaybackStatus>> {
let Some(path) = path() else {
return Ok(None);
};
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let status: PlaybackStatus = serde_json::from_slice(&bytes)?;
if now_ms().saturating_sub(status.updated_at_ms) > STALE_AFTER_MS {
return Ok(None);
}
Ok(Some(status))
}
fn write(status: &PlaybackStatus) -> anyhow::Result<()> {
let Some(path) = path() else {
return Ok(());
};
let Some(dir) = path.parent() else {
return Ok(());
};
fs::create_dir_all(dir)?;
let temporary = dir.join("playback-status.tmp");
fs::write(&temporary, serde_json::to_vec(status)?)?;
#[cfg(windows)]
if path.exists() {
let _ = fs::remove_file(&path);
}
fs::rename(temporary, path)?;
Ok(())
}
fn remove() {
if let Some(path) = path() {
let _ = fs::remove_file(path);
}
}
fn path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.cache_dir().join("playback-status.json"))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn duration(seconds: f64) -> String {
let total = seconds.max(0.0).round() as u64;
format!("{}:{:02}", total / 60, total % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_line_contains_state_metadata_and_progress() {
let status = PlaybackStatus {
playing: true,
paused: true,
title: "Track".into(),
artist: "Artist".into(),
album: "Release".into(),
position_secs: 83.0,
duration_secs: 245.0,
volume: 80,
updated_at_ms: 0,
};
assert_eq!(status.one_line(), "⏸ Artist — Track 1:23/4:05");
}
}
+5 -4
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)
@@ -383,15 +384,15 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
let global = &state.global; let global = &state.global;
let title = if global.total > 0 { let title = if global.total > 0 {
format!( format!(
" Library — {} artists · {} ", " Library — {} artists · Mode: {} ",
global.total, global.total,
global.filters.source_mode.label() global.filters.source_mode.label()
) )
} else { } else {
format!(" Library · {} ", global.filters.source_mode.label()) format!(" Library · Mode: {} ", global.filters.source_mode.label())
}; };
let mut title_spans = vec![Span::styled(title, theme::tab_active_for(state))]; let mut title_spans = vec![Span::styled(title, theme::tab_active_for(state))];
if global.filters.is_active() { if global.filters.hide_featured_only {
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(" FILTERED ", theme::tab_active_for(state))); title_spans.push(Span::styled(" FILTERED ", theme::tab_active_for(state)));
} }
+21 -9
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);
@@ -247,8 +258,9 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player; let player = &state.player;
let block = Block::bordered() let block = Block::bordered()
.title(format!( .title(format!(
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ", " Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len() player.queue.len(),
state.global.filters.source_mode.label()
)) ))
.title_style(theme::header_for(state)) .title_style(theme::header_for(state))
.border_style(theme::border_for(state)); .border_style(theme::border_for(state));
+18 -3
View File
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
} }
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, state, " Playlists ".to_string()); let inner = bordered(
frame,
area,
state,
format!(
" Playlists · Mode: {} ",
state.global.filters.source_mode.label()
),
);
let selected = state.playlists.selected; let selected = state.playlists.selected;
let list = match &state.playlists.list { let list = match &state.playlists.list {
@@ -97,8 +105,15 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id); let loadable = state.playlist_views.get(&id);
let title = match loadable { let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title), Some(Loadable::Ready(detail)) => format!(
_ => " Playlists ▸ ".to_string(), " Playlists ▸ {} · Mode: {} ",
detail.title,
state.global.filters.source_mode.label()
),
_ => format!(
" Playlists ▸ … · Mode: {} ",
state.global.filters.source_mode.label()
),
}; };
let inner = bordered(frame, area, state, title); let inner = bordered(frame, area, state, title);
+114 -15
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,
@@ -598,7 +709,7 @@ fn clip_cells(text: &str, max_width: usize) -> String {
} }
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(frame.area(), 54, 9); let area = centered(frame.area(), 44, 6);
let block = Block::bordered() let block = Block::bordered()
.title(" Library filters ") .title(" Library filters ")
.title_style(theme::header_for(state)) .title_style(theme::header_for(state))
@@ -608,7 +719,7 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
frame.render_widget(block, area); frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([ let [list_area, _, footer] = Layout::vertical([
Constraint::Length(4), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
]) ])
@@ -624,18 +735,6 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
Span::styled(format!("{checked} "), theme::accent_for(state)), Span::styled(format!("{checked} "), theme::accent_for(state)),
Span::raw("Hide featured only"), Span::raw("Hide featured only"),
])); ]));
for mode in crate::config::settings::LibrarySourceMode::ALL {
let marker = if state.global.filters.source_mode == mode {
"(*)"
} else {
"( )"
};
rows.push(Line::from(vec![
Span::styled(format!("{marker} "), theme::accent_for(state)),
Span::raw(mode.label()),
Span::styled(format!(" {}", mode.description()), theme::dim()),
]));
}
for (index, line) in rows.into_iter().enumerate() { for (index, line) in rows.into_iter().enumerate() {
let row = Rect { let row = Rect {
y: list_area.y + index as u16, y: list_area.y + index as u16,
-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)