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:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs:
build:
@@ -43,8 +44,11 @@ jobs:
- name: Show Rust version
run: rustc --version && cargo --version
- name: Fetch locked dependencies
run: cargo fetch --locked
- name: Build
run: cargo build --release --locked
run: cargo build --release --locked --offline
- name: Package
shell: bash
+1 -1
View File
@@ -29,7 +29,7 @@ Preserve these architectural invariants:
## 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:
Generated
+16 -40
View File
@@ -1084,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 2.0.119",
"syn 1.0.109",
]
[[package]]
@@ -1188,7 +1188,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1345,7 +1345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1456,7 +1456,7 @@ dependencies = [
[[package]]
name = "federation-net"
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 = [
"blake3",
"data-encoding",
@@ -1567,7 +1567,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.1.6"
version = "0.1.8"
dependencies = [
"anyhow",
"blake3",
@@ -2990,7 +2990,7 @@ dependencies = [
[[package]]
name = "music-dht"
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 = [
"async-trait",
"blake3",
@@ -3315,7 +3315,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3390,7 +3390,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -4028,16 +4028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[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",
"toml_edit",
]
[[package]]
@@ -4491,7 +4482,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4549,7 +4540,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4641,7 +4632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5289,7 +5280,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5618,18 +5609,6 @@ dependencies = [
"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]]
name = "toml_parser"
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"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -6533,9 +6512,6 @@ name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
@@ -6678,7 +6654,7 @@ version = "3.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate",
"proc-macro2",
"quote",
"regex",
@@ -6832,7 +6808,7 @@ version = "3.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 1.0.109",
+3 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "furumi_tui"
version = "0.1.6"
version = "0.1.8"
edition = "2024"
rust-version = "1.88"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
license = "WTFPL"
@@ -21,7 +21,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between
# 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"
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"] }
+19 -2
View File
@@ -71,8 +71,25 @@ without rebuilding the player.
## Install
Download a prebuilt archive from the project releases, or build Furumi from
source with Rust 1.97 or newer:
### macOS
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
cargo build --release --locked
+6 -3
View File
@@ -48,6 +48,7 @@ pub enum Action {
NewPlaylist,
ToggleHelp,
ToggleViewMode,
CycleSourceMode,
OpenLibraryFilters,
OpenCommandLine,
OpenSearch,
@@ -128,9 +129,10 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => {
Category::Library
}
Action::EditSelected
| Action::DeleteSelected
| Action::CycleSourceMode
| Action::OpenLibraryFilters => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::ToggleHelp | Action::Quit => Category::System,
}
@@ -197,6 +199,7 @@ impl Action {
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
Action::OpenLibraryFilters => "Library filters…".into(),
Action::OpenCommandLine => "Command line (:help for commands)".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,
library: Option<&Library>,
) {
state.player.queue = wire
let queue: Vec<_> = wire
.queue
.iter()
.map(|track| playback_track_to_ui(track, library))
.collect();
state.player.queue_pos = wire
.queue_pos
.min(state.player.queue.len().saturating_sub(1));
let queue_pos = queue
.iter()
.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.paused = wire.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::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 } => {
let library = Arc::clone(&runtime.library);
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 } => {
let count = tracks.len();
let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len);
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")
} else {
format!("{count} tracks queued")
+8 -27
View File
@@ -404,45 +404,26 @@ fn handle_connected_devices(
fn handle_library_filters(
state: &mut AppState,
runtime: &mut Runtime,
cursor: usize,
_cursor: usize,
key: KeyEvent,
) {
let max_cursor = crate::config::settings::LibrarySourceMode::ALL.len();
let cursor = cursor.min(max_cursor);
let cursor = 0;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::LibraryFilters {
cursor: cursor.saturating_sub(1),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::LibraryFilters {
cursor: (cursor + 1).min(max_cursor),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Enter | KeyCode::Char(' ') => {
if cursor == 0 {
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();
}
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
super::save_app_settings(state);
super::reset_artist_pagination(state);
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 }),
}
+72 -16
View File
@@ -48,6 +48,8 @@ pub enum Effect {
},
/// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged,
/// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged,
/// Persist the federation settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
@@ -239,6 +241,18 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
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 => {
if state.active_tab == Tab::Global {
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 {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -931,23 +945,25 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Some((
TrackSelectionScope::ArtistTop(*id),
*cursor,
&detail.top_tracks,
detail.top_tracks.iter().collect(),
))
} else {
let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id),
featured,
&detail.featured_tracks,
detail.featured_tracks.iter().collect(),
))
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => {
Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks))
}
Loadable::Ready(detail) => Some((
TrackSelectionScope::Release(*id),
*cursor,
detail.tracks.iter().collect(),
)),
_ => None,
},
_ => None,
@@ -963,7 +979,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Tab::Queue => Some((
TrackSelectionScope::Queue,
state.queue_tab.cursor,
&state.player.queue,
state.player.queue.iter().collect(),
)),
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))]);
indices
.into_iter()
.filter_map(|index| tracks.get(index).cloned())
.filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
.collect()
}
@@ -1175,21 +1191,32 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)?
.get(opened.cursor)
.cloned()
.map(|track| (*track).clone())
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Federation | Tab::Logs => None,
}
}
/// Tracks backing an opened playlist, if loaded.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec<TrackItem>> {
/// Visible tracks backing an opened playlist. Local mode excludes pending
/// 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)? {
Loadable::Ready(detail) => Some(&detail.tracks),
Loadable::Ready(detail) => Some(
detail
.tracks
.iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect(),
),
_ => 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).
fn selected_release_id(state: &AppState) -> Option<i64> {
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
/// gapless prefetch index pointing at the same track if items shift.
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;
if tracks.is_empty() {
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).
fn playlists_view_len(state: &AppState) -> usize {
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 {
Some(Loadable::Ready(list)) => list.len(),
_ => 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();
let len = current_view_len(state);
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));
}
} 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() {
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() {
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(detail))
@@ -1885,6 +1937,7 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
if let Some(message) = message {
state.status_message = Some(message);
}
queue_effect
}
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> {
match state.playlists.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() {
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]
fn quit_needs_double_press() {
let mut state = AppState::default();
@@ -115,6 +132,36 @@ fn library_filters_popup_opens_on_library_screens() {
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]
fn back_closes_help_first() {
let mut state = AppState::default();
@@ -387,6 +434,118 @@ fn queue_tab_select_and_clear() {
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]
fn current_track_info_uses_now_playing_track() {
let mut state = AppState {
+4
View File
@@ -225,6 +225,10 @@ command = "DeleteSelected"
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = "m"
command = "CycleSourceMode"
[[keymaps]]
key_sequence = "f"
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]
fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP);
+8 -21
View File
@@ -11,12 +11,6 @@ pub enum LibrarySourceMode {
}
impl LibrarySourceMode {
pub const ALL: [LibrarySourceMode; 3] = [
LibrarySourceMode::Local,
LibrarySourceMode::My,
LibrarySourceMode::Global,
];
pub fn label(self) -> &'static str {
match self {
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 {
!matches!(self, LibrarySourceMode::Local)
}
@@ -40,6 +26,14 @@ impl LibrarySourceMode {
pub fn includes_global_peers(self) -> bool {
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)]
@@ -50,12 +44,6 @@ pub struct LibraryFilters {
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)]
pub struct AppSettings {
#[serde(default = "default_volume")]
@@ -140,7 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only);
assert!(settings.library.is_active());
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
}
}
+10 -6
View File
@@ -1201,7 +1201,11 @@ impl Library {
.get(&track.id)
.copied()
.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();
@@ -1218,11 +1222,11 @@ impl Library {
})?;
for row in fed_rows {
let (liked_at, fed) = row?;
entries.push((
liked_at,
fed.title.clone(),
crate::federation::pending_track(&fed),
));
let stable_key = fed
.content_id
.clone()
.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)));
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 title = if global.total > 0 {
format!(
" Library — {} artists · {} ",
" Library — {} artists · Mode: {} ",
global.total,
global.filters.source_mode.label()
)
} 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))];
if global.filters.is_active() {
if global.filters.hide_featured_only {
title_spans.push(Span::raw(" "));
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 block = Block::bordered()
.title(format!(
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len()
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len(),
state.global.filters.source_mode.label()
))
.title_style(theme::header_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) {
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 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) {
let loadable = state.playlist_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
_ => " Playlists ▸ ".to_string(),
Some(Loadable::Ready(detail)) => format!(
" 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);
+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) {
let area = centered(frame.area(), 54, 9);
let area = centered(frame.area(), 44, 6);
let block = Block::bordered()
.title(" Library filters ")
.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);
let [list_area, _, footer] = Layout::vertical([
Constraint::Length(4),
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::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() {
let row = Rect {
y: list_area.y + index as u16,