5 Commits
Author SHA1 Message Date
Ultradesu eb97c6469e Fixed liked track sorting 2026-07-27 17:16:39 +01:00
Ultradesu 7ebf07161e Fixed liked track sorting 2026-07-27 17:09:33 +01:00
Ultradesu d36f55b3f6 Reworked MODE switch 2026-07-27 14:13:56 +01:00
Ultradesu 5722e5aae6 prepared release 2026-07-26 03:51:36 +03:00
Ultradesu 4e1c52c4e6 fix readmi 2026-07-26 03:41:43 +03:00
18 changed files with 386 additions and 148 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ permissions:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs: jobs:
build: build:
@@ -43,8 +44,11 @@ jobs:
- name: Show Rust version - name: Show Rust version
run: rustc --version && cargo --version run: rustc --version && cargo --version
- name: Fetch locked dependencies
run: cargo fetch --locked
- name: Build - name: Build
run: cargo build --release --locked run: cargo build --release --locked --offline
- name: Package - name: Package
shell: bash shell: bash
+1 -1
View File
@@ -29,7 +29,7 @@ Preserve these architectural invariants:
## Toolchain and checks ## Toolchain and checks
The crate uses Rust edition 2024 and Rust 1.88 or newer. The crate uses Rust edition 2024 and Rust 1.97 or newer.
Run the checks relevant to every code change: Run the checks relevant to every code change:
Generated
+16 -40
View File
@@ -1084,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [ dependencies = [
"data-encoding", "data-encoding",
"syn 2.0.119", "syn 1.0.109",
] ]
[[package]] [[package]]
@@ -1188,7 +1188,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1345,7 +1345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1456,7 +1456,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=a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
dependencies = [ dependencies = [
"blake3", "blake3",
"data-encoding", "data-encoding",
@@ -1567,7 +1567,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.6" version = "0.1.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
@@ -2990,7 +2990,7 @@ dependencies = [
[[package]] [[package]]
name = "music-dht" name = "music-dht"
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=a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"blake3", "blake3",
@@ -3315,7 +3315,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -3390,7 +3390,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -4028,16 +4028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"toml_edit 0.19.15", "toml_edit",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.13+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -4491,7 +4482,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4549,7 +4540,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4641,7 +4632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -5289,7 +5280,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -5618,18 +5609,6 @@ dependencies = [
"winnow 0.5.40", "winnow 0.5.40",
] ]
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -6157,7 +6136,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -6533,9 +6512,6 @@ name = "winnow"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
@@ -6678,7 +6654,7 @@ version = "3.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5"
dependencies = [ dependencies = [
"proc-macro-crate 1.3.1", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
@@ -6832,7 +6808,7 @@ version = "3.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9"
dependencies = [ dependencies = [
"proc-macro-crate 1.3.1", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 1.0.109",
+3 -3
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.6" version = "0.1.8"
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 = "a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" }
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"] }
+19 -2
View File
@@ -71,8 +71,25 @@ without rebuilding the player.
## Install ## Install
Download a prebuilt archive from the project releases, or build Furumi from ### macOS
source with Rust 1.97 or newer:
On Apple Silicon Macs, install Furumi from the Homebrew tap:
```bash
brew install house-of-vanity/tap/furumi
```
Run it with:
```bash
furumi
```
### Linux, Windows, and other platforms
Download a prebuilt archive from the
[GitHub releases](https://github.com/house-of-vanity/furumi_tui/releases), or
build Furumi from source with Rust 1.97 or newer:
```bash ```bash
cargo build --release --locked cargo build --release --locked
+6 -3
View File
@@ -48,6 +48,7 @@ pub enum Action {
NewPlaylist, NewPlaylist,
ToggleHelp, ToggleHelp,
ToggleViewMode, ToggleViewMode,
CycleSourceMode,
OpenLibraryFilters, OpenLibraryFilters,
OpenCommandLine, OpenCommandLine,
OpenSearch, OpenSearch,
@@ -128,9 +129,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,
} }
@@ -197,6 +199,7 @@ impl Action {
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(),
+34 -6
View File
@@ -425,14 +425,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 +1298,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();
@@ -3410,10 +3435,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")
+8 -27
View File
@@ -404,45 +404,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 }),
} }
+72 -16
View File
@@ -48,6 +48,8 @@ pub enum Effect {
}, },
/// Queue/options changed without a direct audio engine action. /// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged, PlaybackQueueChanged,
/// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged,
/// Persist the federation settings and start/stop the node. /// Persist the federation settings and start/stop the node.
FedApplySettings, FedApplySettings,
/// Force an immediate library publish into the DHT. /// Force an immediate library publish into the DHT.
@@ -239,6 +241,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 +934,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 +945,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 +979,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 +1003,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()
} }
@@ -1175,21 +1191,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 +1361,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 +1821,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 +1863,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 +1879,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 +1937,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 +2007,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;
} }
+159
View File
@@ -41,6 +41,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 +132,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 +434,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 {
+4
View File
@@ -225,6 +225,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"
+15
View File
@@ -426,6 +426,21 @@ mod tests {
); );
} }
#[test]
fn default_source_mode_key_resolves_on_content_tabs() {
let mut km = keymap_from(DEFAULT_KEYMAP);
for context in [
KeyContext::Library,
KeyContext::Playlists,
KeyContext::Queue,
] {
assert_eq!(
km.resolve(key!(m), context),
KeyResolution::Action(Action::CycleSourceMode)
);
}
}
#[test] #[test]
fn default_shift_n_is_unbound() { fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP); let mut km = keymap_from(DEFAULT_KEYMAP);
+8 -21
View File
@@ -11,12 +11,6 @@ pub enum LibrarySourceMode {
} }
impl LibrarySourceMode { impl LibrarySourceMode {
pub const ALL: [LibrarySourceMode; 3] = [
LibrarySourceMode::Local,
LibrarySourceMode::My,
LibrarySourceMode::Global,
];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
LibrarySourceMode::Local => "Local", LibrarySourceMode::Local => "Local",
@@ -25,14 +19,6 @@ impl LibrarySourceMode {
} }
} }
pub fn description(self) -> &'static str {
match self {
LibrarySourceMode::Local => "only this device",
LibrarySourceMode::My => "this device + connected devices",
LibrarySourceMode::Global => "my devices + known federation peers",
}
}
pub fn includes_network(self) -> bool { pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local) !matches!(self, LibrarySourceMode::Local)
} }
@@ -40,6 +26,14 @@ impl LibrarySourceMode {
pub fn includes_global_peers(self) -> bool { pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global) matches!(self, LibrarySourceMode::Global)
} }
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -50,12 +44,6 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode, pub source_mode: LibrarySourceMode,
} }
impl LibraryFilters {
pub fn is_active(&self) -> bool {
self.hide_featured_only || self.source_mode != LibrarySourceMode::Local
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings { pub struct AppSettings {
#[serde(default = "default_volume")] #[serde(default = "default_volume")]
@@ -140,7 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100); assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only); assert!(settings.library.hide_featured_only);
assert!(settings.library.is_active());
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local); assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
} }
} }
+10 -6
View File
@@ -1201,7 +1201,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 +1222,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 {
+3 -3
View File
@@ -383,15 +383,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)));
} }
+3 -2
View File
@@ -247,8 +247,9 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player; let player = &state.player;
let block = Block::bordered() let block = Block::bordered()
.title(format!( .title(format!(
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ", " Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len() player.queue.len(),
state.global.filters.source_mode.label()
)) ))
.title_style(theme::header_for(state)) .title_style(theme::header_for(state))
.border_style(theme::border_for(state)); .border_style(theme::border_for(state));
+18 -3
View File
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
} }
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, state, " Playlists ".to_string()); let inner = bordered(
frame,
area,
state,
format!(
" Playlists · Mode: {} ",
state.global.filters.source_mode.label()
),
);
let selected = state.playlists.selected; let selected = state.playlists.selected;
let list = match &state.playlists.list { let list = match &state.playlists.list {
@@ -97,8 +105,15 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id); let loadable = state.playlist_views.get(&id);
let title = match loadable { let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title), Some(Loadable::Ready(detail)) => format!(
_ => " Playlists ▸ ".to_string(), " Playlists ▸ {} · Mode: {} ",
detail.title,
state.global.filters.source_mode.label()
),
_ => format!(
" Playlists ▸ … · Mode: {} ",
state.global.filters.source_mode.label()
),
}; };
let inner = bordered(frame, area, state, title); let inner = bordered(frame, area, state, title);
+2 -14
View File
@@ -598,7 +598,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 +608,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 +624,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,