Init
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Semantic commands the user can trigger. Raw key events are translated into
|
||||
/// these by the keymap; views and `update()` never see raw keys.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum Action {
|
||||
Quit,
|
||||
NextTab,
|
||||
PrevTab,
|
||||
GoToTab(usize),
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
PageUp,
|
||||
PageDown,
|
||||
SelectFirst,
|
||||
SelectLast,
|
||||
Select,
|
||||
Back,
|
||||
PlayPause,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
SeekForward { seconds: u32 },
|
||||
SeekBackward { seconds: u32 },
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleShuffle,
|
||||
CycleRepeat,
|
||||
ToggleLike,
|
||||
QueueAddNext,
|
||||
QueueAddLast,
|
||||
ToggleHelp,
|
||||
ToggleViewMode,
|
||||
OpenCommandLine,
|
||||
Logout,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Action::Quit => "Quit".into(),
|
||||
Action::NextTab => "Next tab".into(),
|
||||
Action::PrevTab => "Previous tab".into(),
|
||||
Action::GoToTab(i) => format!("Go to tab {}", i + 1),
|
||||
Action::MoveUp => "Move up".into(),
|
||||
Action::MoveDown => "Move down".into(),
|
||||
Action::MoveLeft => "Move left".into(),
|
||||
Action::MoveRight => "Move right".into(),
|
||||
Action::PageUp => "Page up".into(),
|
||||
Action::PageDown => "Page down".into(),
|
||||
Action::SelectFirst => "Jump to first item".into(),
|
||||
Action::SelectLast => "Jump to last item".into(),
|
||||
Action::Select => "Open / activate".into(),
|
||||
Action::Back => "Go back / close".into(),
|
||||
Action::PlayPause => "Play / pause".into(),
|
||||
Action::NextTrack => "Next track".into(),
|
||||
Action::PrevTrack => "Previous track".into(),
|
||||
Action::SeekForward { seconds } => format!("Seek forward {seconds}s"),
|
||||
Action::SeekBackward { seconds } => format!("Seek backward {seconds}s"),
|
||||
Action::VolumeUp => "Volume up".into(),
|
||||
Action::VolumeDown => "Volume down".into(),
|
||||
Action::ToggleShuffle => "Toggle shuffle".into(),
|
||||
Action::CycleRepeat => "Cycle repeat mode".into(),
|
||||
Action::ToggleLike => "Like / unlike".into(),
|
||||
Action::QueueAddNext => "Queue: add next".into(),
|
||||
Action::QueueAddLast => "Queue: add to end".into(),
|
||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||
Action::OpenCommandLine => "Open command line (:/name searches)".into(),
|
||||
Action::Logout => "Sign out".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::api::client::ApiError;
|
||||
use crate::app::Runtime;
|
||||
use crate::app::command::{self, Command, Parsed};
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
|
||||
|
||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
|
||||
const SEARCH_LIMIT: i64 = 12;
|
||||
|
||||
/// Keys go here instead of the keymap while the command line is open.
|
||||
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => cancel(state),
|
||||
KeyCode::Enter => commit(state),
|
||||
KeyCode::Backspace => {
|
||||
if state.cmdline.input.pop().is_none() {
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
cancel(state);
|
||||
return;
|
||||
}
|
||||
after_change(state, runtime);
|
||||
}
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
state.cmdline.input.push(c);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
state.cmdline.input.push_str(&cleaned);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
|
||||
fn is_typing(key: KeyEvent) -> bool {
|
||||
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
|
||||
}
|
||||
|
||||
/// Re-evaluate the input after every edit; live commands (search) take
|
||||
/// effect immediately, while typing.
|
||||
fn after_change(state: &mut AppState, runtime: &Runtime) {
|
||||
match command::parse(&state.cmdline.input) {
|
||||
Parsed::Command(command) if command::is_live(&command) => {
|
||||
apply_live(state, runtime, command);
|
||||
}
|
||||
_ => retract_live(state),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
match command {
|
||||
Command::Search(query) => {
|
||||
state.active_tab = Tab::Global;
|
||||
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
|
||||
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||
}
|
||||
state.cmdline.live = true;
|
||||
if state.search.query != query {
|
||||
state.search.query = query;
|
||||
set_view_cursor_zero(state);
|
||||
schedule_search(state, runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_view_cursor_zero(state: &mut AppState) {
|
||||
if let Some(GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||
*cursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced, race-free search: every edit bumps the global sequence; the
|
||||
/// spawned task only queries if it is still the latest after the debounce,
|
||||
/// and the receiver drops responses that arrive out of date.
|
||||
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let query = state.search.query.clone();
|
||||
if query.is_empty() {
|
||||
state.search.loading = false;
|
||||
state.search.results = None;
|
||||
return;
|
||||
}
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
state.search.loading = true;
|
||||
let tx = runtime.event_tx.clone();
|
||||
let latest = Arc::clone(&runtime.search_seq);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(SEARCH_DEBOUNCE).await;
|
||||
if latest.load(Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
let event = match api.search(&query, SEARCH_LIMIT).await {
|
||||
Ok(results) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Ok(results),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
|
||||
/// Enter: close the line. Live commands already took effect (their view
|
||||
/// stays open); one-shot commands would execute here.
|
||||
fn commit(state: &mut AppState) {
|
||||
let parsed = command::parse(&state.cmdline.input);
|
||||
close(state);
|
||||
match parsed {
|
||||
Parsed::Empty | Parsed::Command(_) => {}
|
||||
Parsed::Unknown(name) => {
|
||||
state.status_message = Some(format!("unknown command: {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc: close the line and undo any live effect it had.
|
||||
fn cancel(state: &mut AppState) {
|
||||
retract_live(state);
|
||||
close(state);
|
||||
}
|
||||
|
||||
fn close(state: &mut AppState) {
|
||||
state.cmdline.active = false;
|
||||
state.cmdline.input.clear();
|
||||
state.cmdline.live = false;
|
||||
}
|
||||
|
||||
/// Pop the live search view if this command-line session opened it.
|
||||
fn retract_live(state: &mut AppState) {
|
||||
if !state.cmdline.live {
|
||||
return;
|
||||
}
|
||||
state.cmdline.live = false;
|
||||
if matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
|
||||
state.global.stack.pop();
|
||||
state.search = SearchState::default();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Command line (`:`) command parsing.
|
||||
//!
|
||||
//! To add a command:
|
||||
//! 1. Add a `Command` variant.
|
||||
//! 2. Recognize it in `parse()` below.
|
||||
//! 3. Handle it in `app::cmdline` — live commands (re-evaluated on every
|
||||
//! keystroke, like search) in `apply_live`, one-shot commands in `commit`.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// `:/query` — realtime search over artists, releases and tracks.
|
||||
Search(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Parsed {
|
||||
/// Nothing typed yet.
|
||||
Empty,
|
||||
Command(Command),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Parsed {
|
||||
if input.is_empty() {
|
||||
return Parsed::Empty;
|
||||
}
|
||||
if let Some(query) = input.strip_prefix('/') {
|
||||
return Parsed::Command(Command::Search(query.trim().to_string()));
|
||||
}
|
||||
// Future word commands parse here, e.g. "volume 50" / "seek +30".
|
||||
let name = input.split_whitespace().next().unwrap_or(input);
|
||||
Parsed::Unknown(name.to_string())
|
||||
}
|
||||
|
||||
/// Live commands take effect while typing; one-shot commands run on Enter.
|
||||
pub fn is_live(command: &Command) -> bool {
|
||||
matches!(command, Command::Search(_))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_search() {
|
||||
assert_eq!(
|
||||
parse("/daft punk"),
|
||||
Parsed::Command(Command::Search("daft punk".to_string()))
|
||||
);
|
||||
assert_eq!(parse("/"), Parsed::Command(Command::Search(String::new())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_empty() {
|
||||
assert_eq!(parse(""), Parsed::Empty);
|
||||
assert_eq!(parse("volume 50"), Parsed::Unknown("volume".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_is_live() {
|
||||
assert!(is_live(&Command::Search("x".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::auth::AuthSession;
|
||||
use crate::api::models::{
|
||||
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
|
||||
TrackItem,
|
||||
};
|
||||
use crate::art::ArtImage;
|
||||
|
||||
/// Events delivered to the main loop by background tasks (API fetches, the
|
||||
/// playback engine, device sync). Tasks never touch AppState directly.
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
StatusMessage(String),
|
||||
LoginSucceeded(Box<AuthSession>),
|
||||
LoginFailed(String),
|
||||
/// Loopback listener received the browser SSO callback.
|
||||
SsoCallback(Result<String, String>),
|
||||
/// Refresh token rejected — stored credentials were deleted.
|
||||
SessionExpired,
|
||||
/// A page of the Global artists list arrived (or failed).
|
||||
ArtistsLoaded(Result<ArtistsPage, String>),
|
||||
ArtistViewLoaded {
|
||||
id: i64,
|
||||
result: Result<ArtistDetail, String>,
|
||||
},
|
||||
ReleaseViewLoaded {
|
||||
id: i64,
|
||||
result: Result<ReleaseDetail, String>,
|
||||
},
|
||||
/// Live search results; `seq` drops responses that are already stale.
|
||||
SearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<SearchResults, String>,
|
||||
},
|
||||
/// Artwork fetched and decoded for the shared art cache.
|
||||
ArtLoaded {
|
||||
key: String,
|
||||
art: Option<Arc<ArtImage>>,
|
||||
},
|
||||
Player(crate::player::PlayerEvent),
|
||||
/// A command from the OS media keys.
|
||||
Media(crate::media::MediaCommand),
|
||||
/// Gapless prefetch could not open the stream; the normal track-switch
|
||||
/// path takes over when the current track ends.
|
||||
PrefetchFailed {
|
||||
pos: usize,
|
||||
},
|
||||
PlaylistsLoaded(Result<Vec<PlaylistCard>, String>),
|
||||
PlaylistViewLoaded {
|
||||
id: i64,
|
||||
result: Result<PlaylistDetail, String>,
|
||||
},
|
||||
/// Liked track ids for the ♥ markers.
|
||||
LikesLoaded(Result<Vec<i64>, String>),
|
||||
LikeToggled {
|
||||
track_id: i64,
|
||||
liked: bool,
|
||||
},
|
||||
/// A release fetched for queueing (a / shift-a on a release).
|
||||
EnqueueTracks {
|
||||
tracks: Vec<TrackItem>,
|
||||
next: bool,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::api::{auth, client};
|
||||
use crate::app::Runtime;
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::app::sso;
|
||||
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
|
||||
|
||||
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
state.should_quit = true;
|
||||
return;
|
||||
}
|
||||
let form = &mut state.login;
|
||||
if form.busy {
|
||||
return;
|
||||
}
|
||||
match form.mode {
|
||||
LoginMode::Form => handle_form_key(form, runtime, key),
|
||||
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracketed paste goes into whichever text field is focused.
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
let form = &mut state.login;
|
||||
if form.busy {
|
||||
return;
|
||||
}
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.push_str(&cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
|
||||
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
|
||||
KeyCode::Backspace => {
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => match form.focus {
|
||||
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
|
||||
LoginField::Password | LoginField::SignInButton => {
|
||||
submit_password(form, runtime);
|
||||
}
|
||||
LoginField::SsoButton => start_sso(form, runtime),
|
||||
},
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
if let Some(listener) = runtime.sso.take() {
|
||||
listener.abort();
|
||||
}
|
||||
form.mode = LoginMode::Form;
|
||||
form.sso_paste.clear();
|
||||
form.error = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
form.sso_paste.pop();
|
||||
}
|
||||
KeyCode::Enter => submit_sso_code(form, runtime),
|
||||
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_typing(key: KeyEvent) -> bool {
|
||||
key.modifiers
|
||||
.difference(KeyModifiers::SHIFT)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
|
||||
if form.mode == LoginMode::SsoPending {
|
||||
return Some(&mut form.sso_paste);
|
||||
}
|
||||
match form.focus {
|
||||
LoginField::ServerUrl => Some(&mut form.server_url),
|
||||
LoginField::Username => Some(&mut form.username),
|
||||
LoginField::Password => Some(&mut form.password),
|
||||
LoginField::SignInButton | LoginField::SsoButton => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
|
||||
form.error = None;
|
||||
let base_url = match auth::normalize_base_url(&form.server_url) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
let username = form.username.trim().to_string();
|
||||
if username.is_empty() {
|
||||
return form.error = Some("enter a username".to_string());
|
||||
}
|
||||
if form.password.is_empty() {
|
||||
return form.error = Some("enter a password".to_string());
|
||||
}
|
||||
form.server_url = base_url.clone();
|
||||
form.busy = true;
|
||||
|
||||
let password = form.password.clone();
|
||||
let http = runtime.http.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = client::login_password(&http, &base_url, &username, &password).await;
|
||||
let _ = tx.send(login_event(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
|
||||
form.error = None;
|
||||
let base_url = match auth::normalize_base_url(&form.server_url) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
form.server_url = base_url.clone();
|
||||
form.sso_paste.clear();
|
||||
|
||||
// Preferred flow: loopback listener, the browser redirect finishes the
|
||||
// login hands-free. Fallback: furumi:// deep link + manual code paste.
|
||||
if let Some(listener) = runtime.sso.take() {
|
||||
listener.abort();
|
||||
}
|
||||
match sso::start(runtime.event_tx.clone()) {
|
||||
Ok(listener) => {
|
||||
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
|
||||
form.sso_url = client::sso_start_url(&base_url, &redirect);
|
||||
form.sso_port = Some(listener.port);
|
||||
runtime.sso = Some(listener);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
|
||||
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
|
||||
form.sso_port = None;
|
||||
}
|
||||
}
|
||||
|
||||
form.mode = LoginMode::SsoPending;
|
||||
if let Err(err) = open::that_detached(&form.sso_url) {
|
||||
tracing::warn!(%err, "failed to open browser for SSO");
|
||||
form.error = Some("couldn't open a browser — use the URL below".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
|
||||
form.error = None;
|
||||
let code = match auth::extract_sso_code(&form.sso_paste) {
|
||||
Ok(code) => code,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
spawn_sso_exchange(form, runtime, code);
|
||||
}
|
||||
|
||||
/// Used by both the manual paste path and the loopback callback event.
|
||||
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
|
||||
let base_url = form.server_url.clone();
|
||||
form.busy = true;
|
||||
|
||||
let http = runtime.http.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = client::login_sso_exchange(&http, &base_url, &code).await;
|
||||
let _ = tx.send(login_event(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
|
||||
match result {
|
||||
Ok(session) => {
|
||||
if let Err(err) = auth::save_session(&session) {
|
||||
tracing::warn!(%err, "failed to persist credentials");
|
||||
}
|
||||
AppEvent::LoginSucceeded(Box::new(session))
|
||||
}
|
||||
Err(err) => AppEvent::LoginFailed(err.to_string()),
|
||||
}
|
||||
}
|
||||
+887
@@ -0,0 +1,887 @@
|
||||
pub mod action;
|
||||
mod cmdline;
|
||||
pub mod command;
|
||||
pub mod event;
|
||||
mod login;
|
||||
mod sso;
|
||||
pub mod state;
|
||||
pub mod update;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crokey::KeyCombination;
|
||||
use crossterm::event::{Event as TermEvent, EventStream, KeyEvent, KeyEventKind};
|
||||
use futures_util::StreamExt;
|
||||
use ratatui::DefaultTerminal;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::MissedTickBehavior;
|
||||
|
||||
use crate::api::auth;
|
||||
use crate::api::client::{ApiClient, ApiError, http_client};
|
||||
use crate::config::keymap::{KeyResolution, Keymap};
|
||||
use crate::player;
|
||||
use crate::ui;
|
||||
use event::AppEvent;
|
||||
use state::{AppState, Screen};
|
||||
use update::{Effect, update};
|
||||
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Handles shared by background tasks; AppState stays pure UI data.
|
||||
pub struct Runtime {
|
||||
pub event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||
pub http: reqwest::Client,
|
||||
pub api: Option<Arc<ApiClient>>,
|
||||
pub sso: Option<sso::SsoListener>,
|
||||
/// Caps concurrent artwork downloads so they never starve API calls.
|
||||
pub art_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Monotonic sequence for live search; stale responses are dropped.
|
||||
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub player: player::Controller,
|
||||
pub last_state_push: Option<std::time::Instant>,
|
||||
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
||||
pub last_media_push: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
mut terminal: DefaultTerminal,
|
||||
mut keymap: Keymap,
|
||||
startup_warning: Option<String>,
|
||||
event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||
mut event_rx: mpsc::UnboundedReceiver<AppEvent>,
|
||||
media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
||||
) -> Result<()> {
|
||||
let mut state = AppState {
|
||||
status_message: startup_warning,
|
||||
..AppState::default()
|
||||
};
|
||||
|
||||
let player_events = event_tx.clone();
|
||||
let mut runtime = Runtime {
|
||||
event_tx,
|
||||
http: http_client(),
|
||||
api: None,
|
||||
sso: None,
|
||||
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
player: player::spawn(move |event| {
|
||||
let _ = player_events.send(AppEvent::Player(event));
|
||||
}),
|
||||
last_state_push: None,
|
||||
media_tx,
|
||||
last_media_push: None,
|
||||
};
|
||||
|
||||
match auth::load_session() {
|
||||
Some(session) => {
|
||||
state.user = Some(session.user.clone());
|
||||
let api = Arc::new(ApiClient::new(runtime.http.clone(), session));
|
||||
runtime.api = Some(Arc::clone(&api));
|
||||
spawn_session_check(&runtime, api);
|
||||
}
|
||||
None => state.screen = Screen::Login,
|
||||
}
|
||||
|
||||
let mut input = EventStream::new();
|
||||
let mut tick = tokio::time::interval(TICK_INTERVAL);
|
||||
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
|
||||
|
||||
tokio::select! {
|
||||
maybe_event = input.next() => match maybe_event {
|
||||
Some(Ok(event)) => handle_terminal_event(&mut state, &mut keymap, &mut runtime, event),
|
||||
Some(Err(err)) => return Err(err.into()),
|
||||
None => state.should_quit = true,
|
||||
},
|
||||
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
|
||||
_ = tick.tick() => {
|
||||
expire_quit_confirmation(&mut state);
|
||||
if state.player.current.is_some() {
|
||||
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
|
||||
state.player.paused = runtime.player.shared.paused();
|
||||
}
|
||||
maybe_prefetch_next(&mut state, &runtime);
|
||||
maybe_push_state(&state, &mut runtime);
|
||||
push_media_update(&state, &mut runtime, false);
|
||||
}
|
||||
}
|
||||
|
||||
if state.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
maintenance(&mut state, &mut runtime);
|
||||
}
|
||||
}
|
||||
|
||||
const ARTISTS_PAGE_SIZE: i64 = 48;
|
||||
const ARTISTS_PREFETCH_MARGIN: usize = 24;
|
||||
|
||||
/// Runs after every event: kicks off whatever background work the current
|
||||
/// state needs — the first artists page, the next page when the selection
|
||||
/// nears the end, and artwork for loaded artists.
|
||||
fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
if state.screen != Screen::Main {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let global = &mut state.global;
|
||||
let initial = global.artists.is_empty();
|
||||
let near_end =
|
||||
!initial && global.selected + ARTISTS_PREFETCH_MARGIN >= global.artists.len();
|
||||
if global.has_more && !global.loading && global.error.is_none() && (initial || near_end) {
|
||||
global.loading = true;
|
||||
let page = global.next_page;
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.artists(page, ARTISTS_PAGE_SIZE).await {
|
||||
Ok(page) => AppEvent::ArtistsLoaded(Ok(page)),
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Liked ids load once per session — markers are shown everywhere.
|
||||
if !state.likes_loaded {
|
||||
state.likes_loaded = true;
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.likes().await {
|
||||
Ok(ids) => AppEvent::LikesLoaded(Ok(ids)),
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::LikesLoaded(Err(err.to_string())),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
|
||||
// Playlists tab data.
|
||||
if state.active_tab == state::Tab::Playlists {
|
||||
if state.playlists.list.is_none() {
|
||||
state.playlists.list = Some(state::Loadable::Loading);
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.playlists().await {
|
||||
Ok(list) => AppEvent::PlaylistsLoaded(Ok(list)),
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::PlaylistsLoaded(Err(err.to_string())),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
if let Some(opened) = state.playlists.opened {
|
||||
let id = opened.id;
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) =
|
||||
state.playlist_views.entry(id)
|
||||
{
|
||||
entry.insert(state::Loadable::Loading);
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.playlist(id).await {
|
||||
Ok(detail) => AppEvent::PlaylistViewLoaded {
|
||||
id,
|
||||
result: Ok(detail),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::PlaylistViewLoaded {
|
||||
id,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down views pushed on the stack fetch their data on first sight.
|
||||
for view in state.global.stack.clone() {
|
||||
match view {
|
||||
state::GlobalView::Artist { id, .. } => {
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) =
|
||||
state.artist_views.entry(id)
|
||||
{
|
||||
entry.insert(state::Loadable::Loading);
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.artist(id).await {
|
||||
Ok(detail) => AppEvent::ArtistViewLoaded {
|
||||
id,
|
||||
result: Ok(detail),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::ArtistViewLoaded {
|
||||
id,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
state::GlobalView::Release { id, .. } => {
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) =
|
||||
state.release_views.entry(id)
|
||||
{
|
||||
entry.insert(state::Loadable::Loading);
|
||||
let api = Arc::clone(&api);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api.release(id).await {
|
||||
Ok(detail) => AppEvent::ReleaseViewLoaded {
|
||||
id,
|
||||
result: Ok(detail),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::ReleaseViewLoaded {
|
||||
id,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
state::GlobalView::Search { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Artwork wanted by everything currently loaded, at its display size.
|
||||
let mut wanted: Vec<(String, u16, u16)> = Vec::new();
|
||||
let tile = (state::ART_CELL_WIDTH, state::ART_CELL_HEIGHT);
|
||||
let header = (state::ART_HEADER_WIDTH, state::ART_HEADER_HEIGHT);
|
||||
for artist in &state.global.artists {
|
||||
if let Some(url) = &artist.image_url {
|
||||
wanted.push((url.clone(), tile.0, tile.1));
|
||||
}
|
||||
}
|
||||
for detail in state.artist_views.values() {
|
||||
if let state::Loadable::Ready(detail) = detail {
|
||||
if let Some(url) = &detail.image_url {
|
||||
wanted.push((url.clone(), header.0, header.1));
|
||||
}
|
||||
for release in &detail.releases {
|
||||
if let Some(url) = &release.cover_url {
|
||||
wanted.push((url.clone(), tile.0, tile.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for detail in state.release_views.values() {
|
||||
if let state::Loadable::Ready(detail) = detail {
|
||||
if let Some(url) = &detail.cover_url {
|
||||
wanted.push((url.clone(), header.0, header.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (url, width, height) in wanted {
|
||||
let key = crate::art::cache_key(&url, width, height);
|
||||
if state.art.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
state.art.insert(key.clone(), state::ArtState::Loading);
|
||||
spawn_art_fetch(runtime, Arc::clone(&api), key, url, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_art_fetch(
|
||||
runtime: &Runtime,
|
||||
api: Arc<ApiClient>,
|
||||
key: String,
|
||||
url: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) {
|
||||
let tx = runtime.event_tx.clone();
|
||||
let semaphore = Arc::clone(&runtime.art_semaphore);
|
||||
tokio::spawn(async move {
|
||||
let Ok(_permit) = semaphore.acquire_owned().await else {
|
||||
return;
|
||||
};
|
||||
let art = match api.get_bytes(&url).await {
|
||||
Ok(bytes) => tokio::task::spawn_blocking(move || {
|
||||
crate::art::decode_to_cells(&bytes, width, height)
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| r)
|
||||
.map_err(|err| tracing::warn!(%err, url, "artwork decode failed"))
|
||||
.ok()
|
||||
.map(Arc::new),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, url, "artwork fetch failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
let _ = tx.send(AppEvent::ArtLoaded { key, art });
|
||||
});
|
||||
}
|
||||
|
||||
/// Execute a side effect requested by update().
|
||||
fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
match effect {
|
||||
Effect::PlayCurrent => {
|
||||
play_current(state, runtime);
|
||||
push_state_now(state, runtime);
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
Effect::TogglePause => {
|
||||
runtime.player.toggle_pause();
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
Effect::StopPlayback => {
|
||||
runtime.player.stop();
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
Effect::SeekBy(delta) => {
|
||||
let target = (state.player.position_secs + delta as f64).max(0.0);
|
||||
state.player.position_secs = target;
|
||||
runtime.player.seek(std::time::Duration::from_secs_f64(target));
|
||||
}
|
||||
Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)),
|
||||
Effect::EnqueueRelease { id, next } => {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match api.release(id).await {
|
||||
Ok(detail) => {
|
||||
let _ = tx.send(AppEvent::EnqueueTracks {
|
||||
tracks: detail.tracks,
|
||||
next,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, release = id, "queueing a release failed");
|
||||
let _ = tx.send(AppEvent::StatusMessage(format!("queue failed: {err}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ToggleLike { track_id } => {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match api.toggle_like(track_id).await {
|
||||
Ok(liked) => {
|
||||
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, track_id, "like toggle failed");
|
||||
let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start streaming `queue[queue_pos]`: open the authenticated HTTP stream in
|
||||
/// a background task and hand the reader to the audio thread.
|
||||
fn play_current(state: &mut AppState, runtime: &Runtime) {
|
||||
let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
// The track that was playing until now was cut short by this switch.
|
||||
if let Some(previous) = state.player.current.take() {
|
||||
if state.player.playing {
|
||||
report_history(
|
||||
runtime,
|
||||
previous.id,
|
||||
state.player.track_started_at,
|
||||
state.player.position_secs.round() as i32,
|
||||
);
|
||||
}
|
||||
}
|
||||
state.player.current = Some(track.clone());
|
||||
state.player.playing = true;
|
||||
state.player.paused = false;
|
||||
state.player.position_secs = 0.0;
|
||||
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
||||
state.player.prefetched_pos = None;
|
||||
state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line()));
|
||||
|
||||
let controller = runtime.player.clone();
|
||||
let volume = player::amplitude(state.player.volume);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match api.open_stream(&track.stream_url).await {
|
||||
Ok((reader, byte_len)) => controller.play(reader, byte_len, volume),
|
||||
Err(ApiError::SessionExpired) => {
|
||||
let _ = tx.send(AppEvent::SessionExpired);
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(AppEvent::StatusMessage(format!("playback failed: {err}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start streaming the next queue item ~30s before the current track ends
|
||||
/// and append it in the audio thread, so rodio switches sources without a
|
||||
/// device gap.
|
||||
fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
|
||||
const PREFETCH_MARGIN_SECS: f64 = 30.0;
|
||||
let player = &state.player;
|
||||
if !player.playing || player.paused || player.prefetched_pos.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(track) = &player.current else {
|
||||
return;
|
||||
};
|
||||
if track.duration_seconds <= 0.0
|
||||
|| track.duration_seconds - player.position_secs > PREFETCH_MARGIN_SECS
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(next_pos) = update::peek_next_pos(player) else {
|
||||
return;
|
||||
};
|
||||
let Some(next) = player.queue.get(next_pos).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
state.player.prefetched_pos = Some(next_pos);
|
||||
tracing::debug!(title = %next.title, "prefetching next track");
|
||||
let controller = runtime.player.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match api.open_stream(&next.stream_url).await {
|
||||
Ok((reader, byte_len)) => controller.enqueue(reader, byte_len),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "prefetch failed; falling back to a normal switch");
|
||||
let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Persist playback state server-side: on track changes (called directly)
|
||||
/// and every ~10s while something is playing (called from the tick).
|
||||
fn maybe_push_state(state: &AppState, runtime: &mut Runtime) {
|
||||
const PUSH_INTERVAL: Duration = Duration::from_secs(10);
|
||||
if !state.player.playing {
|
||||
return;
|
||||
}
|
||||
let due = runtime
|
||||
.last_state_push
|
||||
.is_none_or(|at| at.elapsed() >= PUSH_INTERVAL);
|
||||
if due {
|
||||
push_state_now(state, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_state_now(state: &AppState, runtime: &mut Runtime) {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
runtime.last_state_push = Some(std::time::Instant::now());
|
||||
let player = &state.player;
|
||||
let body = crate::api::client::PlaybackStateBody {
|
||||
current_track_id: player.current.as_ref().map(|t| t.id),
|
||||
position_ms: (player.position_secs * 1000.0) as i32,
|
||||
queue: player.queue.iter().map(|t| t.id).collect(),
|
||||
queue_position: player.queue_pos as i32,
|
||||
shuffle: player.shuffle,
|
||||
repeat_mode: player.repeat.label().to_string(),
|
||||
volume: f64::from(player.volume) / 100.0,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = api.push_state(&body).await {
|
||||
tracing::warn!(%err, "state push failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget history report; listens shorter than 5s are noise.
|
||||
fn report_history(runtime: &Runtime, track_id: i64, started_at: Option<i64>, listened: i32) {
|
||||
if listened < 5 {
|
||||
return;
|
||||
}
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = api.report_history(track_id, started_at, listened).await {
|
||||
tracing::warn!(%err, "history report failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear a timed-out quit confirmation and its status-bar hint.
|
||||
fn expire_quit_confirmation(state: &mut AppState) {
|
||||
if state
|
||||
.quit_armed_until
|
||||
.is_some_and(|deadline| std::time::Instant::now() > deadline)
|
||||
{
|
||||
state.quit_armed_until = None;
|
||||
if state.status_message.as_deref() == Some(update::QUIT_CONFIRM_HINT) {
|
||||
state.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the stored session in the background: a dead refresh token sends
|
||||
/// the user back to the login screen instead of failing on first use.
|
||||
fn spawn_session_check(runtime: &Runtime, api: Arc<ApiClient>) {
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match api.me().await {
|
||||
Ok(me) => {
|
||||
let _ = tx.send(AppEvent::StatusMessage(format!("signed in as {}", me.name)));
|
||||
}
|
||||
Err(ApiError::SessionExpired) => {
|
||||
let _ = tx.send(AppEvent::SessionExpired);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "session check failed");
|
||||
let _ = tx.send(AppEvent::StatusMessage(format!("server unreachable: {err}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_terminal_event(
|
||||
state: &mut AppState,
|
||||
keymap: &mut Keymap,
|
||||
runtime: &mut Runtime,
|
||||
event: TermEvent,
|
||||
) {
|
||||
match event {
|
||||
TermEvent::Key(key) => {
|
||||
// Kitty-enhanced terminals and Windows also deliver Release
|
||||
// events; acting on them would double-fire every binding.
|
||||
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
match state.screen {
|
||||
Screen::Login => login::handle_key(state, runtime, key),
|
||||
Screen::Main if state.cmdline.active => cmdline::handle_key(state, runtime, key),
|
||||
Screen::Main => handle_main_key(state, keymap, runtime, key),
|
||||
}
|
||||
}
|
||||
TermEvent::Paste(pasted) => match state.screen {
|
||||
Screen::Login => login::handle_paste(state, &pasted),
|
||||
Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted),
|
||||
Screen::Main => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_main_key(state: &mut AppState, keymap: &mut Keymap, runtime: &mut Runtime, key: KeyEvent) {
|
||||
let combo = KeyCombination::from(key);
|
||||
match keymap.resolve(combo, state.active_tab.key_context()) {
|
||||
KeyResolution::Action(action) => {
|
||||
state.pending_keys = None;
|
||||
tracing::debug!(?action, "key resolved");
|
||||
// Logout needs the Runtime, which pure update() never touches.
|
||||
if action == action::Action::Logout {
|
||||
perform_logout(state, runtime);
|
||||
} else if let Some(effect) = update(state, action) {
|
||||
perform_effect(state, runtime, effect);
|
||||
}
|
||||
}
|
||||
KeyResolution::Pending(keys) => state.pending_keys = Some(keys),
|
||||
KeyResolution::Unmatched => state.pending_keys = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out: revoke the session server-side (best effort, in the background),
|
||||
/// delete stored credentials, return to the login screen with the server
|
||||
/// URL kept for convenience.
|
||||
fn perform_logout(state: &mut AppState, runtime: &mut Runtime) {
|
||||
let server_url = runtime.api.as_ref().map(|api| api.base_url().to_string());
|
||||
if let Some(api) = runtime.api.take() {
|
||||
tokio::spawn(async move {
|
||||
match api.logout().await {
|
||||
Ok(revoked) => tracing::info!(revoked, "logged out"),
|
||||
Err(err) => tracing::warn!(%err, "server-side logout failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
auth::delete_session();
|
||||
runtime.player.stop();
|
||||
state.player = state::PlayerBar::default();
|
||||
state.user = None;
|
||||
state.login = state::LoginForm::default();
|
||||
if let Some(url) = server_url {
|
||||
state.login.server_url = url;
|
||||
}
|
||||
reset_library_state(state);
|
||||
state.screen = Screen::Login;
|
||||
state.status_message = None;
|
||||
}
|
||||
|
||||
/// Drop everything fetched from the previous account/server.
|
||||
fn reset_library_state(state: &mut AppState) {
|
||||
state.global = state::GlobalTab::default();
|
||||
state.artist_views.clear();
|
||||
state.release_views.clear();
|
||||
state.playlists = state::PlaylistsTab::default();
|
||||
state.playlist_views.clear();
|
||||
state.likes.clear();
|
||||
state.likes_loaded = false;
|
||||
state.search = state::SearchState::default();
|
||||
state.cmdline = state::Cmdline::default();
|
||||
state.art.clear();
|
||||
}
|
||||
|
||||
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::StatusMessage(message) => state.status_message = Some(message),
|
||||
AppEvent::LoginSucceeded(session) => {
|
||||
if let Some(listener) = runtime.sso.take() {
|
||||
listener.abort();
|
||||
}
|
||||
state.status_message = Some(format!("signed in as {}", session.user.name));
|
||||
state.user = Some(session.user.clone());
|
||||
runtime.api = Some(Arc::new(ApiClient::new(runtime.http.clone(), *session)));
|
||||
state.login = state::LoginForm::default();
|
||||
state.screen = Screen::Main;
|
||||
}
|
||||
AppEvent::LoginFailed(message) => {
|
||||
state.login.busy = false;
|
||||
state.login.error = Some(message);
|
||||
}
|
||||
AppEvent::SsoCallback(result) => {
|
||||
runtime.sso = None;
|
||||
if state.screen != Screen::Login
|
||||
|| state.login.mode != state::LoginMode::SsoPending
|
||||
|| state.login.busy
|
||||
{
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(code) => login::spawn_sso_exchange(&mut state.login, runtime, code),
|
||||
Err(message) => state.login.error = Some(message),
|
||||
}
|
||||
}
|
||||
AppEvent::SessionExpired => {
|
||||
state.user = None;
|
||||
state.login = state::LoginForm::default();
|
||||
if let Some(api) = runtime.api.take() {
|
||||
state.login.server_url = api.base_url().to_string();
|
||||
}
|
||||
state.login.error = Some("session expired — sign in again".to_string());
|
||||
runtime.player.stop();
|
||||
state.player = state::PlayerBar::default();
|
||||
reset_library_state(state);
|
||||
state.screen = Screen::Login;
|
||||
}
|
||||
AppEvent::ArtistsLoaded(Ok(page)) => {
|
||||
let global = &mut state.global;
|
||||
global.loading = false;
|
||||
global.total = page.total;
|
||||
global.has_more = page.has_more;
|
||||
global.next_page = page.page + 1;
|
||||
global.artists.extend(page.items);
|
||||
}
|
||||
AppEvent::ArtistsLoaded(Err(message)) => {
|
||||
state.global.loading = false;
|
||||
state.global.error = Some(message.clone());
|
||||
state.status_message = Some(message);
|
||||
}
|
||||
AppEvent::ArtistViewLoaded { id, result } => {
|
||||
let entry = match result {
|
||||
Ok(detail) => state::Loadable::Ready(detail),
|
||||
Err(message) => state::Loadable::Failed(message),
|
||||
};
|
||||
state.artist_views.insert(id, entry);
|
||||
}
|
||||
AppEvent::ReleaseViewLoaded { id, result } => {
|
||||
let entry = match result {
|
||||
Ok(detail) => state::Loadable::Ready(detail),
|
||||
Err(message) => state::Loadable::Failed(message),
|
||||
};
|
||||
state.release_views.insert(id, entry);
|
||||
}
|
||||
AppEvent::SearchLoaded { seq, result } => {
|
||||
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
state.search.loading = false;
|
||||
match result {
|
||||
Ok(results) => state.search.results = Some(results),
|
||||
Err(message) => state.status_message = Some(message),
|
||||
}
|
||||
}
|
||||
AppEvent::ArtLoaded { key, art } => {
|
||||
let entry = match art {
|
||||
Some(image) => state::ArtState::Ready(image),
|
||||
None => state::ArtState::Failed,
|
||||
};
|
||||
state.art.insert(key, entry);
|
||||
}
|
||||
AppEvent::Player(player::PlayerEvent::TrackFinished { has_next }) => {
|
||||
// The finished track gets a full-duration history entry.
|
||||
if let Some(finished) = state.player.current.clone() {
|
||||
report_history(
|
||||
runtime,
|
||||
finished.id,
|
||||
state.player.track_started_at,
|
||||
finished.duration_seconds.round() as i32,
|
||||
);
|
||||
}
|
||||
if has_next {
|
||||
// A prefetched source is already playing; just realign state.
|
||||
let next_pos = state
|
||||
.player
|
||||
.prefetched_pos
|
||||
.take()
|
||||
.unwrap_or(state.player.queue_pos + 1);
|
||||
state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1));
|
||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||
state.player.position_secs = 0.0;
|
||||
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
} else {
|
||||
state.player.current = None;
|
||||
state.player.prefetched_pos = None;
|
||||
if let Some(effect) = update::advance_after_finish(state) {
|
||||
perform_effect(state, runtime, effect);
|
||||
}
|
||||
}
|
||||
push_state_now(state, runtime);
|
||||
}
|
||||
AppEvent::Player(player::PlayerEvent::Failed(message)) => {
|
||||
state.player.playing = false;
|
||||
state.player.paused = false;
|
||||
state.status_message = Some(message);
|
||||
}
|
||||
AppEvent::PrefetchFailed { pos } => {
|
||||
if state.player.prefetched_pos == Some(pos) {
|
||||
state.player.prefetched_pos = None;
|
||||
}
|
||||
}
|
||||
AppEvent::PlaylistsLoaded(result) => {
|
||||
state.playlists.list = Some(match result {
|
||||
Ok(list) => state::Loadable::Ready(list),
|
||||
Err(message) => {
|
||||
tracing::warn!(%message, "playlists load failed");
|
||||
state::Loadable::Failed(message)
|
||||
}
|
||||
});
|
||||
}
|
||||
AppEvent::PlaylistViewLoaded { id, result } => {
|
||||
let entry = match result {
|
||||
Ok(detail) => state::Loadable::Ready(detail),
|
||||
Err(message) => state::Loadable::Failed(message),
|
||||
};
|
||||
state.playlist_views.insert(id, entry);
|
||||
}
|
||||
AppEvent::LikesLoaded(result) => match result {
|
||||
Ok(ids) => {
|
||||
state.likes = ids.into_iter().collect();
|
||||
}
|
||||
Err(message) => tracing::warn!(%message, "likes load failed"),
|
||||
},
|
||||
AppEvent::LikeToggled { track_id, liked } => {
|
||||
if liked {
|
||||
state.likes.insert(track_id);
|
||||
} else {
|
||||
state.likes.remove(&track_id);
|
||||
}
|
||||
// The virtual Likes playlist is stale now; refetch on next open.
|
||||
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
||||
state.status_message = Some(if liked {
|
||||
"♥ liked".to_string()
|
||||
} else {
|
||||
"like removed".to_string()
|
||||
});
|
||||
}
|
||||
AppEvent::EnqueueTracks { tracks, next } => {
|
||||
let count = tracks.len();
|
||||
update::enqueue_tracks(state, tracks, next);
|
||||
state.status_message = Some(if next {
|
||||
format!("{count} tracks queued next")
|
||||
} else {
|
||||
format!("{count} tracks queued")
|
||||
});
|
||||
}
|
||||
AppEvent::Media(command) => {
|
||||
use crate::media::MediaCommand;
|
||||
tracing::debug!(?command, "media key");
|
||||
let action = match command {
|
||||
MediaCommand::TogglePause | MediaCommand::Play | MediaCommand::Pause => {
|
||||
action::Action::PlayPause
|
||||
}
|
||||
MediaCommand::Next => action::Action::NextTrack,
|
||||
MediaCommand::Previous => action::Action::PrevTrack,
|
||||
MediaCommand::Stop => {
|
||||
state.player.playing = false;
|
||||
state.player.paused = false;
|
||||
state.player.current = None;
|
||||
runtime.player.stop();
|
||||
push_media_update(state, runtime, true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(effect) = update(state, action) {
|
||||
perform_effect(state, runtime, effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the playback state to the OS now-playing surface. `force` skips
|
||||
/// the position throttle (track switches, pauses).
|
||||
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
|
||||
use crate::media::MediaUpdate;
|
||||
const POSITION_INTERVAL: Duration = Duration::from_secs(2);
|
||||
if !force
|
||||
&& runtime
|
||||
.last_media_push
|
||||
.is_some_and(|at| at.elapsed() < POSITION_INTERVAL)
|
||||
{
|
||||
return;
|
||||
}
|
||||
runtime.last_media_push = Some(std::time::Instant::now());
|
||||
let player = &state.player;
|
||||
if !player.playing {
|
||||
let _ = runtime.media_tx.send(MediaUpdate::Stopped);
|
||||
return;
|
||||
}
|
||||
let _ = runtime.media_tx.send(MediaUpdate::Playback {
|
||||
playing: player.playing,
|
||||
paused: player.paused,
|
||||
position_secs: player.position_secs,
|
||||
});
|
||||
}
|
||||
|
||||
fn push_media_metadata(state: &AppState, runtime: &Runtime) {
|
||||
use crate::media::MediaUpdate;
|
||||
if let Some(track) = &state.player.current {
|
||||
let _ = runtime.media_tx.send(MediaUpdate::Metadata {
|
||||
title: track.title.clone(),
|
||||
artist: track.artist_line(),
|
||||
album: track.release_title.clone(),
|
||||
duration_secs: track.duration_seconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use std::io;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app::event::AppEvent;
|
||||
|
||||
/// Loopback callback listener for browser SSO (RFC 8252 native-app flow).
|
||||
/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback`
|
||||
/// with the exchange code; the listener delivers it as an AppEvent and exits.
|
||||
pub struct SsoListener {
|
||||
pub port: u16,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SsoListener {
|
||||
pub fn abort(&self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(tx: UnboundedSender<AppEvent>) -> io::Result<SsoListener> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let handle = tokio::spawn(async move {
|
||||
let result = match serve_one(listener).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(format!("callback listener failed: {err}")),
|
||||
};
|
||||
let _ = tx.send(AppEvent::SsoCallback(result));
|
||||
});
|
||||
Ok(SsoListener { port, handle })
|
||||
}
|
||||
|
||||
async fn serve_one(listener: std::net::TcpListener) -> io::Result<Result<String, String>> {
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let request_line = read_request_line(&mut stream).await?;
|
||||
let Some(result) = parse_request_line(&request_line) else {
|
||||
// Stray request (favicon, prefetch) — keep waiting for the code.
|
||||
let _ = stream.write_all(&response(404, "Not Found")).await;
|
||||
continue;
|
||||
};
|
||||
let page = match &result {
|
||||
Ok(_) => "Sign-in complete. You can close this window and return to the terminal.",
|
||||
Err(_) => "Sign-in failed. Return to the terminal to see the error.",
|
||||
};
|
||||
let _ = stream.write_all(&response(200, page)).await;
|
||||
let _ = stream.shutdown().await;
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result<String> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut len = 0;
|
||||
while len < buf.len() {
|
||||
let n = stream.read(&mut buf[len..]).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
len += n;
|
||||
if buf[..len].windows(2).any(|w| w == b"\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&buf[..len]);
|
||||
Ok(text.lines().next().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error).
|
||||
/// Values are plain tokens (no percent-encoded characters expected).
|
||||
fn parse_request_line(line: &str) -> Option<Result<String, String>> {
|
||||
let path = line.split_whitespace().nth(1)?;
|
||||
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let mut code = None;
|
||||
let mut error = None;
|
||||
for pair in query.split('&') {
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
match key {
|
||||
"code" if !value.is_empty() => code = Some(value.to_string()),
|
||||
"error" if !value.is_empty() => error = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
return Some(Err(format!("SSO failed: {error}")));
|
||||
}
|
||||
code.map(Ok)
|
||||
}
|
||||
|
||||
fn response(status: u16, body: &str) -> Vec<u8> {
|
||||
let reason = if status == 200 { "OK" } else { "Not Found" };
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>furumi</title></head>\
|
||||
<body style=\"font-family:sans-serif;background:#101114;color:#f5f2ea;\
|
||||
display:grid;place-items:center;min-height:100vh;margin:0\"><p>{body}</p></body></html>"
|
||||
);
|
||||
format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_code_from_request_line() {
|
||||
assert_eq!(
|
||||
parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"),
|
||||
Some(Ok("furu_mx_abc".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_error_from_request_line() {
|
||||
assert_eq!(
|
||||
parse_request_line("GET /callback?error=provider_denied HTTP/1.1"),
|
||||
Some(Err("SSO failed: provider_denied".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_requests() {
|
||||
assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None);
|
||||
assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::models::{
|
||||
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
||||
SearchResults, TrackItem, User,
|
||||
};
|
||||
use crate::art::ArtImage;
|
||||
use crate::config::keymap::KeyContext;
|
||||
|
||||
/// 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 Global 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 Global 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 },
|
||||
}
|
||||
|
||||
/// The Global 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 stack: Vec<GlobalView>,
|
||||
}
|
||||
|
||||
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(),
|
||||
stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases of an artist in display order: grouped by type (albums, EPs,
|
||||
/// singles, compilations, then anything else), keeping server order within a
|
||||
/// 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>)> {
|
||||
const GROUPS: [(&str, &str); 4] = [
|
||||
("album", "Albums"),
|
||||
("ep", "EPs"),
|
||||
("single", "Singles"),
|
||||
("compilation", "Compilations"),
|
||||
];
|
||||
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
||||
for (kind, label) in GROUPS {
|
||||
let indices: Vec<usize> = releases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if !indices.is_empty() {
|
||||
groups.push((label, indices));
|
||||
}
|
||||
}
|
||||
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
|
||||
let other: Vec<usize> = (0..releases.len()).filter(|i| !known.contains(i)).collect();
|
||||
if !other.is_empty() {
|
||||
groups.push(("Other", other));
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let columns = columns.max(1);
|
||||
let mut rows = Vec::new();
|
||||
let mut position = 0;
|
||||
for (_, group) in release_groups(releases) {
|
||||
for chunk in group.chunks(columns) {
|
||||
rows.push((position..position + chunk.len()).collect());
|
||||
position += chunk.len();
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The virtual server-side Likes playlist id (`kind == "likes"`).
|
||||
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Cmdline {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Screen {
|
||||
#[default]
|
||||
Main,
|
||||
Login,
|
||||
}
|
||||
|
||||
/// SSO is the primary sign-in path, so it sits right under the server URL;
|
||||
/// the password fields below are the rare fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LoginField {
|
||||
#[default]
|
||||
ServerUrl,
|
||||
SsoButton,
|
||||
Username,
|
||||
Password,
|
||||
SignInButton,
|
||||
}
|
||||
|
||||
impl LoginField {
|
||||
const ORDER: [LoginField; 5] = [
|
||||
LoginField::ServerUrl,
|
||||
LoginField::SsoButton,
|
||||
LoginField::Username,
|
||||
LoginField::Password,
|
||||
LoginField::SignInButton,
|
||||
];
|
||||
|
||||
pub fn next(self) -> LoginField {
|
||||
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
|
||||
Self::ORDER[(i + 1) % Self::ORDER.len()]
|
||||
}
|
||||
|
||||
pub fn prev(self) -> LoginField {
|
||||
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
|
||||
Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LoginMode {
|
||||
/// Server / username / password fields plus the SSO button.
|
||||
#[default]
|
||||
Form,
|
||||
/// Browser SSO started; waiting for the pasted callback link or code.
|
||||
SsoPending,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginForm {
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub sso_paste: String,
|
||||
pub sso_url: String,
|
||||
pub sso_port: Option<u16>,
|
||||
pub focus: LoginField,
|
||||
pub mode: LoginMode,
|
||||
pub busy: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LoginForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_url: "https://music.hexor.cy".to_string(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
sso_paste: String::new(),
|
||||
sso_url: String::new(),
|
||||
sso_port: None,
|
||||
focus: LoginField::default(),
|
||||
mode: LoginMode::default(),
|
||||
busy: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Tab {
|
||||
#[default]
|
||||
Global,
|
||||
Playlists,
|
||||
Queue,
|
||||
Devices,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Devices];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Global => "Global",
|
||||
Tab::Playlists => "Playlists",
|
||||
Tab::Queue => "Queue",
|
||||
Tab::Devices => "Devices",
|
||||
}
|
||||
}
|
||||
|
||||
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::Devices => KeyContext::Devices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
/// 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,
|
||||
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,
|
||||
track_started_at: None,
|
||||
prefetched_pos: None,
|
||||
volume: 80,
|
||||
shuffle: false,
|
||||
repeat: RepeatMode::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 screen: Screen,
|
||||
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 player: PlayerBar,
|
||||
pub login: LoginForm,
|
||||
pub user: Option<User>,
|
||||
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>,
|
||||
pub likes_loaded: bool,
|
||||
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>,
|
||||
}
|
||||
+1028
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user