Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29466d70e8 | ||
|
|
abf9ad782c | ||
|
|
28189bae95 | ||
|
|
a7f41ff205 | ||
|
|
40c318a1d0 | ||
|
|
0d6c89761f | ||
|
|
f55571ab34 | ||
|
|
eb97c6469e | ||
|
|
7ebf07161e | ||
|
|
d36f55b3f6 | ||
|
|
5722e5aae6 | ||
|
|
4e1c52c4e6 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"] }
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user