2026-07-24 04:11:01 +03:00
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
2026-06-10 16:11:09 +01:00
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
2026-07-17 01:17:54 +03:00
|
|
|
|
use crate::app::input::LineEdit;
|
2026-06-10 16:11:09 +01:00
|
|
|
|
use crate::art::ArtImage;
|
|
|
|
|
|
use crate::config::keymap::KeyContext;
|
2026-07-16 20:29:51 +03:00
|
|
|
|
use crate::library::models::{
|
|
|
|
|
|
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
|
|
|
|
|
SearchResults, TrackItem,
|
|
|
|
|
|
};
|
2026-06-10 16:11:09 +01:00
|
|
|
|
|
|
|
|
|
|
/// Remote data that a view renders: spinner, content, or error.
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub enum Loadable<T> {
|
|
|
|
|
|
Loading,
|
|
|
|
|
|
Ready(T),
|
|
|
|
|
|
Failed(String),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 19:43:47 +03:00
|
|
|
|
/// Tile geometry for the Library artist grid (kept here so selection math in
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// update() and rendering in ui::global agree). Width × height in cells,
|
|
|
|
|
|
/// including the tile border; the art area inside is 18×8 cells = 18×16 px.
|
|
|
|
|
|
pub const TILE_WIDTH: u16 = 20;
|
|
|
|
|
|
pub const TILE_HEIGHT: u16 = 12;
|
|
|
|
|
|
pub const ART_CELL_WIDTH: u16 = 18;
|
|
|
|
|
|
pub const ART_CELL_HEIGHT: u16 = 8;
|
|
|
|
|
|
/// Header artwork (artist page, release page): 24×12 cells = 24×24 px.
|
|
|
|
|
|
pub const ART_HEADER_WIDTH: u16 = 24;
|
|
|
|
|
|
pub const ART_HEADER_HEIGHT: u16 = 12;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
|
pub enum ViewMode {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Tiles,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ViewMode {
|
|
|
|
|
|
pub fn toggle(self) -> ViewMode {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
ViewMode::Tiles => ViewMode::Table,
|
|
|
|
|
|
ViewMode::Table => ViewMode::Tiles,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Artist image in the shared art cache.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub enum ArtState {
|
|
|
|
|
|
Loading,
|
|
|
|
|
|
Ready(Arc<ArtImage>),
|
|
|
|
|
|
Failed,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 19:43:47 +03:00
|
|
|
|
/// A drill-down view pushed on top of the Library artist grid. Cursors live
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// in the stack entry so going Back restores the previous position.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum GlobalView {
|
|
|
|
|
|
/// Linear cursor over top tracks (0..tracks) then releases in display
|
|
|
|
|
|
/// order (tracks..tracks+releases).
|
2026-06-10 23:30:03 +01:00
|
|
|
|
Artist {
|
|
|
|
|
|
id: i64,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
|
|
|
|
|
Release {
|
|
|
|
|
|
id: i64,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// Linear cursor over search results: artists, then releases, then tracks.
|
2026-06-10 23:30:03 +01:00
|
|
|
|
Search {
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
2026-07-17 01:17:54 +03:00
|
|
|
|
/// A federated artist card (data lives in `AppState::fed_artist_view`).
|
|
|
|
|
|
FedArtist {
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
2026-07-17 01:43:09 +03:00
|
|
|
|
/// One release of the open federated card: row 0 is the
|
|
|
|
|
|
/// download-release button, rows 1..=n are its tracks.
|
|
|
|
|
|
FedRelease {
|
|
|
|
|
|
index: usize,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 19:43:47 +03:00
|
|
|
|
/// The Library tab: the whole server library of artists.
|
2026-06-10 16:11:09 +01:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct GlobalTab {
|
|
|
|
|
|
pub artists: Vec<ArtistCard>,
|
|
|
|
|
|
pub total: i64,
|
|
|
|
|
|
pub has_more: bool,
|
|
|
|
|
|
pub next_page: i64,
|
|
|
|
|
|
pub loading: bool,
|
|
|
|
|
|
pub error: Option<String>,
|
|
|
|
|
|
pub selected: usize,
|
|
|
|
|
|
pub view: ViewMode,
|
2026-07-23 19:43:47 +03:00
|
|
|
|
pub filters: crate::config::settings::LibraryFilters,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub stack: Vec<GlobalView>,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// Page size, fixed at the first request — the offset is
|
2026-06-10 16:52:09 +01:00
|
|
|
|
/// `(page-1) * limit`, so it must not change between pages.
|
|
|
|
|
|
pub page_limit: Option<i64>,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// A full atomic reload is in flight (after a library change); incoming
|
|
|
|
|
|
/// pages of the old pagination are dropped until it lands.
|
|
|
|
|
|
pub reloading: bool,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for GlobalTab {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
artists: Vec::new(),
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
has_more: true,
|
|
|
|
|
|
next_page: 1,
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
error: None,
|
|
|
|
|
|
selected: 0,
|
|
|
|
|
|
view: ViewMode::default(),
|
2026-07-23 19:43:47 +03:00
|
|
|
|
filters: crate::config::settings::LibraryFilters::default(),
|
2026-06-10 16:11:09 +01:00
|
|
|
|
stack: Vec::new(),
|
2026-06-10 16:52:09 +01:00
|
|
|
|
page_limit: None,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
reloading: false,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 00:12:45 +03:00
|
|
|
|
/// Releases of an artist in display order: grouped by type (albums, singles,
|
2026-07-23 14:23:22 +03:00
|
|
|
|
/// EPs, compilations, then anything else), newest first within each group.
|
|
|
|
|
|
/// Returns (group label, indices into the original slice). Cursor positions
|
|
|
|
|
|
/// use this flattened order, so update() and ui must both go through here.
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
|
2026-07-23 14:23:22 +03:00
|
|
|
|
release_type_groups(
|
|
|
|
|
|
releases,
|
|
|
|
|
|
|release| &release.release_type,
|
|
|
|
|
|
|release| release.year,
|
|
|
|
|
|
|release| &release.title,
|
|
|
|
|
|
)
|
2026-07-21 00:12:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn release_type_groups<T>(
|
|
|
|
|
|
items: &[T],
|
|
|
|
|
|
release_type: impl Fn(&T) -> &str,
|
2026-07-23 14:23:22 +03:00
|
|
|
|
release_year: impl Fn(&T) -> Option<i32>,
|
|
|
|
|
|
release_title: impl Fn(&T) -> &str,
|
2026-07-21 00:12:45 +03:00
|
|
|
|
) -> Vec<(&'static str, Vec<usize>)> {
|
2026-06-10 16:11:09 +01:00
|
|
|
|
const GROUPS: [(&str, &str); 4] = [
|
|
|
|
|
|
("album", "Albums"),
|
|
|
|
|
|
("single", "Singles"),
|
2026-07-21 00:12:45 +03:00
|
|
|
|
("ep", "EPs"),
|
2026-06-10 16:11:09 +01:00
|
|
|
|
("compilation", "Compilations"),
|
|
|
|
|
|
];
|
|
|
|
|
|
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
|
|
|
|
|
for (kind, label) in GROUPS {
|
2026-07-23 14:23:22 +03:00
|
|
|
|
let mut indices: Vec<usize> = items
|
2026-06-10 16:11:09 +01:00
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
2026-07-21 00:12:45 +03:00
|
|
|
|
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
|
2026-06-10 16:11:09 +01:00
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
|
.collect();
|
2026-07-23 14:23:22 +03:00
|
|
|
|
sort_release_indices(&mut indices, items, &release_year, &release_title);
|
2026-06-10 16:11:09 +01:00
|
|
|
|
if !indices.is_empty() {
|
|
|
|
|
|
groups.push((label, indices));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
|
2026-07-23 14:23:22 +03:00
|
|
|
|
let mut other: Vec<usize> = (0..items.len()).filter(|i| !known.contains(i)).collect();
|
|
|
|
|
|
sort_release_indices(&mut other, items, &release_year, &release_title);
|
2026-06-10 16:11:09 +01:00
|
|
|
|
if !other.is_empty() {
|
|
|
|
|
|
groups.push(("Other", other));
|
|
|
|
|
|
}
|
|
|
|
|
|
groups
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 14:23:22 +03:00
|
|
|
|
fn sort_release_indices<T>(
|
|
|
|
|
|
indices: &mut [usize],
|
|
|
|
|
|
items: &[T],
|
|
|
|
|
|
release_year: &impl Fn(&T) -> Option<i32>,
|
|
|
|
|
|
release_title: &impl Fn(&T) -> &str,
|
|
|
|
|
|
) {
|
|
|
|
|
|
indices.sort_by(|&left, &right| {
|
|
|
|
|
|
release_year(&items[right])
|
|
|
|
|
|
.unwrap_or(i32::MIN)
|
|
|
|
|
|
.cmp(&release_year(&items[left]).unwrap_or(i32::MIN))
|
|
|
|
|
|
.then_with(|| release_title(&items[left]).cmp(release_title(&items[right])))
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// Flattened display order of releases (concatenated groups).
|
|
|
|
|
|
pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
|
|
|
|
|
|
release_groups(releases)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flat_map(|(_, indices)| indices)
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Visual tile-grid rows of the releases section: each group starts its own
|
|
|
|
|
|
/// rows, chunked by the column count. Values are display-order positions.
|
|
|
|
|
|
/// Vertical cursor movement must follow these rows to match the rendering.
|
|
|
|
|
|
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
|
2026-07-21 00:12:45 +03:00
|
|
|
|
grouped_release_rows(release_groups(releases), columns)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn fed_release_groups(
|
|
|
|
|
|
releases: &[crate::federation::FedRelease],
|
|
|
|
|
|
) -> Vec<(&'static str, Vec<usize>)> {
|
2026-07-23 14:23:22 +03:00
|
|
|
|
release_type_groups(
|
|
|
|
|
|
releases,
|
|
|
|
|
|
|release| &release.release_type,
|
|
|
|
|
|
|release| release.year,
|
|
|
|
|
|
|release| &release.title,
|
|
|
|
|
|
)
|
2026-07-21 00:12:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Flattened display order of federated releases (concatenated groups).
|
|
|
|
|
|
pub fn fed_release_display_order(releases: &[crate::federation::FedRelease]) -> Vec<usize> {
|
|
|
|
|
|
fed_release_groups(releases)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flat_map(|(_, indices)| indices)
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Visual tile-grid rows of the federated releases section.
|
|
|
|
|
|
pub fn fed_release_rows(
|
|
|
|
|
|
releases: &[crate::federation::FedRelease],
|
|
|
|
|
|
columns: usize,
|
|
|
|
|
|
) -> Vec<Vec<usize>> {
|
|
|
|
|
|
grouped_release_rows(fed_release_groups(releases), columns)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn grouped_release_rows(
|
|
|
|
|
|
groups: Vec<(&'static str, Vec<usize>)>,
|
|
|
|
|
|
columns: usize,
|
|
|
|
|
|
) -> Vec<Vec<usize>> {
|
2026-06-10 16:11:09 +01:00
|
|
|
|
let columns = columns.max(1);
|
|
|
|
|
|
let mut rows = Vec::new();
|
|
|
|
|
|
let mut position = 0;
|
2026-07-21 00:12:45 +03:00
|
|
|
|
for (_, group) in groups {
|
2026-06-10 16:11:09 +01:00
|
|
|
|
for chunk in group.chunks(columns) {
|
|
|
|
|
|
rows.push((position..position + chunk.len()).collect());
|
|
|
|
|
|
position += chunk.len();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 14:23:22 +03:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
fn release(id: i64, title: &str, release_type: &str, year: Option<i32>) -> ReleaseCard {
|
|
|
|
|
|
ReleaseCard {
|
|
|
|
|
|
id,
|
|
|
|
|
|
title: title.to_string(),
|
|
|
|
|
|
release_type: release_type.to_string(),
|
|
|
|
|
|
year,
|
|
|
|
|
|
cover_path: None,
|
|
|
|
|
|
track_count: 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn fed_release(
|
|
|
|
|
|
title: &str,
|
|
|
|
|
|
release_type: &str,
|
|
|
|
|
|
year: Option<i32>,
|
|
|
|
|
|
) -> crate::federation::FedRelease {
|
|
|
|
|
|
crate::federation::FedRelease {
|
|
|
|
|
|
title: title.to_string(),
|
|
|
|
|
|
release_type: release_type.to_string(),
|
|
|
|
|
|
year,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn release_display_order_is_newest_first_within_each_type() {
|
|
|
|
|
|
let releases = vec![
|
|
|
|
|
|
release(1, "Old Album", "album", Some(1991)),
|
|
|
|
|
|
release(2, "New Single", "single", Some(2024)),
|
|
|
|
|
|
release(3, "New Album", "album", Some(2020)),
|
|
|
|
|
|
release(4, "Undated Album", "album", None),
|
|
|
|
|
|
release(5, "Old Single", "single", Some(1999)),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(release_display_order(&releases), vec![2, 0, 3, 1, 4]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn fed_release_display_order_is_newest_first_within_each_type() {
|
|
|
|
|
|
let releases = vec![
|
|
|
|
|
|
fed_release("Old Album", "album", Some(1991)),
|
|
|
|
|
|
fed_release("New Single", "single", Some(2024)),
|
|
|
|
|
|
fed_release("New Album", "album", Some(2020)),
|
|
|
|
|
|
fed_release("Undated Album", "album", None),
|
|
|
|
|
|
fed_release("Old Single", "single", Some(1999)),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(fed_release_display_order(&releases), vec![2, 0, 3, 1, 4]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// The virtual Likes playlist id (`kind == "likes"`).
|
|
|
|
|
|
pub use crate::library::LIKES_PLAYLIST_ID;
|
2026-06-10 16:11:09 +01:00
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
|
pub struct OpenedPlaylist {
|
|
|
|
|
|
pub id: i64,
|
|
|
|
|
|
pub cursor: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The Playlists tab. The server list includes the virtual "Likes"
|
|
|
|
|
|
/// playlist (id = -1), rendered with a ♥ marker.
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct PlaylistsTab {
|
|
|
|
|
|
pub list: Option<Loadable<Vec<PlaylistCard>>>,
|
|
|
|
|
|
pub selected: usize,
|
|
|
|
|
|
pub opened: Option<OpenedPlaylist>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 16:52:09 +01:00
|
|
|
|
/// The Queue tab's own cursor — independent from the playing position, so
|
|
|
|
|
|
/// the user can browse and pick tracks while something else plays.
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct QueueTab {
|
|
|
|
|
|
pub cursor: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 12:28:34 +01:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub enum TrackSelectionScope {
|
|
|
|
|
|
ArtistTop(i64),
|
|
|
|
|
|
ArtistFeatured(i64),
|
|
|
|
|
|
Release(i64),
|
|
|
|
|
|
Playlist(i64),
|
|
|
|
|
|
Queue,
|
2026-07-17 01:43:09 +03:00
|
|
|
|
/// The federated section of the search results (its tracks).
|
|
|
|
|
|
FedSearch,
|
|
|
|
|
|
/// The tracklist of the open federated release view.
|
|
|
|
|
|
FedRelease(usize),
|
2026-07-21 00:12:45 +03:00
|
|
|
|
/// The appears-on track list of the open federated artist card.
|
|
|
|
|
|
FedAppearsOn,
|
2026-06-15 12:28:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Vim-like Shift-V selection for line-oriented track lists. The selected
|
|
|
|
|
|
/// range is always contiguous: anchor is where visual mode started, cursor is
|
|
|
|
|
|
/// extended by normal navigation.
|
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
|
pub struct TrackSelection {
|
|
|
|
|
|
pub scope: Option<TrackSelectionScope>,
|
|
|
|
|
|
pub anchor: usize,
|
|
|
|
|
|
pub cursor: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl TrackSelection {
|
|
|
|
|
|
pub fn is_active(&self) -> bool {
|
|
|
|
|
|
self.scope.is_some()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn is_active_for(&self, scope: &TrackSelectionScope) -> bool {
|
|
|
|
|
|
self.scope.as_ref() == Some(scope)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn start(&mut self, scope: TrackSelectionScope, cursor: usize) {
|
|
|
|
|
|
self.scope = Some(scope);
|
|
|
|
|
|
self.anchor = cursor;
|
|
|
|
|
|
self.cursor = cursor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn clear(&mut self) {
|
|
|
|
|
|
self.scope = None;
|
|
|
|
|
|
self.anchor = 0;
|
|
|
|
|
|
self.cursor = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn set_cursor(&mut self, scope: TrackSelectionScope, cursor: usize) {
|
|
|
|
|
|
if self.scope.as_ref() == Some(&scope) {
|
|
|
|
|
|
self.cursor = cursor;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn contains(&self, scope: &TrackSelectionScope, index: usize) -> bool {
|
|
|
|
|
|
if self.scope.as_ref() != Some(scope) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = self.anchor.min(self.cursor);
|
|
|
|
|
|
let end = self.anchor.max(self.cursor);
|
|
|
|
|
|
(start..=end).contains(&index)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn indices(&self, scope: &TrackSelectionScope, len: usize) -> Option<Vec<usize>> {
|
|
|
|
|
|
if len == 0 || self.scope.as_ref() != Some(scope) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = self.anchor.min(self.cursor).min(len - 1);
|
|
|
|
|
|
let end = self.anchor.max(self.cursor).min(len - 1);
|
|
|
|
|
|
Some((start..=end).collect())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 16:23:20 +01:00
|
|
|
|
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
|
|
|
|
|
|
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
|
|
|
|
|
tracing::Level::ERROR,
|
|
|
|
|
|
tracing::Level::WARN,
|
|
|
|
|
|
tracing::Level::INFO,
|
|
|
|
|
|
tracing::Level::DEBUG,
|
|
|
|
|
|
tracing::Level::TRACE,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/// The Logs tab: a live view over the in-memory ring buffer.
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct LogsTab {
|
|
|
|
|
|
/// Index into LOG_LEVELS; entries more verbose than this are hidden.
|
|
|
|
|
|
pub level_index: usize,
|
|
|
|
|
|
/// Stick to the newest entries as they arrive.
|
|
|
|
|
|
pub follow: bool,
|
2026-06-10 23:30:03 +01:00
|
|
|
|
/// Cursor anchored to a specific entry's seq; appends never move it.
|
|
|
|
|
|
/// None = newest (follow mode).
|
|
|
|
|
|
pub selected_seq: Option<u64>,
|
2026-06-10 16:23:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for LogsTab {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
level_index: 2,
|
|
|
|
|
|
follow: true,
|
2026-06-10 23:30:03 +01:00
|
|
|
|
selected_seq: None,
|
2026-06-10 16:23:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// What an open edit form writes to when saved.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum EditTarget {
|
|
|
|
|
|
Track(i64),
|
|
|
|
|
|
Release(i64),
|
|
|
|
|
|
Artist(i64),
|
|
|
|
|
|
Playlist(i64),
|
2026-06-10 23:30:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// What a confirmed delete removes.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum DeleteTarget {
|
|
|
|
|
|
Track(i64),
|
|
|
|
|
|
Release(i64),
|
|
|
|
|
|
Artist(i64),
|
|
|
|
|
|
Playlist(i64),
|
|
|
|
|
|
}
|
2026-06-10 23:30:03 +01:00
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// One text field of an edit form.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct EditField {
|
|
|
|
|
|
pub label: &'static str,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
pub value: LineEdit,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
}
|
2026-06-10 23:30:03 +01:00
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
impl EditField {
|
|
|
|
|
|
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
label,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
value: LineEdit::new(value),
|
2026-07-16 20:29:51 +03:00
|
|
|
|
}
|
2026-06-10 23:30:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 03:35:42 +03:00
|
|
|
|
/// What an add-to-playlist flow adds: local library tracks directly (including
|
|
|
|
|
|
/// already-materialized federation placeholders), or federated search/card
|
|
|
|
|
|
/// tracks that are downloaded into the library first.
|
2026-07-17 01:43:09 +03:00
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub enum PlaylistAddTarget {
|
|
|
|
|
|
Local(Vec<TrackItem>),
|
|
|
|
|
|
Fed(Vec<crate::federation::FedTrack>),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PlaylistAddTarget {
|
|
|
|
|
|
/// Short description for popup titles.
|
|
|
|
|
|
pub fn label(&self) -> String {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
PlaylistAddTarget::Local(tracks) if tracks.len() == 1 => tracks[0].title.clone(),
|
|
|
|
|
|
PlaylistAddTarget::Fed(tracks) if tracks.len() == 1 => {
|
|
|
|
|
|
format!("{} (federation)", tracks[0].title)
|
|
|
|
|
|
}
|
|
|
|
|
|
PlaylistAddTarget::Local(tracks) => format!("{} tracks", tracks.len()),
|
|
|
|
|
|
PlaylistAddTarget::Fed(tracks) => format!("{} tracks (federation)", tracks.len()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 23:30:03 +01:00
|
|
|
|
/// Modal dialog over the main screen.
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub enum Popup {
|
2026-07-23 17:35:11 +03:00
|
|
|
|
/// Pick one of the playlists (last row = "create new"); the target is
|
2026-07-17 01:43:09 +03:00
|
|
|
|
/// added on Enter (federated tracks are downloaded first).
|
|
|
|
|
|
AddToPlaylist {
|
|
|
|
|
|
target: PlaylistAddTarget,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// Name input for a new playlist; when `for_target` is set, it is
|
|
|
|
|
|
/// added to the playlist right after creation.
|
2026-06-10 23:30:03 +01:00
|
|
|
|
NewPlaylist {
|
2026-07-17 01:43:09 +03:00
|
|
|
|
for_target: Option<PlaylistAddTarget>,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
input: LineEdit,
|
2026-06-10 23:30:03 +01:00
|
|
|
|
busy: bool,
|
|
|
|
|
|
},
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// Metadata edit form for a track, release, artist or playlist.
|
|
|
|
|
|
Edit {
|
|
|
|
|
|
target: EditTarget,
|
|
|
|
|
|
title: String,
|
|
|
|
|
|
fields: Vec<EditField>,
|
|
|
|
|
|
focus: usize,
|
|
|
|
|
|
error: Option<String>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
|
|
|
|
|
ConfirmDelete { target: DeleteTarget, label: String },
|
2026-07-23 19:43:47 +03:00
|
|
|
|
/// Library-home filters. Cursor is kept for the next filters added here.
|
|
|
|
|
|
LibraryFilters { cursor: usize },
|
2026-06-15 12:28:34 +01:00
|
|
|
|
/// Track metadata viewer; left/right switch between selected tracks.
|
|
|
|
|
|
TrackInfo {
|
|
|
|
|
|
tracks: Vec<TrackItem>,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
scroll: usize,
|
|
|
|
|
|
},
|
2026-07-23 16:45:30 +03:00
|
|
|
|
/// Artist picker over the info popup ('a' with several artists):
|
|
|
|
|
|
/// Enter jumps to the chosen artist's page, Esc returns to the info.
|
|
|
|
|
|
TrackArtists {
|
|
|
|
|
|
tracks: Vec<TrackItem>,
|
|
|
|
|
|
cursor: usize,
|
|
|
|
|
|
scroll: usize,
|
|
|
|
|
|
selected: usize,
|
|
|
|
|
|
},
|
2026-06-10 23:30:03 +01:00
|
|
|
|
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
|
|
|
|
|
LogDetail(crate::config::logging::LogEntry),
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// One-line text entry on the Federation tab (network id, peer ticket).
|
|
|
|
|
|
FedInput {
|
|
|
|
|
|
field: FedInputField,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
input: LineEdit,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
},
|
|
|
|
|
|
/// Wrapped read-only text (this peer's connection ticket).
|
|
|
|
|
|
FedText { title: String, text: String },
|
2026-07-24 00:54:27 +03:00
|
|
|
|
/// Incoming trusted-device pairing request.
|
|
|
|
|
|
DevicePairing {
|
|
|
|
|
|
request_id: String,
|
|
|
|
|
|
device_id: String,
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
client_version: String,
|
2026-07-24 02:21:24 +03:00
|
|
|
|
requester_group_id: Option<String>,
|
|
|
|
|
|
requester_group_active_devices: usize,
|
2026-07-24 00:54:27 +03:00
|
|
|
|
},
|
|
|
|
|
|
/// Confirmation before revoking a trusted device.
|
|
|
|
|
|
ConfirmDeviceRevoke { device_id: String, name: String },
|
2026-07-24 04:11:01 +03:00
|
|
|
|
/// Connected playback devices and their current role/status.
|
|
|
|
|
|
ConnectedDevices { cursor: usize },
|
2026-06-10 23:30:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 20:29:51 +03:00
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum FedInputField {
|
|
|
|
|
|
NetworkId,
|
|
|
|
|
|
ConnectTicket,
|
2026-07-24 00:54:27 +03:00
|
|
|
|
DeviceName,
|
|
|
|
|
|
ConnectInvite,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FedInputField {
|
|
|
|
|
|
pub fn title(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
FedInputField::NetworkId => "Network ID",
|
|
|
|
|
|
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
2026-07-24 00:54:27 +03:00
|
|
|
|
FedInputField::DeviceName => "Device name",
|
|
|
|
|
|
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
2026-07-16 20:29:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 18:48:58 +03:00
|
|
|
|
/// Rows of the federation block inside Settings, in display order.
|
2026-07-16 20:29:51 +03:00
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum FedRow {
|
|
|
|
|
|
Toggle,
|
|
|
|
|
|
NetworkId,
|
|
|
|
|
|
SaveOnListen,
|
|
|
|
|
|
SyncNow,
|
|
|
|
|
|
ShowTicket,
|
|
|
|
|
|
Connect,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FedRow {
|
|
|
|
|
|
pub const ALL: [FedRow; 6] = [
|
|
|
|
|
|
FedRow::Toggle,
|
|
|
|
|
|
FedRow::NetworkId,
|
|
|
|
|
|
FedRow::SaveOnListen,
|
|
|
|
|
|
FedRow::SyncNow,
|
|
|
|
|
|
FedRow::ShowTicket,
|
|
|
|
|
|
FedRow::Connect,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 18:48:58 +03:00
|
|
|
|
/// Rows of the Settings tab, in display order. Federation rows are fixed;
|
|
|
|
|
|
/// visualization script rows are derived from the scripts currently found in
|
|
|
|
|
|
/// the config directory.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum SettingsRow {
|
|
|
|
|
|
Federation(FedRow),
|
2026-07-24 00:54:27 +03:00
|
|
|
|
DeviceName,
|
|
|
|
|
|
DeviceInvite,
|
|
|
|
|
|
DeviceConnect,
|
|
|
|
|
|
DeviceSyncNow,
|
|
|
|
|
|
Device(usize),
|
2026-07-23 18:48:58 +03:00
|
|
|
|
VisualizationClock,
|
|
|
|
|
|
VisualizationScript(usize),
|
|
|
|
|
|
VisualizationNew,
|
|
|
|
|
|
VisualizationEdit,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 04:11:01 +03:00
|
|
|
|
pub const DEVICE_ONLINE_TTL_MS: i64 = 45_000;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum DevicePresenceSection {
|
|
|
|
|
|
Online,
|
|
|
|
|
|
Offline,
|
|
|
|
|
|
Revoked,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl DevicePresenceSection {
|
|
|
|
|
|
pub fn title(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
DevicePresenceSection::Online => "Online devices",
|
|
|
|
|
|
DevicePresenceSection::Offline => "Offline devices",
|
|
|
|
|
|
DevicePresenceSection::Revoked => "Revoked devices",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn sort_rank(self) -> u8 {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
DevicePresenceSection::Online => 0,
|
|
|
|
|
|
DevicePresenceSection::Offline => 1,
|
|
|
|
|
|
DevicePresenceSection::Revoked => 2,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn unix_time_ms() -> i64 {
|
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
|
.map(|d| d.as_millis() as i64)
|
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn device_display_name(device: &crate::devices::DeviceStatusRow) -> String {
|
|
|
|
|
|
if device.name.trim().is_empty() {
|
|
|
|
|
|
device.device_id.chars().take(10).collect()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
device.name.clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn device_status_active(state: &AppState, device_id: &str) -> bool {
|
|
|
|
|
|
state
|
|
|
|
|
|
.device_playback
|
|
|
|
|
|
.active_device_id
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.is_some_and(|active| active == device_id)
|
|
|
|
|
|
|| state
|
|
|
|
|
|
.device_playback
|
|
|
|
|
|
.remote
|
|
|
|
|
|
.get(device_id)
|
|
|
|
|
|
.is_some_and(|snapshot| snapshot.active)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn device_status_online(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
device: &crate::devices::DeviceStatusRow,
|
|
|
|
|
|
now_ms: i64,
|
|
|
|
|
|
) -> bool {
|
|
|
|
|
|
device.is_self
|
|
|
|
|
|
|| device.device_id == state.device_playback.self_device_id
|
|
|
|
|
|
|| device
|
|
|
|
|
|
.last_seen_ms
|
|
|
|
|
|
.is_some_and(|seen| now_ms.saturating_sub(seen) <= DEVICE_ONLINE_TTL_MS)
|
|
|
|
|
|
|| state
|
|
|
|
|
|
.device_playback
|
|
|
|
|
|
.remote
|
|
|
|
|
|
.get(&device.device_id)
|
|
|
|
|
|
.is_some_and(|snapshot| {
|
|
|
|
|
|
now_ms.saturating_sub(snapshot.updated_at_ms) <= DEVICE_ONLINE_TTL_MS
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn device_presence_section(
|
|
|
|
|
|
state: &AppState,
|
|
|
|
|
|
device: &crate::devices::DeviceStatusRow,
|
|
|
|
|
|
now_ms: i64,
|
|
|
|
|
|
) -> DevicePresenceSection {
|
|
|
|
|
|
if device.revoked {
|
|
|
|
|
|
DevicePresenceSection::Revoked
|
|
|
|
|
|
} else if device_status_online(state, device, now_ms) {
|
|
|
|
|
|
DevicePresenceSection::Online
|
|
|
|
|
|
} else {
|
|
|
|
|
|
DevicePresenceSection::Offline
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn device_status_order(state: &AppState) -> Vec<usize> {
|
|
|
|
|
|
let Some(status) = &state.federation.devices else {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
};
|
|
|
|
|
|
let now = unix_time_ms();
|
|
|
|
|
|
let mut indices: Vec<usize> = status
|
|
|
|
|
|
.devices
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter_map(|(index, device)| (!device.revoked).then_some(index))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
indices.sort_by(|left, right| {
|
|
|
|
|
|
let left_device = &status.devices[*left];
|
|
|
|
|
|
let right_device = &status.devices[*right];
|
|
|
|
|
|
let left_section = device_presence_section(state, left_device, now).sort_rank();
|
|
|
|
|
|
let right_section = device_presence_section(state, right_device, now).sort_rank();
|
|
|
|
|
|
(
|
|
|
|
|
|
left_section,
|
|
|
|
|
|
!device_status_active(state, &left_device.device_id),
|
|
|
|
|
|
!left_device.is_self,
|
|
|
|
|
|
device_display_name(left_device).to_ascii_lowercase(),
|
|
|
|
|
|
left_device.device_id.as_str(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.cmp(&(
|
|
|
|
|
|
right_section,
|
|
|
|
|
|
!device_status_active(state, &right_device.device_id),
|
|
|
|
|
|
!right_device.is_self,
|
|
|
|
|
|
device_display_name(right_device).to_ascii_lowercase(),
|
|
|
|
|
|
right_device.device_id.as_str(),
|
|
|
|
|
|
))
|
|
|
|
|
|
});
|
|
|
|
|
|
indices
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 18:48:58 +03:00
|
|
|
|
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
|
|
|
|
|
let mut rows = Vec::new();
|
|
|
|
|
|
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
2026-07-24 00:54:27 +03:00
|
|
|
|
rows.push(SettingsRow::DeviceName);
|
|
|
|
|
|
rows.push(SettingsRow::DeviceInvite);
|
|
|
|
|
|
rows.push(SettingsRow::DeviceConnect);
|
|
|
|
|
|
rows.push(SettingsRow::DeviceSyncNow);
|
2026-07-24 04:11:01 +03:00
|
|
|
|
rows.extend(
|
|
|
|
|
|
device_status_order(state)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(SettingsRow::Device),
|
|
|
|
|
|
);
|
2026-07-23 18:48:58 +03:00
|
|
|
|
rows.push(SettingsRow::VisualizationClock);
|
|
|
|
|
|
rows.extend(
|
|
|
|
|
|
state
|
|
|
|
|
|
.visualizer
|
|
|
|
|
|
.scripts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.map(|(index, _)| SettingsRow::VisualizationScript(index)),
|
|
|
|
|
|
);
|
|
|
|
|
|
rows.push(SettingsRow::VisualizationNew);
|
|
|
|
|
|
if !state.visualizer.scripts.is_empty() {
|
|
|
|
|
|
rows.push(SettingsRow::VisualizationEdit);
|
|
|
|
|
|
}
|
|
|
|
|
|
rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Federation settings mirror + the latest status snapshot for Settings.
|
2026-07-16 20:29:51 +03:00
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct FederationTab {
|
|
|
|
|
|
pub settings: crate::federation::FedSettings,
|
|
|
|
|
|
pub status: Option<crate::federation::FedStatus>,
|
2026-07-24 00:54:27 +03:00
|
|
|
|
pub devices: Option<crate::devices::DeviceSyncStatus>,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
|
|
|
|
|
/// through likes, not direct adds).
|
2026-06-10 23:30:03 +01:00
|
|
|
|
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
|
|
|
|
|
match &state.playlists.list {
|
|
|
|
|
|
Some(Loadable::Ready(list)) => list
|
|
|
|
|
|
.iter()
|
2026-07-16 20:29:51 +03:00
|
|
|
|
.filter(|p| p.kind != "likes")
|
2026-06-10 23:30:03 +01:00
|
|
|
|
.map(|p| (p.id, p.title.clone()))
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
_ => Vec::new(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct Cmdline {
|
|
|
|
|
|
pub active: bool,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
pub input: LineEdit,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// A live command (search) applied effects during this session; Esc
|
|
|
|
|
|
/// undoes them, Enter keeps them.
|
|
|
|
|
|
pub live: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Live search state driven by the `:/query` command.
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct SearchState {
|
|
|
|
|
|
pub query: String,
|
|
|
|
|
|
pub loading: bool,
|
|
|
|
|
|
pub results: Option<SearchResults>,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
/// Tracks found on the federated network (empty while federation is
|
|
|
|
|
|
/// off); rendered as a separate, marked section.
|
|
|
|
|
|
pub fed_tracks: Vec<crate::federation::FedTrack>,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
/// Artists a federated card can be opened for — from artist records and
|
|
|
|
|
|
/// from the artist names of matching tracks.
|
|
|
|
|
|
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
pub fed_loading: bool,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
|
pub enum Tab {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Global,
|
|
|
|
|
|
Playlists,
|
|
|
|
|
|
Queue,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
Federation,
|
2026-06-10 16:23:20 +01:00
|
|
|
|
Logs,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Tab {
|
2026-07-16 20:29:51 +03:00
|
|
|
|
pub const ALL: [Tab; 5] = [
|
|
|
|
|
|
Tab::Global,
|
|
|
|
|
|
Tab::Playlists,
|
|
|
|
|
|
Tab::Queue,
|
|
|
|
|
|
Tab::Federation,
|
|
|
|
|
|
Tab::Logs,
|
|
|
|
|
|
];
|
2026-06-10 16:11:09 +01:00
|
|
|
|
|
|
|
|
|
|
pub fn title(self) -> &'static str {
|
|
|
|
|
|
match self {
|
2026-07-23 19:43:47 +03:00
|
|
|
|
Tab::Global => "Library",
|
2026-06-10 16:11:09 +01:00
|
|
|
|
Tab::Playlists => "Playlists",
|
|
|
|
|
|
Tab::Queue => "Queue",
|
2026-07-23 18:48:58 +03:00
|
|
|
|
Tab::Federation => "Settings",
|
2026-06-10 16:23:20 +01:00
|
|
|
|
Tab::Logs => "Logs",
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn index(self) -> usize {
|
|
|
|
|
|
Self::ALL.iter().position(|t| *t == self).unwrap()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn from_index(index: usize) -> Option<Tab> {
|
|
|
|
|
|
Self::ALL.get(index).copied()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn next(self) -> Tab {
|
|
|
|
|
|
Self::ALL[(self.index() + 1) % Self::ALL.len()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn prev(self) -> Tab {
|
|
|
|
|
|
Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn key_context(self) -> KeyContext {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
Tab::Global => KeyContext::Library,
|
|
|
|
|
|
Tab::Playlists => KeyContext::Playlists,
|
|
|
|
|
|
Tab::Queue => KeyContext::Queue,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
Tab::Federation => KeyContext::Federation,
|
2026-06-10 16:23:20 +01:00
|
|
|
|
Tab::Logs => KeyContext::Logs,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
|
pub enum RepeatMode {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Off,
|
|
|
|
|
|
One,
|
|
|
|
|
|
All,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl RepeatMode {
|
|
|
|
|
|
pub fn label(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
RepeatMode::Off => "off",
|
|
|
|
|
|
RepeatMode::One => "one",
|
|
|
|
|
|
RepeatMode::All => "all",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn next(self) -> RepeatMode {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
RepeatMode::Off => RepeatMode::All,
|
|
|
|
|
|
RepeatMode::All => RepeatMode::One,
|
|
|
|
|
|
RepeatMode::One => RepeatMode::Off,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Playback state mirrored for the UI: the queue, the loaded track and the
|
|
|
|
|
|
/// position polled from the audio thread on every tick.
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct PlayerBar {
|
|
|
|
|
|
pub queue: Vec<TrackItem>,
|
|
|
|
|
|
pub queue_pos: usize,
|
|
|
|
|
|
pub current: Option<TrackItem>,
|
|
|
|
|
|
/// A track is loaded (playing or paused); false = stopped.
|
|
|
|
|
|
pub playing: bool,
|
|
|
|
|
|
pub paused: bool,
|
|
|
|
|
|
pub position_secs: f64,
|
2026-07-23 18:48:58 +03:00
|
|
|
|
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// Epoch seconds when the current track started (for history reports).
|
|
|
|
|
|
pub track_started_at: Option<i64>,
|
|
|
|
|
|
/// Queue index already enqueued in the audio thread for gapless play.
|
|
|
|
|
|
pub prefetched_pos: Option<usize>,
|
|
|
|
|
|
pub volume: u8,
|
|
|
|
|
|
pub shuffle: bool,
|
2026-06-10 16:52:09 +01:00
|
|
|
|
/// Track ids in pre-shuffle order; restores the queue when shuffle is
|
|
|
|
|
|
/// turned off.
|
|
|
|
|
|
pub original_order: Option<Vec<i64>>,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub repeat: RepeatMode,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for PlayerBar {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
queue: Vec::new(),
|
|
|
|
|
|
queue_pos: 0,
|
|
|
|
|
|
current: None,
|
|
|
|
|
|
playing: false,
|
|
|
|
|
|
paused: false,
|
|
|
|
|
|
position_secs: 0.0,
|
2026-07-23 18:48:58 +03:00
|
|
|
|
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
|
2026-06-10 16:11:09 +01:00
|
|
|
|
track_started_at: None,
|
|
|
|
|
|
prefetched_pos: None,
|
2026-06-10 16:52:09 +01:00
|
|
|
|
original_order: None,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
volume: 80,
|
|
|
|
|
|
shuffle: false,
|
|
|
|
|
|
repeat: RepeatMode::Off,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 04:11:01 +03:00
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
|
pub enum DevicePlaybackRole {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Active,
|
|
|
|
|
|
Control,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl DevicePlaybackRole {
|
|
|
|
|
|
pub fn label(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
DevicePlaybackRole::Active => "active",
|
|
|
|
|
|
DevicePlaybackRole::Control => "control",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct DevicePlaybackState {
|
|
|
|
|
|
pub role: DevicePlaybackRole,
|
|
|
|
|
|
pub self_device_id: String,
|
|
|
|
|
|
pub self_device_name: String,
|
|
|
|
|
|
pub active_device_id: Option<String>,
|
|
|
|
|
|
pub active_device_name: Option<String>,
|
|
|
|
|
|
pub online_devices: usize,
|
|
|
|
|
|
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
|
|
|
|
|
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl DevicePlaybackState {
|
|
|
|
|
|
pub fn is_control(&self) -> bool {
|
|
|
|
|
|
self.role == DevicePlaybackRole::Control
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn active_label(&self) -> String {
|
|
|
|
|
|
self.active_device_name
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.or_else(|| self.active_device_id.clone())
|
|
|
|
|
|
.unwrap_or_else(|| "this device".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 16:11:09 +01:00
|
|
|
|
/// Single source of truth for the UI. Mutated only by `update()` and the
|
|
|
|
|
|
/// event handlers in the main loop; views render from `&AppState`.
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
|
pub struct AppState {
|
|
|
|
|
|
pub active_tab: Tab,
|
|
|
|
|
|
pub should_quit: bool,
|
|
|
|
|
|
/// Double-press quit confirmation: set by the first Quit press, expires
|
|
|
|
|
|
/// after a short window (any other action also cancels it).
|
|
|
|
|
|
pub quit_armed_until: Option<std::time::Instant>,
|
|
|
|
|
|
pub help_visible: bool,
|
|
|
|
|
|
pub pending_keys: Option<String>,
|
|
|
|
|
|
pub status_message: Option<String>,
|
2026-07-23 18:48:58 +03:00
|
|
|
|
pub settings_cursor: usize,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub player: PlayerBar,
|
2026-07-24 04:11:01 +03:00
|
|
|
|
pub device_playback: DevicePlaybackState,
|
2026-07-23 18:48:58 +03:00
|
|
|
|
pub visualizer: crate::visualizer::VisualizerState,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub global: GlobalTab,
|
|
|
|
|
|
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
|
|
|
|
|
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
|
|
|
|
|
pub playlists: PlaylistsTab,
|
|
|
|
|
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
|
|
|
|
|
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
|
|
|
|
|
pub likes: std::collections::HashSet<i64>,
|
2026-07-24 02:21:24 +03:00
|
|
|
|
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
|
|
|
|
|
/// reference peers' tracks without downloading them.
|
2026-07-17 15:29:10 +03:00
|
|
|
|
pub fed_likes: std::collections::HashSet<String>,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub likes_loaded: bool,
|
2026-06-10 16:23:20 +01:00
|
|
|
|
pub logs: LogsTab,
|
2026-06-10 16:52:09 +01:00
|
|
|
|
pub queue_tab: QueueTab,
|
2026-07-16 20:29:51 +03:00
|
|
|
|
pub federation: FederationTab,
|
2026-07-17 01:17:54 +03:00
|
|
|
|
/// The one federated artist card being viewed (name + loading state);
|
|
|
|
|
|
/// opening another card replaces it.
|
|
|
|
|
|
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
2026-07-17 15:29:10 +03:00
|
|
|
|
/// The "search this artist in the federation" button of the open local
|
|
|
|
|
|
/// artist view has the focus (reached by pressing Up from the first
|
|
|
|
|
|
/// row, like the download button on a federated release).
|
|
|
|
|
|
pub artist_fed_button: bool,
|
2026-06-15 12:28:34 +01:00
|
|
|
|
pub track_selection: TrackSelection,
|
2026-06-10 17:01:40 +01:00
|
|
|
|
/// Shift-J jump in flight: focus this (release, track) once the release
|
|
|
|
|
|
/// view finishes loading.
|
|
|
|
|
|
pub pending_release_focus: Option<(i64, i64)>,
|
|
|
|
|
|
/// Where a Shift-J jump came from (tab, stack depth of the pushed
|
|
|
|
|
|
/// view): Esc from that view returns to the origin tab instead of
|
|
|
|
|
|
/// unwinding the Global stack.
|
|
|
|
|
|
pub jump_origin: Option<(Tab, usize)>,
|
2026-06-10 23:30:03 +01:00
|
|
|
|
pub popup: Option<Popup>,
|
2026-06-10 16:11:09 +01:00
|
|
|
|
pub cmdline: Cmdline,
|
|
|
|
|
|
pub search: SearchState,
|
|
|
|
|
|
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
|
|
|
|
|
/// every view that shows artwork.
|
|
|
|
|
|
pub art: HashMap<String, ArtState>,
|
|
|
|
|
|
}
|
2026-07-24 02:21:24 +03:00
|
|
|
|
|
|
|
|
|
|
impl AppState {
|
|
|
|
|
|
pub fn connected_devices_enabled(&self) -> bool {
|
|
|
|
|
|
self.federation.settings.enabled && !self.federation.settings.network_id.trim().is_empty()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn fed_track_liked(&self, fed: &crate::federation::FedTrack) -> bool {
|
|
|
|
|
|
self.fed_likes.contains(&fed.item_id)
|
|
|
|
|
|
|| fed
|
|
|
|
|
|
.content_id
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.and_then(music_dht::normalize_content_id)
|
|
|
|
|
|
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn fed_card_track_liked(&self, track: &crate::federation::FedCardTrack) -> bool {
|
|
|
|
|
|
track
|
|
|
|
|
|
.content_id
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.and_then(music_dht::normalize_content_id)
|
|
|
|
|
|
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
|
|
|
|
|
|| track
|
|
|
|
|
|
.sources
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, item_id)| self.fed_likes.contains(item_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|