Files
furumi_tui/src/app/update.rs
T

2819 lines
104 KiB
Rust
Raw Normal View History

2026-06-10 16:11:09 +01:00
use std::time::{Duration, Instant};
use super::action::Action;
2026-07-16 20:29:51 +03:00
use crate::library::models::TrackItem;
2026-06-10 16:11:09 +01:00
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
2026-07-25 23:57:03 +03:00
TrackSelectionScope, ViewMode, artist_merged_releases, artist_release_display_order,
artist_release_rows, fed_artist_visible_appearance_indices, fed_artist_visible_release_indices,
fed_release_display_order, fed_release_rows, fed_release_visible_track_indices, settings_rows,
track_content_id, track_key,
2026-06-10 16:11:09 +01:00
};
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit";
/// Side effects requested by `update()`; executed by the app loop, which
/// owns the Runtime (audio controller, API client). Keeps update() pure.
#[derive(Debug, Clone, PartialEq, Eq)]
2026-06-10 16:11:09 +01:00
pub enum Effect {
/// (Re)start playback of `queue[queue_pos]`.
PlayCurrent,
TogglePause,
StopPlayback,
/// Seek relative to the current position, in seconds.
SeekBy(i64),
SetVolume(u8),
SetOptions,
2026-06-10 16:11:09 +01:00
/// Fetch a release and append all its tracks to the queue.
EnqueueRelease {
id: i64,
next: bool,
},
ToggleLikes {
track_ids: Vec<i64>,
2026-07-17 15:29:10 +03:00
fed_tracks: Vec<crate::federation::FedTrack>,
},
2026-07-16 20:29:51 +03:00
/// Remove tracks from a stored playlist in the library.
RemoveFromPlaylist {
playlist_id: i64,
track_ids: Vec<i64>,
2026-07-24 03:06:20 +03:00
content_ids: Vec<String>,
2026-07-16 20:29:51 +03:00
},
RemoveQueueIndices {
indices: Vec<usize>,
restart_paused: Option<bool>,
stop: bool,
},
2026-07-24 04:11:01 +03:00
/// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged,
2026-07-23 18:48:58 +03:00
/// Persist the federation settings and start/stop the node.
2026-07-16 20:29:51 +03:00
FedApplySettings,
/// Force an immediate library publish into the DHT.
FedSyncNow,
/// Fetch this peer's ticket and show it in a popup.
FedShowTicket,
2026-07-24 00:54:27 +03:00
/// Generate a personal-device invite and show it in a popup.
DeviceShowInvite,
/// Connect this client to a trusted device by opaque frid invite.
DeviceConnectInvite(String),
/// Force an immediate personal-device sync.
DeviceSyncNow,
/// Persist and publish this device's display name.
DeviceSetName(String),
/// Revoke a trusted device.
DeviceRevoke(String),
2026-07-26 01:41:16 +03:00
/// Leave the current personal-device group after publishing self-revoke.
DeviceLeaveGroup,
2026-07-17 01:17:54 +03:00
/// Assemble the federated artist card (fan-out to the owning peers).
FedOpenArtist(String),
2026-07-17 01:43:09 +03:00
/// Download federated tracks into the local library, one by one.
FedDownload {
tracks: Vec<crate::federation::FedTrack>,
},
2026-07-21 00:12:45 +03:00
/// Fetch richer metadata for federated tracks without downloading audio.
FedFetchTrackInfo {
tracks: Vec<(i64, crate::federation::FedTrack)>,
},
2026-07-23 18:48:58 +03:00
/// Temporarily leaves the TUI and opens a visualization script in $EDITOR.
OpenVisualizerEditor {
path: std::path::PathBuf,
},
2026-06-10 16:11:09 +01:00
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
// Any action other than a second Quit press disarms the confirmation.
let quit_armed = state
.quit_armed_until
.take()
.is_some_and(|deadline| Instant::now() <= deadline);
state.status_message = None;
match action {
// While the help window is open, quit/back just close it.
Action::Quit | Action::Back if state.help_visible => {
state.help_visible = false;
None
}
2026-06-10 16:11:09 +01:00
Action::Quit => {
if quit_armed {
state.should_quit = true;
} else {
state.quit_armed_until = Some(Instant::now() + QUIT_CONFIRM_WINDOW);
state.status_message = Some(QUIT_CONFIRM_HINT.to_string());
}
None
}
Action::ToggleHelp => {
state.help_visible = !state.help_visible;
None
}
2026-07-24 04:11:01 +03:00
Action::OpenConnectedDevices => {
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
None
}
2026-06-10 16:11:09 +01:00
Action::NextTab => {
switch_tab(state, state.active_tab.next());
None
}
Action::PrevTab => {
switch_tab(state, state.active_tab.prev());
None
}
Action::GoToTab(index) => {
if let Some(tab) = Tab::from_index(index) {
// Pressing the current tab's number again resets it to its
// top level (closes drill-down views).
if tab == state.active_tab {
reset_tab(state, tab);
} else {
switch_tab(state, tab);
}
}
None
}
Action::PlayPause => {
if state.player.current.is_some() {
state.player.paused = !state.player.paused;
Some(Effect::TogglePause)
} else if state.player.queue.is_empty() {
state.status_message = Some("nothing queued — open a track and press enter".into());
None
} else {
Some(Effect::PlayCurrent)
}
}
Action::NextTrack => queue_step(state, 1),
Action::PrevTrack => queue_step(state, -1),
Action::SeekForward { seconds } => state
.player
.current
.is_some()
.then_some(Effect::SeekBy(seconds as i64)),
2026-06-10 16:11:09 +01:00
Action::SeekBackward { seconds } => state
.player
.current
.is_some()
.then_some(Effect::SeekBy(-(seconds as i64))),
Action::VolumeUp => {
state.player.volume = (state.player.volume + 5).min(100);
Some(Effect::SetVolume(state.player.volume))
}
Action::VolumeDown => {
state.player.volume = state.player.volume.saturating_sub(5);
Some(Effect::SetVolume(state.player.volume))
}
Action::ToggleShuffle => {
state.player.shuffle = !state.player.shuffle;
// Shuffle physically reorders the unplayed tail, so the Queue
// tab always shows the real upcoming order; turning it off
// restores the original ordering.
if state.player.shuffle {
shuffle_upcoming(&mut state.player);
} else {
restore_queue_order(&mut state.player);
}
Some(Effect::SetOptions)
2026-06-10 16:11:09 +01:00
}
Action::CycleRepeat => {
state.player.repeat = state.player.repeat.next();
Some(Effect::SetOptions)
2026-06-10 16:11:09 +01:00
}
2026-07-23 18:48:58 +03:00
Action::ToggleVisualizer => {
if state.visualizer.active {
state.visualizer.close();
} else if state.player.current.is_some() {
state.visualizer.open();
state.help_visible = false;
state.popup = None;
state.cmdline.active = false;
state.pending_keys = None;
} else {
state.status_message = Some("nothing playing — start a track first".into());
}
None
}
2026-06-10 16:11:09 +01:00
Action::MoveUp => {
move_selection(state, 0, -1);
None
}
Action::MoveDown => {
move_selection(state, 0, 1);
None
}
Action::MoveLeft => {
move_selection(state, -1, 0);
None
}
Action::MoveRight => {
move_selection(state, 1, 0);
None
}
Action::PageUp => {
move_selection(state, 0, -page_step(state));
None
}
Action::PageDown => {
move_selection(state, 0, page_step(state));
None
}
Action::SelectFirst => {
jump_selection(state, true);
None
}
Action::SelectLast => {
jump_selection(state, false);
None
}
Action::ToggleViewMode => {
2026-06-10 16:23:20 +01:00
match state.active_tab {
Tab::Global => state.global.view = state.global.view.toggle(),
// On the Logs tab the same key cycles the severity filter.
Tab::Logs => {
state.logs.level_index =
(state.logs.level_index + 1) % super::state::LOG_LEVELS.len();
state.logs.selected_seq = None;
2026-06-10 16:23:20 +01:00
state.logs.follow = true;
}
_ => {}
2026-06-10 16:11:09 +01:00
}
None
}
Action::OpenLibraryFilters => {
2026-07-25 23:57:03 +03:00
if state.active_tab == Tab::Global {
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
}
None
}
2026-06-10 16:11:09 +01:00
Action::OpenCommandLine => {
state.cmdline.active = true;
state.cmdline.input.clear();
None
}
Action::OpenSearch => {
// The command line opens pre-filled with "/": typing continues
// the live search, exactly as if `:` then `/` were pressed.
state.cmdline.active = true;
2026-07-17 01:17:54 +03:00
state.cmdline.input = crate::app::input::LineEdit::new("/");
state.cmdline.live = true;
state.search = SearchState::default();
state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
state.global.stack.push(GlobalView::Search { cursor: 0 });
}
None
}
2026-06-10 16:11:09 +01:00
Action::Select => select_current(state),
2026-07-23 18:48:58 +03:00
Action::Back if state.visualizer.active => {
state.visualizer.close();
None
}
Action::Back if state.track_selection.is_active() => {
state.track_selection.clear();
state.status_message = Some("selection cleared".into());
None
}
2026-06-10 16:11:09 +01:00
Action::Back => {
go_back(state);
None
}
Action::ToggleLike => {
2026-07-17 15:29:10 +03:00
let mut tracks = selected_tracks(state);
if tracks.is_empty() {
tracks = state
.player
.current
.as_ref()
2026-07-17 15:29:10 +03:00
.map(|track| vec![track.clone()])
.unwrap_or_default();
}
2026-07-25 01:23:29 +03:00
let mut local_tracks: Vec<TrackItem> = Vec::new();
2026-07-17 15:29:10 +03:00
let mut fed_tracks: Vec<crate::federation::FedTrack> = Vec::new();
2026-07-25 01:23:29 +03:00
let mut seen = std::collections::HashSet::new();
2026-07-17 15:29:10 +03:00
for track in tracks {
2026-07-25 01:23:29 +03:00
if !seen.insert(track_key(&track)) {
continue;
}
2026-07-17 15:29:10 +03:00
match &track.fed {
Some(fed) => fed_tracks.push(fed.clone()),
2026-07-25 01:23:29 +03:00
None if track.id >= 0 && track_content_id(&track).is_some() => {
local_tracks.push(track)
}
2026-07-17 15:29:10 +03:00
None => {}
}
}
2026-07-25 01:23:29 +03:00
if local_tracks.is_empty() && fed_tracks.is_empty() {
state.status_message = Some("no track selected".into());
None
} else {
2026-07-25 01:23:29 +03:00
let should_like = local_tracks.iter().any(|track| !state.track_liked(track))
|| fed_tracks.iter().any(|fed| !state.fed_track_liked(fed));
2026-07-25 01:23:29 +03:00
let toggles: Vec<i64> = local_tracks
.into_iter()
2026-07-25 01:23:29 +03:00
.filter(|track| state.track_liked(track) != should_like)
.map(|track| track.id)
.collect();
2026-07-17 15:29:10 +03:00
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
.into_iter()
.filter(|fed| state.fed_track_liked(fed) != should_like)
2026-07-17 15:29:10 +03:00
.collect();
let total = toggles.len() + fed_toggles.len();
state.status_message = Some(if should_like {
2026-07-17 15:29:10 +03:00
format!("liking {total} track(s)")
} else {
2026-07-17 15:29:10 +03:00
format!("removing like from {total} track(s)")
});
2026-07-17 15:29:10 +03:00
Some(Effect::ToggleLikes {
track_ids: toggles,
fed_tracks: fed_toggles,
})
2026-06-10 16:11:09 +01:00
}
}
Action::ToggleTrackSelection => {
toggle_track_selection(state);
None
}
Action::OpenTrackInfo => {
let tracks = selected_tracks(state);
2026-07-23 17:35:11 +03:00
open_track_info(state, tracks, "no track selected")
}
Action::OpenCurrentTrackInfo => {
let tracks = state.player.current.clone().into_iter().collect();
open_track_info(state, tracks, "nothing playing")
}
Action::RemoveFromQueue => remove_selected_from_queue(state),
2026-06-10 16:11:09 +01:00
Action::QueueAddNext => queue_add(state, true),
Action::QueueAddLast => queue_add(state, false),
2026-06-10 17:01:40 +01:00
Action::GoToRelease => {
let track = selected_track(state).or_else(|| state.player.current.clone());
match track {
Some(track) => open_release_for_track(state, &track),
None => state.status_message = Some("no track selected".into()),
}
None
}
Action::AddToPlaylist => {
2026-07-23 17:35:11 +03:00
let target = selected_playlist_target(state, true);
2026-07-17 01:43:09 +03:00
match target {
Some(target) => {
state.popup = Some(super::state::Popup::AddToPlaylist { target, cursor: 0 });
state.track_selection.clear();
}
None => state.status_message = Some("no track selected".into()),
}
None
}
2026-07-17 01:43:09 +03:00
Action::DownloadSelected => {
2026-07-25 01:23:29 +03:00
let tracks = selected_downloadable_fed_tracks(state);
2026-07-17 01:43:09 +03:00
if tracks.is_empty() {
2026-07-25 01:23:29 +03:00
state.status_message = Some("select federated tracks first".into());
2026-07-17 01:43:09 +03:00
None
} else {
state.track_selection.clear();
state.status_message = Some(format!(
2026-07-17 01:48:41 +03:00
"federation: downloading {} track(s) to the library…",
2026-07-17 01:43:09 +03:00
tracks.len()
));
Some(Effect::FedDownload { tracks })
}
}
Action::NewPlaylist => {
2026-07-23 17:35:11 +03:00
let for_target = selected_playlist_target(state, false);
state.popup = Some(super::state::Popup::NewPlaylist {
2026-07-23 17:35:11 +03:00
for_target,
2026-07-17 01:17:54 +03:00
input: crate::app::input::LineEdit::default(),
busy: false,
});
2026-07-23 17:35:11 +03:00
state.track_selection.clear();
None
}
Action::ClearQueue => {
let had_tracks = !state.player.queue.is_empty();
state.player.queue.clear();
state.player.queue_pos = 0;
state.player.current = None;
state.player.playing = false;
state.player.paused = false;
state.player.prefetched_pos = None;
state.player.original_order = None;
state.queue_tab.cursor = 0;
state.track_selection.clear();
if had_tracks {
state.status_message = Some("queue cleared".into());
Some(Effect::StopPlayback)
} else {
None
}
}
2026-07-16 20:29:51 +03:00
Action::EditSelected => {
open_edit_popup(state);
None
}
Action::DeleteSelected => delete_selected(state),
}
}
2026-07-21 00:12:45 +03:00
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
track.fed.is_some()
&& (track.featured_artists.is_empty()
|| track.audio_format.is_none()
|| track.audio_bitrate.is_none()
|| track.audio_sample_rate.is_none()
|| track.audio_bit_depth.is_none()
|| track.file_size_bytes.is_none()
|| track.file_path.is_empty())
}
2026-07-23 17:35:11 +03:00
fn open_track_info(
state: &mut AppState,
tracks: Vec<TrackItem>,
empty_message: &'static str,
) -> Option<Effect> {
if tracks.is_empty() {
state.status_message = Some(empty_message.into());
return None;
}
let fed_tracks = tracks
.iter()
.filter(|track| track_info_needs_fed_metadata(track))
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
.collect::<Vec<_>>();
state.popup = Some(super::state::Popup::TrackInfo {
tracks,
cursor: 0,
scroll: 0,
});
if fed_tracks.is_empty() {
None
} else {
state.status_message = Some("federation: fetching track metadata…".to_string());
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
}
}
fn selected_playlist_target(
state: &AppState,
include_current: bool,
) -> Option<super::state::PlaylistAddTarget> {
let fed = selected_fed_tracks(state);
if !fed.is_empty() {
return Some(super::state::PlaylistAddTarget::Fed(fed));
}
let local = selected_tracks(state);
if !local.is_empty() {
return Some(super::state::PlaylistAddTarget::Local(local));
}
include_current
.then(|| state.player.current.clone())
.flatten()
.map(|track| super::state::PlaylistAddTarget::Local(vec![track]))
}
2026-07-16 20:29:51 +03:00
/// `e`: open the metadata edit form for whatever is under the cursor —
/// an artist tile, a release, a track or a playlist.
fn open_edit_popup(state: &mut AppState) {
use super::state::{EditField, EditTarget, Popup};
if state.active_tab == Tab::Playlists && state.playlists.opened.is_none() {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let Some(card) = card else {
return;
};
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be edited".into());
return;
}
state.popup = Some(Popup::Edit {
target: EditTarget::Playlist(card.id),
title: format!("Edit playlist — {}", card.title),
fields: vec![EditField::new("Title", card.title.clone())],
focus: 0,
error: None,
});
return;
}
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
return;
};
2026-07-25 22:40:27 +03:00
if artist.id < 0 {
state.status_message = Some("remote artists cannot be edited here".into());
return;
}
2026-07-21 00:12:45 +03:00
state.popup = Some(artist_edit_popup(
artist.id,
&artist.name,
artist.image_path,
));
2026-07-16 20:29:51 +03:00
return;
}
if let Some(artist) = selected_search_artist(state) {
2026-07-21 00:12:45 +03:00
state.popup = Some(artist_edit_popup(
artist.id,
&artist.name,
artist.image_path,
));
2026-07-16 20:29:51 +03:00
return;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::Edit {
target: EditTarget::Release(release.id),
title: format!("Edit release — {}", release.title),
fields: vec![
EditField::new("Title", release.title.clone()),
EditField::new("Type", release.release_type.clone()),
EditField::new(
"Year",
release.year.map(|y| y.to_string()).unwrap_or_default(),
),
],
focus: 0,
error: None,
});
return;
}
if let Some(track) = selected_track(state).or_else(|| state.player.current.clone()) {
state.popup = Some(track_edit_popup(&track));
return;
}
state.status_message = Some("nothing to edit here".into());
}
2026-07-21 00:12:45 +03:00
fn artist_edit_popup(id: i64, name: &str, image_path: Option<String>) -> super::state::Popup {
2026-07-16 20:29:51 +03:00
use super::state::{EditField, EditTarget, Popup};
Popup::Edit {
target: EditTarget::Artist(id),
title: format!("Edit artist — {name}"),
fields: vec![
EditField::new("Name", name),
EditField::new("Image path", image_path.unwrap_or_default()),
],
focus: 0,
error: None,
}
}
fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
let join = |artists: &[crate::library::models::ArtistRef]| {
artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<_>>()
.join("; ")
};
Popup::Edit {
target: EditTarget::Track(track.id),
title: format!("Edit track — {}", track.title),
fields: vec![
EditField::new("Title", track.title.clone()),
EditField::new("Artists", join(&track.artists)),
EditField::new("Featured", join(&track.featured_artists)),
EditField::new(
"Track #",
2026-07-21 00:12:45 +03:00
track
.track_number
.map(|n| n.to_string())
.unwrap_or_default(),
2026-07-16 20:29:51 +03:00
),
EditField::new(
"Disc #",
track.disc_number.map(|n| n.to_string()).unwrap_or_default(),
),
2026-07-17 00:00:34 +03:00
EditField::new("Cover path", track.cover_path.clone().unwrap_or_default()),
2026-07-16 20:29:51 +03:00
],
focus: 0,
error: None,
}
}
/// shift-d: delete whatever is under the cursor. Library entities ask for
/// confirmation; playlist/queue rows are removed directly.
fn delete_selected(state: &mut AppState) -> Option<Effect> {
use super::state::{DeleteTarget, Popup};
if state.active_tab == Tab::Queue {
return remove_selected_from_queue(state);
}
if state.active_tab == Tab::Playlists {
match state.playlists.opened {
None => {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let card = card?;
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be deleted".into());
return None;
}
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Playlist(card.id),
label: format!("playlist \"{}\"", card.title),
});
return None;
}
Some(opened) => {
let tracks = selected_tracks(state);
if tracks.is_empty() {
state.status_message = Some("no track selected".into());
return None;
}
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
2026-07-25 01:23:29 +03:00
let content_ids: Vec<String> = tracks.iter().filter_map(track_content_id).collect();
2026-07-16 20:29:51 +03:00
state.track_selection.clear();
if opened.id == super::state::LIKES_PLAYLIST_ID {
2026-07-25 01:23:29 +03:00
let mut seen = std::collections::HashSet::new();
let mut liked = Vec::new();
let mut fed_tracks = Vec::new();
for track in tracks {
if !state.track_liked(&track) || !seen.insert(track_key(&track)) {
continue;
}
if let Some(fed) = track.fed {
fed_tracks.push(fed);
} else if track.id >= 0 && track_content_id(&track).is_some() {
liked.push(track.id);
}
}
let total = liked.len() + fed_tracks.len();
state.status_message = Some(format!("removing {} like(s)", total));
2026-07-17 15:29:10 +03:00
return Some(Effect::ToggleLikes {
track_ids: liked,
2026-07-25 01:23:29 +03:00
fed_tracks,
2026-07-17 15:29:10 +03:00
});
2026-07-16 20:29:51 +03:00
}
2026-07-25 01:23:29 +03:00
let content_ids: Vec<String> = content_ids
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let track_ids: Vec<i64> = track_ids.into_iter().filter(|id| *id >= 0).collect();
2026-07-21 00:12:45 +03:00
state.status_message = Some(format!(
"removing {} track(s) from playlist",
2026-07-25 01:23:29 +03:00
content_ids.len().max(track_ids.len())
2026-07-21 00:12:45 +03:00
));
2026-07-16 20:29:51 +03:00
return Some(Effect::RemoveFromPlaylist {
playlist_id: opened.id,
track_ids,
2026-07-24 03:06:20 +03:00
content_ids,
2026-07-16 20:29:51 +03:00
});
}
}
}
if state.active_tab != Tab::Global {
2026-07-24 04:11:01 +03:00
return Some(Effect::PlaybackQueueChanged);
2026-07-16 20:29:51 +03:00
}
if state.global.stack.is_empty() {
let artist = state.global.artists.get(state.global.selected).cloned()?;
2026-07-25 22:40:27 +03:00
if artist.id < 0 {
state.status_message = Some("remote artists cannot be deleted here".into());
return None;
}
2026-07-16 20:29:51 +03:00
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
2026-07-21 00:12:45 +03:00
label: format!(
"artist \"{}\" with all their releases and tracks",
artist.name
),
2026-07-16 20:29:51 +03:00
});
return None;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
2026-07-21 00:12:45 +03:00
label: format!(
"artist \"{}\" with all their releases and tracks",
artist.name
),
2026-07-16 20:29:51 +03:00
});
return None;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Release(release.id),
label: format!("release \"{}\" with all its tracks", release.title),
});
return None;
}
if let Some(track) = selected_track(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Track(track.id),
label: format!("track \"{}\" (the audio file stays on disk)", track.title),
});
} else {
state.status_message = Some("nothing to delete here".into());
}
None
}
/// The artist under the cursor in the search view, if any.
fn selected_search_artist(state: &AppState) -> Option<crate::library::models::ArtistCard> {
if state.active_tab != Tab::Global {
return None;
}
let Some(GlobalView::Search { cursor }) = state.global.stack.last() else {
return None;
};
state.search.results.as_ref()?.artists.get(*cursor).cloned()
}
/// The release card under the cursor (artist view tiles/rows or search).
fn selected_release_card(state: &AppState) -> Option<crate::library::models::ReleaseCard> {
if state.active_tab != Tab::Global {
return None;
}
match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let position = cursor.checked_sub(detail.top_tracks.len())?;
2026-07-25 23:57:03 +03:00
let merged = artist_merged_releases(state, *id, detail);
let order = artist_release_display_order(&merged);
2026-07-16 20:29:51 +03:00
order
.get(position)
2026-07-25 23:57:03 +03:00
.and_then(|&index| merged[index].local_index)
.map(|index| detail.releases[index].clone())
2026-07-16 20:29:51 +03:00
}
_ => None,
},
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).cloned()
}
2026-07-21 00:12:45 +03:00
GlobalView::Release { .. }
| GlobalView::FedArtist { .. }
| GlobalView::FedRelease { .. } => None,
2026-06-10 16:11:09 +01:00
}
}
fn toggle_track_selection(state: &mut AppState) {
let Some((scope, cursor, len)) = current_track_list_context(state) else {
state.status_message = Some("track selection is unavailable here".into());
return;
};
if len == 0 {
state.status_message = Some("no tracks here".into());
return;
}
if state.track_selection.is_active_for(&scope) {
state.track_selection.clear();
state.status_message = Some("selection cleared".into());
} else {
state.track_selection.start(scope, cursor);
visual_selection_status(state);
}
}
fn visual_selection_status(state: &mut AppState) {
let Some((scope, _, len)) = current_track_list_context(state) else {
return;
};
if let Some(indices) = state.track_selection.indices(&scope, len) {
state.status_message = Some(format!("-- VISUAL LINE -- {} track(s)", indices.len()));
}
}
fn refresh_track_selection_cursor(state: &mut AppState) {
let Some((scope, cursor, _)) = current_track_list_context(state) else {
state.track_selection.clear();
return;
};
state.track_selection.set_cursor(scope, cursor);
if state.track_selection.is_active() {
visual_selection_status(state);
}
}
fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, value: usize) {
match scope {
TrackSelectionScope::ArtistTop(id) => {
if matches!(
state.global.stack.last(),
Some(GlobalView::Artist { id: current, .. }) if current == id
) {
set_view_cursor(state, value);
}
}
TrackSelectionScope::ArtistFeatured(id) => {
let Some(GlobalView::Artist { id: current, .. }) = state.global.stack.last() else {
return;
};
if current != id {
return;
}
let Some(Loadable::Ready(detail)) = state.artist_views.get(id) else {
return;
};
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, detail).len();
let flat = detail.top_tracks.len() + releases + value;
set_view_cursor(state, flat);
}
TrackSelectionScope::Release(id) => {
if matches!(
state.global.stack.last(),
Some(GlobalView::Release { id: current, .. }) if current == id
) {
set_view_cursor(state, value);
}
}
TrackSelectionScope::Playlist(id) => {
2026-07-16 20:29:51 +03:00
if let Some(opened) = &mut state.playlists.opened
2026-07-21 00:12:45 +03:00
&& opened.id == *id
{
opened.cursor = value;
}
}
TrackSelectionScope::Queue => {
state.queue_tab.cursor = value;
}
2026-07-17 01:43:09 +03:00
TrackSelectionScope::FedSearch => {
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
set_view_cursor(state, base + value);
}
TrackSelectionScope::FedRelease(_) => {
set_view_cursor(state, value + 1);
}
2026-07-21 00:12:45 +03:00
TrackSelectionScope::FedAppearsOn => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return;
};
2026-07-25 23:57:03 +03:00
let release_count = fed_artist_visible_release_indices(state, card).len();
2026-07-21 00:12:45 +03:00
set_view_cursor(state, release_count + value);
}
}
}
fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let tracks = detail.top_tracks.len();
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, detail).len();
if *cursor < tracks {
Some((TrackSelectionScope::ArtistTop(*id), *cursor, tracks))
} else {
let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id),
featured,
detail.featured_tracks.len(),
))
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => Some((
TrackSelectionScope::Release(*id),
*cursor,
detail.tracks.len(),
)),
_ => None,
},
2026-07-17 01:43:09 +03:00
GlobalView::Search { cursor } => {
// Only the federated tracks section is selectable here.
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
let len = state.search.fed_tracks.len();
let relative = cursor.checked_sub(base)?;
(relative < len).then_some((TrackSelectionScope::FedSearch, relative, len))
}
GlobalView::FedRelease { index, cursor } => {
2026-07-25 23:57:03 +03:00
let len = fed_release_tracks(state, *index).len();
2026-07-17 01:43:09 +03:00
let relative = cursor.checked_sub(1)?;
2026-07-21 00:12:45 +03:00
(relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
}
GlobalView::FedArtist { cursor } => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return None;
};
2026-07-25 23:57:03 +03:00
let release_count = fed_artist_visible_release_indices(state, card).len();
let appearance_count = fed_artist_visible_appearance_indices(state, card).len();
let relative = cursor.checked_sub(release_count)?;
(relative < appearance_count).then_some((
2026-07-21 00:12:45 +03:00
TrackSelectionScope::FedAppearsOn,
2026-07-17 01:43:09 +03:00
relative,
2026-07-25 23:57:03 +03:00
appearance_count,
2026-07-17 01:43:09 +03:00
))
}
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
let len = playlist_tracks(state, opened.id)?.len();
Some((TrackSelectionScope::Playlist(opened.id), opened.cursor, len))
}
Tab::Queue => Some((
TrackSelectionScope::Queue,
state.queue_tab.cursor,
state.player.queue.len(),
)),
2026-07-16 20:29:51 +03:00
Tab::Federation | Tab::Logs => None,
}
}
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &[TrackItem])> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let tracks = detail.top_tracks.len();
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, detail).len();
if *cursor < tracks {
Some((
TrackSelectionScope::ArtistTop(*id),
*cursor,
&detail.top_tracks,
))
} else {
let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id),
featured,
&detail.featured_tracks,
))
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => {
Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks))
}
_ => None,
},
_ => None,
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
Some((
TrackSelectionScope::Playlist(opened.id),
opened.cursor,
playlist_tracks(state, opened.id)?,
))
}
Tab::Queue => Some((
TrackSelectionScope::Queue,
state.queue_tab.cursor,
&state.player.queue,
)),
2026-07-16 20:29:51 +03:00
Tab::Federation | Tab::Logs => None,
}
}
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
2026-07-17 15:29:10 +03:00
// Federated contexts produce queueable placeholders that behave like
// regular tracks (queue, info, playback-on-demand).
{
let fed = selected_fed_tracks(state);
if !fed.is_empty() {
return fed.iter().map(crate::federation::pending_track).collect();
}
}
let Some((scope, cursor, tracks)) = current_track_list(state) else {
return selected_track(state).into_iter().collect();
};
let indices = state
.track_selection
.indices(&scope, tracks.len())
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
indices
.into_iter()
.filter_map(|index| tracks.get(index).cloned())
.collect()
}
fn selected_queue_indices(state: &AppState) -> Vec<usize> {
if state.active_tab != Tab::Queue || state.player.queue.is_empty() {
return Vec::new();
}
state
.track_selection
.indices(&TrackSelectionScope::Queue, state.player.queue.len())
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
}
fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> {
let indices = selected_queue_indices(state);
if indices.is_empty() {
state.status_message = Some("queue is empty".into());
return None;
}
let outcome = remove_queue_indices(state, &indices);
state.status_message = Some(format!("removed {} track(s) from queue", indices.len()));
Some(Effect::RemoveQueueIndices {
indices,
restart_paused: outcome.restart_paused,
stop: outcome.stop,
})
}
struct QueueRemovalOutcome {
restart_paused: Option<bool>,
stop: bool,
}
fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemovalOutcome {
let len = state.player.queue.len();
let mut unique: Vec<usize> = indices
.iter()
.copied()
.filter(|index| *index < len)
.collect();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return QueueRemovalOutcome {
restart_paused: None,
stop: false,
};
}
let old_queue_pos = state.player.queue_pos;
2026-07-25 01:23:29 +03:00
let current_key = state.player.current.as_ref().map(track_key);
let current_removed = current_key.as_ref().is_some_and(|key| {
unique.iter().any(|index| {
state
.player
.queue
.get(*index)
2026-07-25 01:23:29 +03:00
.is_some_and(|track| track_key(track) == *key)
})
});
let removed_before_current = unique
.iter()
.filter(|index| **index < old_queue_pos)
.count();
let was_loaded = state.player.playing;
let was_paused = state.player.paused;
for index in unique.iter().rev() {
state.player.queue.remove(*index);
}
state.player.prefetched_pos = None;
state.track_selection.clear();
if state.player.queue.is_empty() {
state.player = super::state::PlayerBar::default();
state.queue_tab.cursor = 0;
return QueueRemovalOutcome {
restart_paused: None,
stop: true,
};
}
if current_removed {
let desired = old_queue_pos.saturating_sub(removed_before_current);
state.player.queue_pos = desired.min(state.player.queue.len() - 1);
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0;
state.player.track_started_at = None;
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
return QueueRemovalOutcome {
restart_paused: was_loaded.then_some(was_paused),
stop: false,
};
}
2026-07-25 01:23:29 +03:00
if let Some(key) = current_key.as_ref() {
if let Some(position) = state
.player
.queue
.iter()
.position(|track| track_key(track) == *key)
{
state.player.queue_pos = position;
}
} else {
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
}
2026-07-25 01:23:29 +03:00
state.player.current = current_key.and_then(|key| {
state
.player
.queue
.iter()
2026-07-25 01:23:29 +03:00
.find(|track| track_key(track) == key)
.cloned()
});
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
QueueRemovalOutcome {
restart_paused: None,
stop: false,
}
}
2026-06-10 16:11:09 +01:00
/// The track under the cursor in whatever view is showing tracks.
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let tracks = detail.top_tracks.len();
if *cursor < tracks {
detail.top_tracks.get(*cursor).cloned()
} else {
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, detail).len();
cursor
2026-07-25 23:57:03 +03:00
.checked_sub(tracks + releases)
.and_then(|i| detail.featured_tracks.get(i).cloned())
}
}
2026-06-10 16:11:09 +01:00
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => detail.tracks.get(*cursor).cloned(),
_ => None,
},
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
2026-07-17 15:29:10 +03:00
match results.tracks.get(offset) {
Some(track) => Some(track.clone()),
// Below the local tracks: the federated section.
None => {
let fed_offset = offset
.checked_sub(results.tracks.len() + state.search.fed_artists.len())?;
state
.search
.fed_tracks
.get(fed_offset)
.map(crate::federation::pending_track)
}
}
}
2026-07-21 00:12:45 +03:00
GlobalView::FedArtist { cursor } => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return None;
};
2026-07-25 23:57:03 +03:00
let release_count = fed_artist_visible_release_indices(state, card).len();
let appearance_indices = fed_artist_visible_appearance_indices(state, card);
let index = cursor.checked_sub(release_count)?;
appearance_indices
2026-07-21 00:12:45 +03:00
.get(index)
2026-07-25 23:57:03 +03:00
.and_then(|&appearance_index| card.appears_on.get(appearance_index))
2026-07-21 00:12:45 +03:00
.and_then(|appearance| {
fed_track_from_appearance(appearance, card.own_owner.as_deref())
})
2026-07-17 15:29:10 +03:00
.as_ref()
.map(crate::federation::pending_track)
2026-06-10 16:11:09 +01:00
}
2026-07-21 00:12:45 +03:00
GlobalView::FedRelease { index, cursor } => fed_release_tracks(state, *index)
.into_iter()
.nth(cursor.checked_sub(1)?)
.as_ref()
.map(crate::federation::pending_track),
2026-06-10 16:11:09 +01:00
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)?
.get(opened.cursor)
.cloned()
2026-06-10 16:11:09 +01:00
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
2026-07-16 20:29:51 +03:00
Tab::Federation | Tab::Logs => None,
2026-06-10 16:11:09 +01:00
}
}
/// Tracks backing an opened playlist, if loaded.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec<TrackItem>> {
match state.playlist_views.get(&id)? {
Loadable::Ready(detail) => Some(&detail.tracks),
_ => None,
}
}
/// 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 {
return None;
}
match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let position = cursor.checked_sub(detail.top_tracks.len())?;
2026-07-25 23:57:03 +03:00
let merged = artist_merged_releases(state, *id, detail);
let order = artist_release_display_order(&merged);
order
.get(position)
.and_then(|&i| merged[i].local_index)
.map(|i| detail.releases[i].id)
2026-06-10 16:11:09 +01:00
}
_ => None,
},
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).map(|r| r.id)
}
2026-07-21 00:12:45 +03:00
GlobalView::Release { .. }
| GlobalView::FedArtist { .. }
| GlobalView::FedRelease { .. } => None,
2026-06-10 16:11:09 +01:00
}
}
/// a / shift-a: queue the selection — a single track directly, a release via
/// an async fetch effect.
fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
let tracks = selected_tracks(state);
if !tracks.is_empty() {
let count = tracks.len();
let title = tracks[0].title.clone();
enqueue_tracks(state, tracks, next);
2026-07-21 00:12:45 +03:00
state.track_selection.clear();
state.status_message = Some(if count == 1 && next {
2026-06-10 16:11:09 +01:00
format!("queued next: {title}")
} else if count == 1 {
2026-06-10 16:11:09 +01:00
format!("queued: {title}")
} else if next {
format!("queued next: {count} tracks")
} else {
format!("queued: {count} tracks")
2026-06-10 16:11:09 +01:00
});
2026-07-24 04:49:48 +03:00
return Some(Effect::PlaybackQueueChanged);
2026-06-10 16:11:09 +01:00
}
if let Some(id) = selected_release_id(state) {
return Some(Effect::EnqueueRelease { id, next });
}
state.status_message = Some("nothing to queue here".into());
None
}
2026-06-10 17:01:40 +01:00
/// Shift-J: open the release the track belongs to, with the cursor on that
/// track. If the release view is still loading, the focus is applied when
/// it arrives (`pending_release_focus`).
fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
2026-07-17 15:29:10 +03:00
if track.release_id < 0 {
state.status_message = Some("this federated track has no local release".into());
return;
}
2026-06-10 17:01:40 +01:00
let release_id = track.release_id;
let cursor = match state.release_views.get(&release_id) {
Some(Loadable::Ready(detail)) => detail
.tracks
.iter()
.position(|t| t.id == track.id)
.unwrap_or(0),
_ => {
state.pending_release_focus = Some((release_id, track.id));
0
}
};
let origin = state.active_tab;
state.active_tab = Tab::Global;
match state.global.stack.last_mut() {
Some(GlobalView::Release {
id,
cursor: current,
}) if *id == release_id => {
2026-06-10 17:01:40 +01:00
*current = cursor;
}
_ => state.global.stack.push(GlobalView::Release {
id: release_id,
cursor,
}),
}
// Jumps from another tab return there on Esc; jumps within Global
// unwind the navigation stack as usual.
if origin != Tab::Global {
state.jump_origin = Some((origin, state.global.stack.len() - 1));
}
}
/// Jumps to an artist page from anywhere: the local view when the artist is
/// in the library, the federated card otherwise. Returns the effect that
/// starts the federated fetch, if one is needed.
pub(crate) fn open_artist_ref(
state: &mut AppState,
artist: &crate::library::models::ArtistRef,
) -> Option<Effect> {
let origin = state.active_tab;
state.active_tab = Tab::Global;
let effect = if artist.id >= 0 {
match state.global.stack.last() {
Some(GlobalView::Artist { id, .. }) if *id == artist.id => {}
_ => state.global.stack.push(GlobalView::Artist {
id: artist.id,
cursor: 0,
}),
}
None
} else {
state.fed_artist_view = Some((artist.name.clone(), Loadable::Loading));
state.global.stack.push(GlobalView::FedArtist { cursor: 0 });
Some(Effect::FedOpenArtist(artist.name.clone()))
};
if origin != Tab::Global {
state.jump_origin = Some((origin, state.global.stack.len() - 1));
}
effect
}
/// The artists of a track as shown in the info popup: main artists first,
/// then featured, without duplicates.
pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models::ArtistRef> {
let mut seen = std::collections::HashSet::new();
let mut refs: Vec<crate::library::models::ArtistRef> = Vec::new();
for artist in track.artists.iter().chain(track.featured_artists.iter()) {
let key = music_dht::normalize_name(&artist.name);
if key.is_empty() || !seen.insert(key) {
continue;
}
refs.push(artist.clone());
}
refs
}
2026-06-10 16:11:09 +01:00
/// 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 player = &mut state.player;
if tracks.is_empty() {
return;
}
let insert_at = if next && !player.queue.is_empty() {
(player.queue_pos + 1).min(player.queue.len())
} else if next {
0
} else {
player.queue.len()
};
let count = tracks.len();
for (offset, track) in tracks.into_iter().enumerate() {
player.queue.insert(insert_at + offset, track);
}
2026-07-16 20:29:51 +03:00
if let Some(prefetched) = &mut player.prefetched_pos
2026-07-21 00:12:45 +03:00
&& insert_at <= *prefetched
{
*prefetched += count;
}
2026-06-10 16:11:09 +01:00
if insert_at <= player.queue_pos && player.current.is_some() {
player.queue_pos += count;
}
}
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
/// is on, so stepping is always sequential.
2026-06-10 16:11:09 +01:00
fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
let player = &mut state.player;
if player.queue.is_empty() {
state.status_message = Some("queue is empty".into());
return None;
}
let len = player.queue.len();
let next = player.queue_pos as isize + direction;
if next < 0 {
player.queue_pos = 0;
} else if next >= len as isize {
if player.repeat == super::state::RepeatMode::All {
player.queue_pos = 0;
} else {
state.status_message = Some("end of queue".into());
return None;
}
} else {
player.queue_pos = next as usize;
}
Some(Effect::PlayCurrent)
}
/// What plays after the current track, without mutating anything — used to
/// pick the gapless prefetch target. Mirrors `advance_after_finish`.
/// Shuffle needs no special case: the queue tail is already shuffled.
2026-06-10 16:11:09 +01:00
pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option<usize> {
if player.queue.is_empty() {
return None;
}
match player.repeat {
super::state::RepeatMode::One => Some(player.queue_pos),
repeat => {
if player.queue_pos + 1 < player.queue.len() {
Some(player.queue_pos + 1)
} else if repeat == super::state::RepeatMode::All {
Some(0)
} else {
None
}
}
}
}
/// The current track finished: play the next queue position (the tail is
/// pre-shuffled when shuffle is on), or stop at the end.
2026-06-10 16:11:09 +01:00
pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
let player = &mut state.player;
if player.queue.is_empty() {
player.playing = false;
player.current = None;
return None;
}
match player.repeat {
super::state::RepeatMode::One => Some(Effect::PlayCurrent),
repeat => {
if player.queue_pos + 1 < player.queue.len() {
player.queue_pos += 1;
Some(Effect::PlayCurrent)
} else if repeat == super::state::RepeatMode::All {
player.queue_pos = 0;
Some(Effect::PlayCurrent)
} else {
player.playing = false;
player.paused = false;
Some(Effect::StopPlayback)
}
}
}
}
/// First index of the not-yet-played queue tail: everything after the
/// current track, or from the current position when nothing is loaded.
fn upcoming_start(player: &super::state::PlayerBar) -> usize {
if player.current.is_some() {
(player.queue_pos + 1).min(player.queue.len())
} else {
player.queue_pos.min(player.queue.len())
}
}
/// Remember the original order and Fisher-Yates the unplayed tail.
pub fn shuffle_upcoming(player: &mut super::state::PlayerBar) {
if player.queue.is_empty() {
return;
}
if player.original_order.is_none() {
2026-07-25 01:23:29 +03:00
player.original_order = Some(player.queue.iter().map(track_key).collect());
}
shuffle_range(player, upcoming_start(player));
}
/// Put the unplayed tail back into pre-shuffle order. Tracks queued while
/// shuffled (absent from the snapshot) keep their relative order at the end.
pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
let Some(order) = player.original_order.take() else {
return;
};
let start = upcoming_start(player);
if start >= player.queue.len() {
return;
}
let tail = player.queue.split_off(start);
let mut used = vec![false; order.len()];
2026-07-16 20:29:51 +03:00
let mut keyed: Vec<(usize, usize, crate::library::models::TrackItem)> = tail
.into_iter()
.enumerate()
.map(|(position, track)| {
let key = order
.iter()
.enumerate()
2026-07-25 01:23:29 +03:00
.position(|(slot, key)| !used[slot] && *key == track_key(&track))
.inspect(|&slot| used[slot] = true)
.unwrap_or(usize::MAX);
(key, position, track)
})
.collect();
keyed.sort_by_key(|(key, position, _)| (*key, *position));
player
.queue
.extend(keyed.into_iter().map(|(_, _, track)| track));
player.prefetched_pos = None;
2026-06-10 16:11:09 +01:00
}
/// Columns of the Global tile grid. Derived from the terminal width the same
/// way ui::global does (full width minus the surrounding block's borders),
/// so selection math and rendering agree.
pub fn grid_columns() -> usize {
let width = crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80);
usize::from((width.saturating_sub(2) / TILE_WIDTH).max(1))
}
/// Lines of content visible in the main area (terminal height minus the tab
/// bar, status bar and the view's borders).
fn viewport_lines() -> isize {
let height = crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24);
height.saturating_sub(5).max(1) as isize
}
/// One PageUp/PageDown step in MoveUp/MoveDown units for the current view:
/// move_selection() multiplies vertical steps by the column count in tile
/// zones, so tile views page by visible tile rows, line views by visible
/// lines.
fn page_step(state: &AppState) -> isize {
let lines = viewport_lines();
2026-06-10 16:23:20 +01:00
if state.active_tab != Tab::Global {
return lines;
}
2026-06-10 16:11:09 +01:00
let tile_rows = (lines / TILE_HEIGHT as isize).max(1);
match state.global.stack.last() {
None => match state.global.view {
ViewMode::Tiles => tile_rows,
ViewMode::Table => lines,
},
Some(GlobalView::Artist { id, cursor }) => {
let in_release_tiles = match state.artist_views.get(id) {
Some(Loadable::Ready(detail)) => {
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, detail).len();
*cursor >= detail.top_tracks.len()
2026-07-25 23:57:03 +03:00
&& *cursor < detail.top_tracks.len() + releases
}
_ => false,
2026-06-10 16:11:09 +01:00
};
if in_release_tiles && state.global.view == ViewMode::Tiles {
2026-06-10 16:11:09 +01:00
tile_rows
} else {
lines
2026-06-10 16:11:09 +01:00
}
}
2026-07-17 01:17:54 +03:00
Some(GlobalView::Release { .. })
| Some(GlobalView::Search { .. })
2026-07-17 01:43:09 +03:00
| Some(GlobalView::FedRelease { .. }) => lines,
2026-07-21 00:12:45 +03:00
Some(GlobalView::FedArtist { cursor }) => {
let in_release_tiles = match &state.fed_artist_view {
2026-07-25 23:57:03 +03:00
Some((_, Loadable::Ready(card))) => {
*cursor < fed_artist_visible_release_indices(state, card).len()
}
2026-07-21 00:12:45 +03:00
_ => false,
};
if in_release_tiles { tile_rows } else { lines }
}
2026-06-10 16:11:09 +01:00
}
}
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
2026-07-16 20:29:51 +03:00
if state.active_tab == Tab::Federation {
if dy != 0 {
2026-07-23 18:48:58 +03:00
let last = settings_rows(state).len() as isize - 1;
state.settings_cursor = (state.settings_cursor as isize + dy).clamp(0, last) as usize;
2026-07-16 20:29:51 +03:00
}
return;
}
2026-06-10 16:23:20 +01:00
if state.active_tab == Tab::Logs {
// The cursor anchors to an entry's seq, so freshly appended log
// lines (including ones caused by this very keypress) don't shift
// the selection.
if dy != 0 {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
let current = if state.logs.follow {
None
} else {
state.logs.selected_seq
};
if let Some((seq, is_newest)) = buffer.move_selection(level, current, dy) {
state.logs.selected_seq = Some(seq);
state.logs.follow = is_newest && dy > 0;
}
2026-06-10 16:23:20 +01:00
}
}
return;
}
if state.track_selection.is_active()
&& dx == 0
&& let Some((scope, cursor, len)) = current_track_list_context(state)
{
if len == 0 {
return;
}
let next = (cursor as isize + dy).clamp(0, len as isize - 1) as usize;
set_track_scope_cursor(state, &scope, next);
refresh_track_selection_cursor(state);
return;
}
2026-06-10 16:11:09 +01:00
if state.active_tab == Tab::Playlists {
let len = playlists_view_len(state);
if len == 0 {
return;
}
let last = len as isize - 1;
match &mut state.playlists.opened {
Some(opened) => {
opened.cursor = (opened.cursor as isize + dy).clamp(0, last) as usize;
refresh_track_selection_cursor(state);
2026-06-10 16:11:09 +01:00
}
None => {
state.playlists.selected =
(state.playlists.selected as isize + dy).clamp(0, last) as usize;
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
}
}
return;
}
if state.active_tab == Tab::Queue {
let len = state.player.queue.len();
if len == 0 {
return;
}
state.queue_tab.cursor =
(state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize;
refresh_track_selection_cursor(state);
return;
}
2026-06-10 16:11:09 +01:00
if state.active_tab != Tab::Global {
return not_yet(state, "Navigation in this view");
}
match state.global.stack.last().copied() {
None => {
let global = &mut state.global;
if global.artists.is_empty() {
return;
}
let step = match global.view {
ViewMode::Tiles => dx + dy * grid_columns() as isize,
ViewMode::Table => dy,
};
let last = global.artists.len() as isize - 1;
global.selected = (global.selected as isize + step).clamp(0, last) as usize;
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
}
Some(GlobalView::Artist { id, cursor }) => {
let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else {
return;
};
2026-07-25 23:57:03 +03:00
let merged = artist_merged_releases(state, id, detail);
2026-06-10 16:11:09 +01:00
let tracks = detail.top_tracks.len();
2026-07-25 23:57:03 +03:00
let releases = merged.len();
let featured = detail.featured_tracks.len();
let total = tracks + releases + featured;
2026-06-10 16:11:09 +01:00
if total == 0 {
return;
}
let in_release_tiles = state.global.view == ViewMode::Tiles
&& cursor >= tracks
&& cursor < tracks + releases;
let next = if !in_release_tiles {
// List zones (top tracks, featured tracks; releases in
// table mode): plain vertical steps cross zone boundaries
// in flat order.
2026-06-10 16:11:09 +01:00
(cursor as isize + dy).clamp(0, total as isize - 1) as usize
} else {
// Release tiles: move by visual rows (groups break rows),
// keeping the column, so Up lands on the tile above.
2026-07-25 23:57:03 +03:00
let rows = artist_release_rows(&merged, grid_columns());
2026-06-10 16:11:09 +01:00
let position = cursor - tracks;
let (row, column) = rows
.iter()
.enumerate()
.find_map(|(r, items)| {
items.iter().position(|p| *p == position).map(|c| (r, c))
})
.unwrap_or((0, 0));
if dx != 0 {
let last = releases as isize - 1;
2026-06-10 16:11:09 +01:00
tracks + (position as isize + dx).clamp(0, last) as usize
} else {
let target = row as isize + dy;
if target < 0 {
if tracks > 0 { tracks - 1 } else { cursor }
} else if target as usize >= rows.len() {
// Below the last release row: the featured section.
if featured > 0 {
tracks + releases
2026-06-10 16:11:09 +01:00
} else {
cursor
}
} else {
let items = &rows[target as usize];
2026-06-10 16:11:09 +01:00
tracks + items[column.min(items.len() - 1)]
}
}
};
set_view_cursor(state, next);
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
}
Some(GlobalView::Release { id, cursor }) => {
let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else {
return;
};
let total = detail.tracks.len() as isize;
if total == 0 {
return;
}
let next = (cursor as isize + dy).clamp(0, total - 1);
set_view_cursor(state, next as usize);
refresh_track_selection_cursor(state);
2026-06-10 16:11:09 +01:00
}
Some(GlobalView::Search { cursor }) => {
2026-07-16 20:49:53 +03:00
// Local results plus the federated section below them.
let total = (state.search.results.as_ref().map_or(0, |r| r.len())
2026-07-17 01:17:54 +03:00
+ state.search.fed_artists.len()
2026-07-16 20:49:53 +03:00
+ state.search.fed_tracks.len()) as isize;
2026-06-10 16:11:09 +01:00
if total == 0 {
return;
}
let next = (cursor as isize + dy).clamp(0, total - 1);
set_view_cursor(state, next as usize);
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
}
2026-07-17 01:17:54 +03:00
Some(GlobalView::FedArtist { cursor }) => {
2026-07-21 00:12:45 +03:00
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return;
};
2026-07-25 23:57:03 +03:00
let release_indices = fed_artist_visible_release_indices(state, card);
let releases = release_indices.len();
let appears_on = fed_artist_visible_appearance_indices(state, card).len();
2026-07-21 00:12:45 +03:00
let total = (releases + appears_on) as isize;
2026-07-17 01:17:54 +03:00
if total == 0 {
return;
}
2026-07-21 00:12:45 +03:00
let in_release_tiles = cursor < releases;
let next = if !in_release_tiles {
(cursor as isize + dy).clamp(0, total - 1) as usize
} else {
2026-07-25 23:57:03 +03:00
let visible_releases: Vec<_> = release_indices
.iter()
.map(|&index| card.releases[index].clone())
.collect();
let rows = fed_release_rows(&visible_releases, grid_columns());
2026-07-21 00:12:45 +03:00
let (row, column) = rows
.iter()
.enumerate()
.find_map(|(r, items)| items.iter().position(|p| *p == cursor).map(|c| (r, c)))
.unwrap_or((0, 0));
if dx != 0 {
(cursor as isize + dx).clamp(0, releases as isize - 1) as usize
} else {
let target = row as isize + dy;
if target < 0 {
cursor
} else if target as usize >= rows.len() {
if appears_on > 0 { releases } else { cursor }
} else {
let items = &rows[target as usize];
items[column.min(items.len() - 1)]
}
}
};
2026-07-17 01:17:54 +03:00
set_view_cursor(state, next as usize);
state.track_selection.clear();
}
2026-07-17 01:43:09 +03:00
Some(GlobalView::FedRelease { index, cursor }) => {
2026-07-25 23:57:03 +03:00
let tracks = fed_card_release(state, index)
.map_or(0, |r| fed_release_visible_track_indices(state, r).len())
as isize;
2026-07-17 01:43:09 +03:00
// Row 0 is the download button, 1..=tracks are the tracks.
let next = (cursor as isize + dy).clamp(0, tracks);
set_view_cursor(state, next as usize);
refresh_track_selection_cursor(state);
}
2026-07-17 01:17:54 +03:00
}
}
2026-07-21 00:12:45 +03:00
/// Selectable rows of the open federated artist card: releases, then appearances.
2026-07-17 01:17:54 +03:00
pub(crate) fn fed_card_len(state: &AppState) -> usize {
match &state.fed_artist_view {
2026-07-25 23:57:03 +03:00
Some((_, Loadable::Ready(card))) => {
fed_artist_visible_release_indices(state, card).len()
+ fed_artist_visible_appearance_indices(state, card).len()
}
2026-07-17 01:17:54 +03:00
_ => 0,
2026-06-10 16:11:09 +01:00
}
}
fn set_view_cursor(state: &mut AppState, value: usize) {
if let Some(view) = state.global.stack.last_mut() {
match view {
GlobalView::Artist { cursor, .. }
| GlobalView::Release { cursor, .. }
2026-07-17 01:17:54 +03:00
| GlobalView::Search { cursor }
2026-07-17 01:43:09 +03:00
| GlobalView::FedArtist { cursor }
| GlobalView::FedRelease { cursor, .. } => *cursor = value,
2026-06-10 16:11:09 +01:00
}
}
}
/// 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),
None => match &state.playlists.list {
Some(Loadable::Ready(list)) => list.len(),
_ => 0,
},
}
}
fn current_view_len(state: &AppState) -> usize {
if state.active_tab == Tab::Playlists {
return playlists_view_len(state);
}
if state.active_tab == Tab::Queue {
return state.player.queue.len();
}
2026-07-16 20:29:51 +03:00
if state.active_tab == Tab::Federation {
2026-07-23 18:48:58 +03:00
return settings_rows(state).len();
2026-07-16 20:29:51 +03:00
}
2026-06-10 16:11:09 +01:00
match state.global.stack.last() {
None => state.global.artists.len(),
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(d)) => {
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, *id, d).len();
d.top_tracks.len() + releases + d.featured_tracks.len()
}
2026-06-10 16:11:09 +01:00
_ => 0,
},
Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) {
Some(Loadable::Ready(d)) => d.tracks.len(),
_ => 0,
},
2026-07-16 20:29:51 +03:00
Some(GlobalView::Search { .. }) => {
2026-07-17 01:17:54 +03:00
state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len()
+ state.search.fed_tracks.len()
2026-07-16 20:29:51 +03:00
}
2026-07-17 01:17:54 +03:00
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
2026-07-25 23:57:03 +03:00
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
.map_or(0, |r| fed_release_visible_track_indices(state, r).len() + 1),
}
}
pub(crate) fn apply_library_filter_change(state: &mut AppState) {
state.track_selection.clear();
let len = current_view_len(state);
if state.active_tab == Tab::Global {
if state.global.stack.is_empty() {
state.global.selected = state.global.selected.min(len.saturating_sub(1));
} else if let Some(view) = state.global.stack.last_mut() {
let cursor = match view {
GlobalView::Artist { cursor, .. }
| GlobalView::Release { cursor, .. }
| GlobalView::Search { cursor }
| GlobalView::FedArtist { cursor }
| GlobalView::FedRelease { cursor, .. } => cursor,
};
*cursor = (*cursor).min(len.saturating_sub(1));
2026-07-17 01:43:09 +03:00
}
2026-06-10 16:11:09 +01:00
}
2026-07-25 23:57:03 +03:00
if state.global.filters.source_mode.includes_network() {
return;
}
let message = match state.global.stack.last() {
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(detail))
if detail.top_tracks.is_empty()
&& artist_merged_releases(state, *id, detail).is_empty()
&& detail.featured_tracks.is_empty() =>
{
Some(format!(
"local filter: \"{}\" is not available locally",
detail.name
))
}
_ => None,
},
Some(GlobalView::FedArtist { .. }) if fed_card_len(state) == 0 => {
let name = state
.fed_artist_view
.as_ref()
.map(|(name, _)| name.as_str())
.unwrap_or("artist");
Some(format!("local filter: \"{name}\" is not available locally"))
}
Some(GlobalView::FedRelease { index, .. })
if fed_release_tracks(state, *index).is_empty() =>
{
Some("local filter: release is not available locally".to_string())
}
_ => None,
};
if let Some(message) = message {
state.status_message = Some(message);
}
2026-06-10 16:11:09 +01:00
}
fn jump_selection(state: &mut AppState, first: bool) {
if state.track_selection.is_active()
&& let Some((scope, _, len)) = current_track_list_context(state)
{
if len > 0 {
let target = if first { 0 } else { len - 1 };
set_track_scope_cursor(state, &scope, target);
refresh_track_selection_cursor(state);
}
return;
}
if state.active_tab == Tab::Queue {
let len = state.player.queue.len();
if len > 0 {
state.queue_tab.cursor = if first { 0 } else { len - 1 };
refresh_track_selection_cursor(state);
}
return;
}
2026-07-16 20:29:51 +03:00
if state.active_tab == Tab::Federation {
2026-07-23 18:48:58 +03:00
let last = settings_rows(state).len().saturating_sub(1);
state.settings_cursor = if first { 0 } else { last };
2026-07-16 20:29:51 +03:00
return;
}
2026-06-10 16:23:20 +01:00
if state.active_tab == Tab::Logs {
if first {
let level = super::state::LOG_LEVELS[state.logs.level_index];
2026-07-16 20:29:51 +03:00
if let Some(buffer) = crate::config::logging::buffer()
2026-07-21 00:12:45 +03:00
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN)
{
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
2026-06-10 16:23:20 +01:00
} else {
state.logs.follow = true;
state.logs.selected_seq = None;
2026-06-10 16:23:20 +01:00
}
return;
}
2026-06-10 16:11:09 +01:00
if state.active_tab != Tab::Global && state.active_tab != Tab::Playlists {
return not_yet(state, "Navigation in this view");
}
let len = current_view_len(state);
if len == 0 {
return;
}
let target = if first { 0 } else { len - 1 };
if state.active_tab == Tab::Playlists {
match &mut state.playlists.opened {
Some(opened) => opened.cursor = target,
None => state.playlists.selected = target,
}
refresh_track_selection_cursor(state);
2026-06-10 16:11:09 +01:00
} else if state.global.stack.is_empty() {
state.global.selected = target;
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
} else {
set_view_cursor(state, target);
refresh_track_selection_cursor(state);
2026-06-10 16:11:09 +01:00
}
}
/// Enter on the Playlists tab: open a playlist from the list, or play the
/// selected track with the playlist as the queue.
fn select_playlist(state: &mut AppState) -> Option<Effect> {
match state.playlists.opened {
Some(opened) => {
let tracks = playlist_tracks(state, opened.id)?.clone();
if tracks.is_empty() {
return None;
}
state.player.queue = tracks;
state.player.queue_pos = opened.cursor.min(state.player.queue.len() - 1);
on_new_queue(state);
2026-06-10 16:11:09 +01:00
Some(Effect::PlayCurrent)
}
None => {
let id = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected)?.id,
_ => return None,
};
state.playlists.opened = Some(OpenedPlaylist { id, cursor: 0 });
None
}
}
}
/// Enter: drill down (grid → artist → release) or play the selected track
/// with its surrounding list as the queue.
fn select_current(state: &mut AppState) -> Option<Effect> {
if state.active_tab == Tab::Playlists {
return select_playlist(state);
}
// Logs: open the full, wrapped entry under the cursor.
if state.active_tab == Tab::Logs {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
let selected = if state.logs.follow {
None
} else {
state.logs.selected_seq
};
if let Some(entry) = buffer.entry_at(level, selected) {
state.popup = Some(super::state::Popup::LogDetail(entry));
}
}
return None;
}
2026-07-16 20:29:51 +03:00
if state.active_tab == Tab::Federation {
return federation_select(state);
}
// Queue: jump playback to the track under the cursor. Earlier tracks
// stay in the queue as "played"; picking one of them just moves the
// playing position back.
if state.active_tab == Tab::Queue {
if state.player.queue.is_empty() {
return None;
}
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
return Some(Effect::PlayCurrent);
}
2026-06-10 16:11:09 +01:00
if state.active_tab != Tab::Global {
not_yet(state, "Navigation in this view");
return None;
}
let outcome = match state.global.stack.last().copied() {
None => match state.global.artists.get(state.global.selected) {
2026-07-25 22:40:27 +03:00
Some(artist) if artist.id >= 0 => Outcome::Push(GlobalView::Artist {
2026-06-10 16:11:09 +01:00
id: artist.id,
cursor: 0,
}),
2026-07-25 22:40:27 +03:00
Some(artist) => Outcome::OpenFedArtist(artist.name.clone()),
2026-06-10 16:11:09 +01:00
None => Outcome::Nothing,
},
Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) {
Some(Loadable::Ready(detail)) => {
let tracks = detail.top_tracks.len();
2026-07-25 23:57:03 +03:00
let releases = artist_merged_releases(state, id, detail);
let releases_len = releases.len();
2026-06-10 16:11:09 +01:00
if cursor < tracks {
Outcome::Play {
tracks: detail.top_tracks.clone(),
start: cursor,
}
2026-07-25 23:57:03 +03:00
} else if cursor < tracks + releases_len {
let order = artist_release_display_order(&releases);
2026-06-10 16:11:09 +01:00
match order.get(cursor - tracks) {
2026-07-25 23:57:03 +03:00
Some(&original) => {
let slot = &releases[original];
if slot.availability.is_remoteish()
&& let Some(index) = slot.fed_index
&& let Some(card) =
crate::app::state::artist_fed_card(state, id).cloned()
{
Outcome::OpenFedRelease {
name: detail.name.clone(),
card,
index,
}
} else if let Some(index) = slot.local_index {
Outcome::Push(GlobalView::Release {
id: detail.releases[index].id,
cursor: 0,
})
} else if let Some(index) = slot.fed_index
&& let Some(card) =
crate::app::state::artist_fed_card(state, id).cloned()
{
Outcome::OpenFedRelease {
name: detail.name.clone(),
card,
index,
}
} else {
Outcome::Nothing
}
}
2026-06-10 16:11:09 +01:00
None => Outcome::Nothing,
}
} else if detail
.featured_tracks
2026-07-25 23:57:03 +03:00
.get(cursor - tracks - releases_len)
.is_some()
{
Outcome::Play {
tracks: detail.featured_tracks.clone(),
2026-07-25 23:57:03 +03:00
start: cursor - tracks - releases_len,
}
} else {
Outcome::Nothing
2026-06-10 16:11:09 +01:00
}
}
_ => Outcome::Nothing,
},
Some(GlobalView::Release { id, cursor }) => match state.release_views.get(&id) {
Some(Loadable::Ready(detail)) if !detail.tracks.is_empty() => Outcome::Play {
tracks: detail.tracks.clone(),
start: cursor.min(detail.tracks.len() - 1),
},
_ => Outcome::Nothing,
},
Some(GlobalView::Search { cursor }) => match &state.search.results {
Some(results) => {
let artists = results.artists.len();
let releases = results.releases.len();
if cursor < artists {
Outcome::Push(GlobalView::Artist {
id: results.artists[cursor].id,
cursor: 0,
})
} else if cursor < artists + releases {
Outcome::Push(GlobalView::Release {
id: results.releases[cursor - artists].id,
cursor: 0,
})
} else if results.tracks.get(cursor - artists - releases).is_some() {
Outcome::Play {
tracks: results.tracks.clone(),
start: cursor - artists - releases,
}
} else {
2026-07-17 01:17:54 +03:00
fed_outcome(state, cursor - artists - releases - results.tracks.len())
}
}
None => fed_outcome(state, cursor),
},
2026-07-17 01:43:09 +03:00
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
2026-07-21 00:12:45 +03:00
Some((_, Loadable::Ready(card))) => {
2026-07-25 23:57:03 +03:00
let release_indices = fed_artist_visible_release_indices(state, card);
if cursor < release_indices.len() {
let visible_releases: Vec<_> = release_indices
2026-07-21 00:12:45 +03:00
.iter()
2026-07-25 23:57:03 +03:00
.map(|&index| card.releases[index].clone())
2026-07-21 00:12:45 +03:00
.collect();
2026-07-25 23:57:03 +03:00
let order = fed_release_display_order(&visible_releases);
let Some(&visible_index) = order.get(cursor) else {
return None;
};
let Some(&release_index) = release_indices.get(visible_index) else {
return None;
};
// Focus starts on the first track; Up from it reaches the
// download-release button (row 0).
let start =
if fed_release_visible_track_indices(state, &card.releases[release_index])
.is_empty()
{
0
} else {
1
};
Outcome::Push(GlobalView::FedRelease {
index: release_index,
cursor: start,
})
} else {
let appearance_indices = fed_artist_visible_appearance_indices(state, card);
let start = cursor - release_indices.len();
if appearance_indices.get(start).is_none() {
2026-07-21 00:12:45 +03:00
Outcome::Nothing
} else {
2026-07-25 23:57:03 +03:00
let tracks: Vec<_> = appearance_indices
.iter()
.filter_map(|&appearance_index| card.appears_on.get(appearance_index))
.filter_map(|appearance| {
fed_track_from_appearance(appearance, card.own_owner.as_deref())
})
.map(|fed| crate::federation::pending_track(&fed))
.collect();
if tracks.is_empty() {
Outcome::Nothing
} else {
Outcome::Play { tracks, start }
}
2026-07-21 00:12:45 +03:00
}
}
}
2026-07-17 01:43:09 +03:00
_ => Outcome::Nothing,
},
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
Some(_) if cursor == 0 => {
// The download-whole-release button.
match fed_release_tracks(state, index) {
tracks if tracks.is_empty() => Outcome::Nothing,
tracks => Outcome::DownloadFed(tracks),
}
}
2026-07-17 15:29:10 +03:00
Some(_) => {
// Play the whole release starting at the selected track,
// exactly like a local release view.
let tracks: Vec<_> = fed_release_tracks(state, index)
.iter()
.map(crate::federation::pending_track)
.collect();
if tracks.is_empty() || cursor > tracks.len() {
Outcome::Nothing
} else {
Outcome::Play {
tracks,
start: cursor - 1,
}
2026-07-17 01:43:09 +03:00
}
}
None => Outcome::Nothing,
},
2026-06-10 16:11:09 +01:00
};
match outcome {
Outcome::Push(view) => {
state.global.stack.push(view);
None
}
Outcome::Play { tracks, start } => {
state.player.queue = tracks;
state.player.queue_pos = start;
on_new_queue(state);
2026-06-10 16:11:09 +01:00
Some(Effect::PlayCurrent)
}
2026-07-17 01:17:54 +03:00
Outcome::OpenFedArtist(name) => {
state.fed_artist_view = Some((name.clone(), Loadable::Loading));
state.global.stack.push(GlobalView::FedArtist { cursor: 0 });
state.active_tab = Tab::Global;
Some(Effect::FedOpenArtist(name))
}
2026-07-25 23:57:03 +03:00
Outcome::OpenFedRelease { name, card, index } => {
let start =
if card.releases.get(index).is_some_and(|release| {
fed_release_visible_track_indices(state, release).is_empty()
}) {
0
} else {
1
};
state.fed_artist_view = Some((name, Loadable::Ready(card)));
state.global.stack.push(GlobalView::FedRelease {
index,
cursor: start,
});
state.active_tab = Tab::Global;
None
}
2026-07-17 01:43:09 +03:00
Outcome::DownloadFed(tracks) => {
state.status_message = Some(format!(
2026-07-17 01:48:41 +03:00
"federation: downloading {} track(s) to the library…",
2026-07-17 01:43:09 +03:00
tracks.len()
));
Some(Effect::FedDownload { tracks })
}
2026-06-10 16:11:09 +01:00
Outcome::Nothing => None,
}
}
2026-07-17 01:43:09 +03:00
/// The open card's release by index.
pub(crate) fn fed_card_release(
state: &AppState,
index: usize,
) -> Option<&crate::federation::FedRelease> {
match &state.fed_artist_view {
Some((_, Loadable::Ready(card))) => card.releases.get(index),
_ => None,
}
}
2026-07-25 23:57:03 +03:00
/// Every visible track of one card release as playable FedTracks.
2026-07-17 01:43:09 +03:00
pub(crate) fn fed_release_tracks(
state: &AppState,
index: usize,
) -> Vec<crate::federation::FedTrack> {
let Some((name, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
let Some(release) = card.releases.get(index) else {
return Vec::new();
};
release
.tracks
.iter()
2026-07-25 23:57:03 +03:00
.filter(|track| crate::app::state::fed_card_track_visible(state, track))
2026-07-21 00:12:45 +03:00
.filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref()))
.collect()
}
pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
card.appears_on
.iter()
2026-07-25 23:57:03 +03:00
.filter(|appearance| crate::app::state::fed_appearance_visible(state, appearance))
2026-07-21 00:12:45 +03:00
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
2026-07-17 01:43:09 +03:00
.collect()
}
/// Federated tracks covered by the active visual selection, or the single
/// one under the cursor in a federated context.
pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
// Federated cursors and selections live in Global-tab views only; on any
// other tab a stale Global cursor must not shadow that tab's own
// selection (e.g. `i` on a queue or playlist track).
if state.active_tab != Tab::Global {
return Vec::new();
}
2026-07-17 01:43:09 +03:00
// An active Shift-V range in a federated scope.
if let Some(scope) = state.track_selection.scope.clone() {
match scope {
TrackSelectionScope::FedSearch => {
let len = state.search.fed_tracks.len();
if let Some(indices) = state.track_selection.indices(&scope, len) {
return indices
.into_iter()
.filter_map(|i| state.search.fed_tracks.get(i).cloned())
.collect();
}
}
TrackSelectionScope::FedRelease(index) => {
let all = fed_release_tracks(state, index);
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
2026-07-21 00:12:45 +03:00
return indices
.into_iter()
.filter_map(|i| all.get(i).cloned())
.collect();
}
}
TrackSelectionScope::FedAppearsOn => {
let all = fed_appears_on_tracks(state);
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
return indices
.into_iter()
.filter_map(|i| all.get(i).cloned())
.collect();
2026-07-17 01:43:09 +03:00
}
}
_ => {}
}
}
// No selection: the federated track under the cursor.
match state.global.stack.last() {
Some(GlobalView::Search { cursor }) => {
let base = state.search.results.as_ref().map_or(0, |r| r.len())
+ state.search.fed_artists.len();
cursor
.checked_sub(base)
.and_then(|i| state.search.fed_tracks.get(i).cloned())
.into_iter()
.collect()
}
Some(GlobalView::FedRelease { index, cursor }) => cursor
.checked_sub(1)
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
.into_iter()
.collect(),
2026-07-21 00:12:45 +03:00
Some(GlobalView::FedArtist { cursor }) => {
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
return Vec::new();
};
2026-07-25 23:57:03 +03:00
let release_count = fed_artist_visible_release_indices(state, card).len();
2026-07-21 00:12:45 +03:00
cursor
2026-07-25 23:57:03 +03:00
.checked_sub(release_count)
2026-07-21 00:12:45 +03:00
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
.into_iter()
.collect()
}
2026-07-17 01:43:09 +03:00
_ => Vec::new(),
}
}
2026-07-25 01:23:29 +03:00
fn selected_downloadable_fed_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
let mut tracks = Vec::new();
for track in selected_tracks(state) {
if let Some(fed) = fed_track_for_download(&track)
&& !tracks
.iter()
.any(|existing| same_fed_download(existing, &fed))
{
tracks.push(fed);
}
}
for fed in selected_fed_tracks(state) {
if !tracks
.iter()
.any(|existing| same_fed_download(existing, &fed))
{
tracks.push(fed);
}
}
tracks
}
fn fed_track_for_download(track: &TrackItem) -> Option<crate::federation::FedTrack> {
if let Some(fed) = &track.fed {
return Some(fed.clone());
}
let content_id = track
.content_id
.as_deref()
.and_then(music_dht::normalize_content_id)?;
Some(crate::federation::FedTrack {
item_id: String::new(),
owner: String::new(),
own: false,
title: track.title.clone(),
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
year: track.release_year,
duration_seconds: (track.duration_seconds > 0.0)
.then(|| track.duration_seconds.round().max(0.0) as i64),
content_id: Some(content_id),
release_title: (!track.release_title.trim().is_empty())
.then(|| track.release_title.clone()),
track_number: track.track_number,
disc_number: track.disc_number,
})
}
fn same_fed_download(
left: &crate::federation::FedTrack,
right: &crate::federation::FedTrack,
) -> bool {
let left_content = left
.content_id
.as_deref()
.and_then(music_dht::normalize_content_id);
let right_content = right
.content_id
.as_deref()
.and_then(music_dht::normalize_content_id);
if left_content.is_some() && left_content == right_content {
return true;
}
!left.owner.is_empty()
&& !left.item_id.is_empty()
&& left.owner == right.owner
&& left.item_id == right.item_id
}
2026-07-17 01:17:54 +03:00
/// What Enter resolved to in the current view.
enum Outcome {
Push(GlobalView),
Play {
tracks: Vec<crate::library::models::TrackItem>,
start: usize,
},
OpenFedArtist(String),
2026-07-25 23:57:03 +03:00
OpenFedRelease {
name: String,
card: crate::federation::FedArtistCard,
index: usize,
},
2026-07-17 01:43:09 +03:00
DownloadFed(Vec<crate::federation::FedTrack>),
2026-07-17 01:17:54 +03:00
Nothing,
}
/// Enter inside the federated section of the search results: artists open
2026-07-17 15:29:10 +03:00
/// their card, tracks queue up like a local track list.
2026-07-17 01:17:54 +03:00
fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome {
let artists = &state.search.fed_artists;
if fed_index < artists.len() {
return Outcome::OpenFedArtist(artists[fed_index].name.clone());
}
2026-07-17 15:29:10 +03:00
let start = fed_index - artists.len();
if start >= state.search.fed_tracks.len() {
return Outcome::Nothing;
}
Outcome::Play {
tracks: state
.search
.fed_tracks
.iter()
.map(crate::federation::pending_track)
.collect(),
start,
2026-07-17 01:17:54 +03:00
}
}
/// A playable FedTrack out of a card row (first source; the rest are
/// fallbacks for a later improvement).
fn fed_track_from_card(
artist: &str,
release: &crate::federation::FedRelease,
track: &crate::federation::FedCardTrack,
2026-07-21 00:12:45 +03:00
own_owner: Option<&str>,
2026-07-17 01:17:54 +03:00
) -> Option<crate::federation::FedTrack> {
let (owner, item_id) = track.sources.first()?.clone();
2026-07-21 00:12:45 +03:00
let own = own_owner == Some(owner.as_str());
2026-07-17 01:17:54 +03:00
Some(crate::federation::FedTrack {
item_id,
owner,
2026-07-21 00:12:45 +03:00
own,
2026-07-17 01:17:54 +03:00
title: track.title.clone(),
2026-07-21 00:12:45 +03:00
artist_names: fed_card_main_artist_names(track, Some(artist)),
featured_artist_names: fed_card_featured_artist_names(track),
2026-07-17 01:17:54 +03:00
year: release.year,
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
2026-07-21 00:12:45 +03:00
content_id: track.content_id.clone(),
2026-07-17 15:29:10 +03:00
release_title: Some(release.title.clone()),
track_number: track.track_number,
disc_number: track.disc_number,
2026-07-17 01:17:54 +03:00
})
}
2026-07-21 00:12:45 +03:00
fn fed_track_from_appearance(
appearance: &crate::federation::FedAppearsOn,
own_owner: Option<&str>,
) -> Option<crate::federation::FedTrack> {
let (owner, item_id) = appearance.track.sources.first()?.clone();
let own = own_owner == Some(owner.as_str());
Some(crate::federation::FedTrack {
item_id,
owner,
own,
title: appearance.track.title.clone(),
artist_names: fed_card_main_artist_names(&appearance.track, None),
featured_artist_names: fed_card_featured_artist_names(&appearance.track),
year: appearance.year,
duration_seconds: appearance.track.duration_seconds.map(|d| d.round() as i64),
content_id: appearance.track.content_id.clone(),
release_title: (!appearance.release_title.is_empty())
.then(|| appearance.release_title.clone()),
track_number: appearance.track.track_number,
disc_number: appearance.track.disc_number,
})
}
fn fed_card_main_artist_names(
track: &crate::federation::FedCardTrack,
fallback: Option<&str>,
) -> Vec<String> {
if track.artists.is_empty() {
fallback.into_iter().map(str::to_string).collect()
} else {
track.artists.clone()
}
}
fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for artist in &track.featured_artists {
if !names
.iter()
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
{
names.push(artist.clone());
}
}
names
}
2026-07-23 18:48:58 +03:00
/// Enter on Settings: toggle switches, open text inputs, run
2026-07-16 20:29:51 +03:00
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
2026-07-23 18:48:58 +03:00
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
SettingsRow::Federation(FedRow::Toggle) => {
2026-07-16 20:29:51 +03:00
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
2026-07-17 01:17:54 +03:00
input: crate::app::input::LineEdit::default(),
2026-07-16 20:29:51 +03:00
});
return None;
}
settings.enabled = !settings.enabled;
Some(Effect::FedApplySettings)
}
2026-07-23 18:48:58 +03:00
SettingsRow::Federation(FedRow::NetworkId) => {
2026-07-16 20:29:51 +03:00
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
2026-07-17 01:17:54 +03:00
input: crate::app::input::LineEdit::new(
state.federation.settings.network_id.clone(),
),
2026-07-16 20:29:51 +03:00
});
None
}
2026-07-23 18:48:58 +03:00
SettingsRow::Federation(FedRow::SaveOnListen) => {
2026-07-16 20:29:51 +03:00
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
Some(Effect::FedApplySettings)
}
2026-07-23 18:48:58 +03:00
SettingsRow::Federation(FedRow::SyncNow) => Some(Effect::FedSyncNow),
SettingsRow::Federation(FedRow::ShowTicket) => Some(Effect::FedShowTicket),
SettingsRow::Federation(FedRow::Connect) => {
2026-07-16 20:29:51 +03:00
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectTicket,
2026-07-17 01:17:54 +03:00
input: crate::app::input::LineEdit::default(),
2026-07-16 20:29:51 +03:00
});
None
}
2026-07-25 02:10:20 +03:00
SettingsRow::StatusDetails => {
2026-07-26 00:45:53 +03:00
state.popup = Some(Popup::FederationStatusDetails {
focus: super::state::StatusDetailFocus::Status,
status_cursor: 0,
devices_scroll: 0,
logs_scroll: 0,
});
2026-07-25 02:10:20 +03:00
None
}
2026-07-24 00:54:27 +03:00
SettingsRow::DeviceName => {
if !require_connected_devices_enabled(state) {
return None;
}
2026-07-24 00:54:27 +03:00
let name = state
.federation
.devices
.as_ref()
.map(|status| status.this_device_name.clone())
.unwrap_or_default();
state.popup = Some(Popup::FedInput {
field: FedInputField::DeviceName,
input: crate::app::input::LineEdit::new(name),
});
None
}
SettingsRow::DeviceInvite => {
if !require_connected_devices_enabled(state) {
return None;
}
Some(Effect::DeviceShowInvite)
}
2026-07-24 00:54:27 +03:00
SettingsRow::DeviceConnect => {
if !require_connected_devices_enabled(state) {
return None;
}
2026-07-24 00:54:27 +03:00
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectInvite,
input: crate::app::input::LineEdit::default(),
});
None
}
SettingsRow::DeviceSyncNow => {
if !require_connected_devices_enabled(state) {
return None;
}
Some(Effect::DeviceSyncNow)
}
2026-07-26 01:41:16 +03:00
SettingsRow::DeviceLeaveGroup => {
if !require_connected_devices_enabled(state) {
return None;
}
state.popup = Some(Popup::ConfirmDeviceLeave);
None
}
2026-07-24 00:54:27 +03:00
SettingsRow::Device(index) => {
if !require_connected_devices_enabled(state) {
return None;
}
2026-07-24 00:54:27 +03:00
let Some(device) = state
.federation
.devices
.as_ref()
.and_then(|status| status.devices.get(index))
.cloned()
else {
return None;
};
if device.is_self || device.revoked {
state.status_message = Some("this device cannot be revoked here".to_string());
return None;
}
state.popup = Some(Popup::ConfirmDeviceRevoke {
device_id: device.device_id,
name: device.name,
});
None
}
2026-07-23 18:48:58 +03:00
SettingsRow::VisualizationClock => {
match state.visualizer.toggle_clock() {
Ok(()) => {
state.status_message = Some(format!(
"visualization clock {}",
if state.visualizer.config.show_clock {
"on"
} else {
"off"
}
));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationScript(index) => {
match state.visualizer.select_script(index) {
Ok(()) => {
let name = state
.visualizer
.scripts
.get(index)
.map(|script| script.name.clone())
.unwrap_or_else(|| "visualization".to_string());
state.status_message = Some(format!("visualization: {name}"));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationNew => match state.visualizer.create_script() {
Ok(path) => Some(Effect::OpenVisualizerEditor { path }),
Err(err) => {
state.status_message = Some(format!("visualization script: {err:#}"));
None
}
},
SettingsRow::VisualizationEdit => match state.visualizer.selected_script_path() {
Some(path) => Some(Effect::OpenVisualizerEditor { path }),
None => {
state.status_message = Some("no visualization script selected".into());
None
}
},
2026-07-16 20:29:51 +03:00
}
}
fn require_connected_devices_enabled(state: &mut AppState) -> bool {
if state.connected_devices_enabled() {
true
} else {
state.status_message = Some("enable federation before using connected devices".to_string());
false
}
}
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
/// if shuffle is on, shuffle everything after the chosen track right away.
2026-07-16 20:29:51 +03:00
pub(super) fn on_new_queue(state: &mut AppState) {
let player = &mut state.player;
player.original_order = None;
if player.shuffle && !player.queue.is_empty() {
2026-07-25 01:23:29 +03:00
player.original_order = Some(player.queue.iter().map(track_key).collect());
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
}
}
fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) {
let tail = &mut player.queue[start..];
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for i in (1..tail.len()).rev() {
tail.swap(i, next() as usize % (i + 1));
}
player.prefetched_pos = None;
}
2026-06-10 16:11:09 +01:00
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
/// search so the next `:/` starts clean.
fn go_back(state: &mut AppState) {
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
match state.active_tab {
Tab::Playlists => {
state.playlists.opened = None;
}
Tab::Global => {
2026-06-10 17:01:40 +01:00
// Esc on a view opened by Shift-J from another tab goes back to
// that tab, not down the Global stack.
2026-07-16 20:29:51 +03:00
if let Some((origin, depth)) = state.jump_origin
2026-07-21 00:12:45 +03:00
&& state.global.stack.len() == depth + 1
{
state.global.stack.pop();
state.jump_origin = None;
state.active_tab = origin;
return;
}
2026-07-17 01:17:54 +03:00
if let Some(popped) = state.global.stack.pop() {
if matches!(popped, GlobalView::Search { .. }) {
2026-06-10 16:11:09 +01:00
state.search = SearchState::default();
}
2026-07-17 01:17:54 +03:00
if matches!(popped, GlobalView::FedArtist { .. }) {
state.fed_artist_view = None;
}
}
2026-06-10 16:11:09 +01:00
}
_ => {}
}
}
fn switch_tab(state: &mut AppState, tab: Tab) {
state.active_tab = tab;
state.help_visible = false;
state.track_selection.clear();
2026-06-10 17:01:40 +01:00
// Manually leaving a view cancels any pending Shift-J return path.
state.jump_origin = None;
2026-06-10 16:11:09 +01:00
}
fn reset_tab(state: &mut AppState, tab: Tab) {
state.track_selection.clear();
2026-06-10 16:11:09 +01:00
match tab {
Tab::Global => {
if state
.global
.stack
.iter()
.any(|v| matches!(v, GlobalView::Search { .. }))
{
state.search = SearchState::default();
}
state.global.stack.clear();
2026-07-17 01:17:54 +03:00
state.fed_artist_view = None;
2026-06-10 16:11:09 +01:00
}
Tab::Playlists => state.playlists.opened = None,
2026-07-23 18:48:58 +03:00
Tab::Federation => state.settings_cursor = 0,
2026-06-10 16:23:20 +01:00
Tab::Logs => {
state.logs.follow = true;
state.logs.selected_seq = None;
2026-06-10 16:23:20 +01:00
}
Tab::Queue => {}
2026-06-10 16:11:09 +01:00
}
}
fn not_yet(state: &mut AppState, what: &str) {
state.status_message = Some(format!("{what}: coming in a later milestone"));
}
#[cfg(test)]
2026-07-26 02:07:07 +03:00
#[path = "update_tests.rs"]
mod tests;