Reworked MODE switch
This commit is contained in:
+6
-3
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -225,6 +225,10 @@ command = "DeleteSelected"
|
||||
key_sequence = "v"
|
||||
command = "ToggleViewMode"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "m"
|
||||
command = "CycleSourceMode"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "f"
|
||||
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]
|
||||
fn default_shift_n_is_unbound() {
|
||||
let mut km = keymap_from(DEFAULT_KEYMAP);
|
||||
|
||||
+8
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user