Files
furumi_tui/src/app/state.rs
T

1050 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::app::input::LineEdit;
use crate::art::ArtImage;
use crate::config::keymap::KeyContext;
use crate::library::models::{
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem,
};
/// Remote data that a view renders: spinner, content, or error.
#[derive(Debug)]
pub enum Loadable<T> {
Loading,
Ready(T),
Failed(String),
}
/// Tile geometry for the Library artist grid (kept here so selection math in
/// 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,
}
/// A drill-down view pushed on top of the Library artist grid. Cursors live
/// 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).
Artist {
id: i64,
cursor: usize,
},
Release {
id: i64,
cursor: usize,
},
/// Linear cursor over search results: artists, then releases, then tracks.
Search {
cursor: usize,
},
/// A federated artist card (data lives in `AppState::fed_artist_view`).
FedArtist {
cursor: usize,
},
/// 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,
},
}
/// The Library tab: the whole server library of artists.
#[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,
pub filters: crate::config::settings::LibraryFilters,
pub stack: Vec<GlobalView>,
/// Page size, fixed at the first request — the offset is
/// `(page-1) * limit`, so it must not change between pages.
pub page_limit: Option<i64>,
/// 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,
}
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(),
filters: crate::config::settings::LibraryFilters::default(),
stack: Vec::new(),
page_limit: None,
reloading: false,
}
}
}
/// Releases of an artist in display order: grouped by type (albums, singles,
/// 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.
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
release_type_groups(
releases,
|release| &release.release_type,
|release| release.year,
|release| &release.title,
)
}
fn release_type_groups<T>(
items: &[T],
release_type: impl Fn(&T) -> &str,
release_year: impl Fn(&T) -> Option<i32>,
release_title: impl Fn(&T) -> &str,
) -> Vec<(&'static str, Vec<usize>)> {
const GROUPS: [(&str, &str); 4] = [
("album", "Albums"),
("single", "Singles"),
("ep", "EPs"),
("compilation", "Compilations"),
];
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
for (kind, label) in GROUPS {
let mut indices: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
.map(|(i, _)| i)
.collect();
sort_release_indices(&mut indices, items, &release_year, &release_title);
if !indices.is_empty() {
groups.push((label, indices));
}
}
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
let mut other: Vec<usize> = (0..items.len()).filter(|i| !known.contains(i)).collect();
sort_release_indices(&mut other, items, &release_year, &release_title);
if !other.is_empty() {
groups.push(("Other", other));
}
groups
}
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])))
});
}
/// 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>> {
grouped_release_rows(release_groups(releases), columns)
}
pub fn fed_release_groups(
releases: &[crate::federation::FedRelease],
) -> Vec<(&'static str, Vec<usize>)> {
release_type_groups(
releases,
|release| &release.release_type,
|release| release.year,
|release| &release.title,
)
}
/// 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>> {
let columns = columns.max(1);
let mut rows = Vec::new();
let mut position = 0;
for (_, group) in groups {
for chunk in group.chunks(columns) {
rows.push((position..position + chunk.len()).collect());
position += chunk.len();
}
}
rows
}
#[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]);
}
}
/// The virtual Likes playlist id (`kind == "likes"`).
pub use crate::library::LIKES_PLAYLIST_ID;
#[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>,
}
/// 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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrackSelectionScope {
ArtistTop(i64),
ArtistFeatured(i64),
Release(i64),
Playlist(i64),
Queue,
/// The federated section of the search results (its tracks).
FedSearch,
/// The tracklist of the open federated release view.
FedRelease(usize),
/// The appears-on track list of the open federated artist card.
FedAppearsOn,
}
/// 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())
}
}
/// 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,
/// Cursor anchored to a specific entry's seq; appends never move it.
/// None = newest (follow mode).
pub selected_seq: Option<u64>,
}
impl Default for LogsTab {
fn default() -> Self {
Self {
level_index: 2,
follow: true,
selected_seq: None,
}
}
}
/// 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),
}
/// What a confirmed delete removes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteTarget {
Track(i64),
Release(i64),
Artist(i64),
Playlist(i64),
}
/// One text field of an edit form.
#[derive(Debug, Clone)]
pub struct EditField {
pub label: &'static str,
pub value: LineEdit,
}
impl EditField {
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
Self {
label,
value: LineEdit::new(value),
}
}
}
/// 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.
#[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()),
}
}
}
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the playlists (last row = "create new"); the target is
/// 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.
NewPlaylist {
for_target: Option<PlaylistAddTarget>,
input: LineEdit,
busy: bool,
},
/// 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 },
/// Library-home filters. Cursor is kept for the next filters added here.
LibraryFilters { cursor: usize },
/// Track metadata viewer; left/right switch between selected tracks.
TrackInfo {
tracks: Vec<TrackItem>,
cursor: usize,
scroll: usize,
},
/// 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,
},
/// Full, wrapped view of one log entry (Enter on the Logs tab).
LogDetail(crate::config::logging::LogEntry),
/// One-line text entry on the Federation tab (network id, peer ticket).
FedInput {
field: FedInputField,
input: LineEdit,
},
/// Wrapped read-only text (this peer's connection ticket).
FedText { title: String, text: String },
/// Incoming trusted-device pairing request.
DevicePairing {
request_id: String,
device_id: String,
name: String,
client_version: String,
requester_group_id: Option<String>,
requester_group_active_devices: usize,
},
/// Confirmation before revoking a trusted device.
ConfirmDeviceRevoke { device_id: String, name: String },
/// Connected playback devices and their current role/status.
ConnectedDevices { cursor: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
NetworkId,
ConnectTicket,
DeviceName,
ConnectInvite,
}
impl FedInputField {
pub fn title(self) -> &'static str {
match self {
FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
FedInputField::DeviceName => "Device name",
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
}
}
}
/// Rows of the federation block inside Settings, in display order.
#[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,
];
}
/// 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),
DeviceName,
DeviceInvite,
DeviceConnect,
DeviceSyncNow,
Device(usize),
VisualizationClock,
VisualizationScript(usize),
VisualizationNew,
VisualizationEdit,
}
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
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = Vec::new();
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
rows.push(SettingsRow::DeviceConnect);
rows.push(SettingsRow::DeviceSyncNow);
rows.extend(
device_status_order(state)
.into_iter()
.map(SettingsRow::Device),
);
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.
#[derive(Debug, Default)]
pub struct FederationTab {
pub settings: crate::federation::FedSettings,
pub status: Option<crate::federation::FedStatus>,
pub devices: Option<crate::devices::DeviceSyncStatus>,
}
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
/// through likes, not direct adds).
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
match &state.playlists.list {
Some(Loadable::Ready(list)) => list
.iter()
.filter(|p| p.kind != "likes")
.map(|p| (p.id, p.title.clone()))
.collect(),
_ => Vec::new(),
}
}
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
#[derive(Debug, Default)]
pub struct Cmdline {
pub active: bool,
pub input: LineEdit,
/// 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>,
/// Tracks found on the federated network (empty while federation is
/// off); rendered as a separate, marked section.
pub fed_tracks: Vec<crate::federation::FedTrack>,
/// 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>,
pub fed_loading: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Global,
Playlists,
Queue,
Federation,
Logs,
}
impl Tab {
pub const ALL: [Tab; 5] = [
Tab::Global,
Tab::Playlists,
Tab::Queue,
Tab::Federation,
Tab::Logs,
];
pub fn title(self) -> &'static str {
match self {
Tab::Global => "Library",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Federation => "Settings",
Tab::Logs => "Logs",
}
}
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,
Tab::Federation => KeyContext::Federation,
Tab::Logs => KeyContext::Logs,
}
}
}
#[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,
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
/// 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,
/// Track ids in pre-shuffle order; restores the queue when shuffle is
/// turned off.
pub original_order: Option<Vec<i64>>,
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,
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
track_started_at: None,
prefetched_pos: None,
original_order: None,
volume: 80,
shuffle: false,
repeat: RepeatMode::Off,
}
}
}
#[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 local_idle_since_ms: Option<i64>,
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())
}
}
/// 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>,
pub settings_cursor: usize,
pub player: PlayerBar,
pub device_playback: DevicePlaybackState,
pub visualizer: crate::visualizer::VisualizerState,
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>,
/// Liked federated tracks (DHT item ids and content ids) — likes that
/// reference peers' tracks without downloading them.
pub fed_likes: std::collections::HashSet<String>,
pub likes_loaded: bool,
pub logs: LogsTab,
pub queue_tab: QueueTab,
pub federation: FederationTab,
/// 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>)>,
/// 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,
pub track_selection: TrackSelection,
/// 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)>,
pub popup: Option<Popup>,
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>,
}
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))
}
}