Added connected devices. Improved logging. UI fixes

This commit is contained in:
Ultradesu
2026-06-10 23:30:03 +01:00
parent bcee68eb4e
commit 02a396c146
25 changed files with 2540 additions and 314 deletions
+97 -1
View File
@@ -32,13 +32,105 @@ pub enum Action {
QueueAddLast,
ClearQueue,
GoToRelease,
AddToPlaylist,
NewPlaylist,
ToggleHelp,
ToggleViewMode,
OpenDevices,
OpenCommandLine,
OpenSearch,
Logout,
}
/// Help-window sections, in display order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
Playback,
Queue,
Navigation,
Search,
System,
}
impl Category {
pub const ALL: [Category; 5] = [
Category::Playback,
Category::Queue,
Category::Navigation,
Category::Search,
Category::System,
];
pub fn title(self) -> &'static str {
match self {
Category::Playback => "Playback",
Category::Queue => "Queue & playlists",
Category::Navigation => "Navigation",
Category::Search => "Search & commands",
Category::System => "System",
}
}
}
impl Action {
pub fn category(&self) -> Category {
match self {
Action::PlayPause
| Action::NextTrack
| Action::PrevTrack
| Action::SeekForward { .. }
| Action::SeekBackward { .. }
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::ClearQueue
| Action::AddToPlaylist
| Action::NewPlaylist
| Action::ToggleLike => Category::Queue,
Action::MoveUp
| Action::MoveDown
| Action::MoveLeft
| Action::MoveRight
| Action::PageUp
| Action::PageDown
| Action::SelectFirst
| Action::SelectLast
| Action::Select
| Action::Back
| Action::NextTab
| Action::PrevTab
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::OpenDevices => Category::System,
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
}
}
/// The command-line equivalent shown in the help window, if any.
pub fn command_hint(&self) -> Option<&'static str> {
match self {
Action::Quit => Some(":q"),
Action::Logout => Some(":logout"),
Action::PlayPause => Some(":play"),
Action::NextTrack => Some(":next"),
Action::PrevTrack => Some(":prev"),
Action::SeekForward { .. } | Action::SeekBackward { .. } => Some(":seek +30 | 1:30"),
Action::VolumeUp | Action::VolumeDown => Some(":volume 0-100"),
Action::ToggleShuffle => Some(":shuffle"),
Action::CycleRepeat => Some(":repeat [off|one|all]"),
Action::ClearQueue => Some(":clear"),
Action::OpenDevices => Some(":devices"),
Action::ToggleHelp => Some(":help"),
Action::OpenSearch => Some("/text"),
_ => None,
}
}
pub fn describe(&self) -> String {
match self {
Action::Quit => "Quit".into(),
@@ -69,9 +161,13 @@ impl Action {
Action::QueueAddLast => "Queue: add to end".into(),
Action::ClearQueue => "Queue: clear".into(),
Action::GoToRelease => "Open the track's release".into(),
Action::AddToPlaylist => "Add track to a playlist…".into(),
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::OpenCommandLine => "Open command line (:/name searches)".into(),
Action::OpenDevices => "Connected devices".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
Action::Logout => "Sign out".into(),
}
}
+83 -5
View File
@@ -14,10 +14,10 @@ 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) {
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => cancel(state),
KeyCode::Enter => commit(state),
KeyCode::Enter => commit(state, runtime),
KeyCode::Backspace => {
if state.cmdline.input.pop().is_none() {
// Backspace on an empty line closes it, like vim.
@@ -57,6 +57,21 @@ fn after_change(state: &mut AppState, runtime: &Runtime) {
fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
match command {
// One-shot commands have no live effect.
Command::Quit
| Command::Logout
| Command::Volume(_)
| Command::Seek(_)
| Command::SeekTo(_)
| Command::Shuffle
| Command::Repeat(_)
| Command::ClearQueue
| Command::Next
| Command::Prev
| Command::PlayPause
| Command::Help
| Command::Devices
| Command::Logs(_) => {}
Command::Search(query) => {
state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
@@ -116,18 +131,81 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
}
/// Enter: close the line. Live commands already took effect (their view
/// stays open); one-shot commands would execute here.
fn commit(state: &mut AppState) {
/// stays open); one-shot commands execute here.
fn commit(state: &mut AppState, runtime: &mut Runtime) {
let parsed = command::parse(&state.cmdline.input);
close(state);
match parsed {
Parsed::Empty | Parsed::Command(_) => {}
Parsed::Empty => {}
Parsed::Command(command) if command::is_live(&command) => {}
Parsed::Command(command) => execute(state, runtime, command),
Parsed::Invalid(usage) => state.status_message = Some(usage),
Parsed::Unknown(name) => {
state.status_message = Some(format!("unknown command: {name}"));
}
}
}
/// One-shot command execution. Most commands reuse the same Action/Effect
/// path as keybindings, so behavior stays identical.
fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
use crate::app::action::Action;
use crate::app::state::{LOG_LEVELS, RepeatMode, Tab};
use crate::app::update::Effect;
let run_action = |state: &mut AppState, runtime: &mut Runtime, action: Action| {
if let Some(effect) = crate::app::update::update(state, action) {
super::perform_effect(state, runtime, effect);
}
};
match command {
Command::Search(_) => {}
Command::Quit => state.should_quit = true,
Command::Logout => super::perform_logout(state, runtime),
Command::Volume(value) => {
state.player.volume = value;
super::perform_effect(state, runtime, Effect::SetVolume(value));
state.status_message = Some(format!("volume {value}%"));
}
Command::Seek(delta) => {
if state.player.current.is_some() {
super::perform_effect(state, runtime, Effect::SeekBy(delta));
}
}
Command::SeekTo(seconds) => {
if state.player.current.is_some() {
let delta = seconds as f64 - state.player.position_secs;
super::perform_effect(state, runtime, Effect::SeekBy(delta.round() as i64));
}
}
Command::Shuffle => run_action(state, runtime, Action::ToggleShuffle),
Command::Repeat(None) => run_action(state, runtime, Action::CycleRepeat),
Command::Repeat(Some(mode)) => {
state.player.repeat = match mode {
command::RepeatArg::Off => RepeatMode::Off,
command::RepeatArg::One => RepeatMode::One,
command::RepeatArg::All => RepeatMode::All,
};
super::perform_effect(state, runtime, Effect::SetOptions);
state.status_message = Some(format!("repeat {}", state.player.repeat.label()));
}
Command::ClearQueue => run_action(state, runtime, Action::ClearQueue),
Command::Next => run_action(state, runtime, Action::NextTrack),
Command::Prev => run_action(state, runtime, Action::PrevTrack),
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
Command::Help => state.help_visible = true,
Command::Devices => run_action(state, runtime, Action::OpenDevices),
Command::Logs(level) => {
if let Some(index) = level {
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
state.logs.follow = true;
state.logs.selected_seq = None;
}
state.active_tab = Tab::Logs;
}
}
}
/// Esc: close the line and undo any live effect it had.
fn cancel(state: &mut AppState) {
retract_live(state);
+143 -11
View File
@@ -1,15 +1,53 @@
//! Command line (`:`) command parsing.
//! Command line parsing. `/query` is the live search; word commands run on
//! Enter.
//!
//! 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`.
//! 1. Add a `Command` variant and a match arm in `parse()`.
//! 2. Execute it in `cmdline::execute()`.
//!
//! Live commands (re-evaluated on every keystroke) also need handling in
//! `cmdline::apply_live`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepeatArg {
Off,
One,
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// `:/query` — realtime search over artists, releases and tracks.
/// `/query` — realtime search over artists, releases and tracks.
Search(String),
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
/// double-press confirmation).
Quit,
/// `:logout` — sign out and return to the login screen.
Logout,
/// `:volume 40` (also `:vol`) — set the volume precisely.
Volume(u8),
/// `:seek +30` / `:seek -10` — relative seek in seconds.
Seek(i64),
/// `:seek 90` / `:seek 1:30` — absolute position.
SeekTo(u64),
/// `:shuffle` — toggle shuffle.
Shuffle,
/// `:repeat [off|one|all]` — set or cycle the repeat mode.
Repeat(Option<RepeatArg>),
/// `:clear` — clear the play queue.
ClearQueue,
/// `:next` / `:prev` — queue navigation.
Next,
Prev,
/// `:pause` / `:play` — toggle playback.
PlayPause,
/// `:help` — open the keybinding help.
Help,
/// `:devices` — open the connected-devices picker.
Devices,
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
/// optionally setting the severity filter.
Logs(Option<usize>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -17,6 +55,8 @@ pub enum Parsed {
/// Nothing typed yet.
Empty,
Command(Command),
/// Recognized command with bad arguments; the message explains usage.
Invalid(String),
Unknown(String),
}
@@ -27,9 +67,67 @@ pub fn parse(input: &str) -> Parsed {
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())
let mut parts = input.split_whitespace();
let Some(name) = parts.next() else {
return Parsed::Empty;
};
let arg = parts.next();
match name {
"q" | "quit" => Parsed::Command(Command::Quit),
"logout" => Parsed::Command(Command::Logout),
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
},
"seek" => match arg.map(parse_seek_arg) {
Some(Some(command)) => Parsed::Command(command),
_ => Parsed::Invalid("usage: :seek +30 | -10 | 90 | 1:30".to_string()),
},
"shuffle" => Parsed::Command(Command::Shuffle),
"repeat" => match arg {
None => Parsed::Command(Command::Repeat(None)),
Some("off") => Parsed::Command(Command::Repeat(Some(RepeatArg::Off))),
Some("one") => Parsed::Command(Command::Repeat(Some(RepeatArg::One))),
Some("all") => Parsed::Command(Command::Repeat(Some(RepeatArg::All))),
Some(_) => Parsed::Invalid("usage: :repeat [off|one|all]".to_string()),
},
"clear" => Parsed::Command(Command::ClearQueue),
"next" => Parsed::Command(Command::Next),
"prev" => Parsed::Command(Command::Prev),
"pause" | "play" => Parsed::Command(Command::PlayPause),
"help" => Parsed::Command(Command::Help),
"devices" | "device" => Parsed::Command(Command::Devices),
"logs" => match arg {
None => Parsed::Command(Command::Logs(None)),
Some(level) => match ["error", "warn", "info", "debug", "trace"]
.iter()
.position(|l| *l == level)
{
Some(index) => Parsed::Command(Command::Logs(Some(index))),
None => Parsed::Invalid("usage: :logs [error|warn|info|debug|trace]".to_string()),
},
},
_ => Parsed::Unknown(name.to_string()),
}
}
/// `+30`/`-10` → relative; `90` or `1:30` → absolute.
fn parse_seek_arg(arg: &str) -> Option<Command> {
if let Some(rest) = arg.strip_prefix('+') {
return rest.parse::<i64>().ok().map(Command::Seek);
}
if let Some(rest) = arg.strip_prefix('-') {
return rest.parse::<i64>().ok().map(|s| Command::Seek(-s));
}
if let Some((minutes, seconds)) = arg.split_once(':') {
let minutes = minutes.parse::<u64>().ok()?;
let seconds = seconds.parse::<u64>().ok()?;
if seconds >= 60 {
return None;
}
return Some(Command::SeekTo(minutes * 60 + seconds));
}
arg.parse::<u64>().ok().map(Command::SeekTo)
}
/// Live commands take effect while typing; one-shot commands run on Enter.
@@ -51,13 +149,47 @@ mod tests {
}
#[test]
fn unknown_and_empty() {
fn parses_word_commands() {
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
assert_eq!(parse("quit"), Parsed::Command(Command::Quit));
assert_eq!(parse("logout"), Parsed::Command(Command::Logout));
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
assert_eq!(parse("repeat"), Parsed::Command(Command::Repeat(None)));
assert_eq!(
parse("repeat all"),
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
);
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
assert_eq!(parse("devices"), Parsed::Command(Command::Devices));
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
}
#[test]
fn parses_seek_forms() {
assert_eq!(parse("seek +30"), Parsed::Command(Command::Seek(30)));
assert_eq!(parse("seek -10"), Parsed::Command(Command::Seek(-10)));
assert_eq!(parse("seek 90"), Parsed::Command(Command::SeekTo(90)));
assert_eq!(parse("seek 1:30"), Parsed::Command(Command::SeekTo(90)));
assert!(matches!(parse("seek"), Parsed::Invalid(_)));
assert!(matches!(parse("seek 1:75"), Parsed::Invalid(_)));
}
#[test]
fn invalid_and_unknown() {
assert_eq!(parse(""), Parsed::Empty);
assert_eq!(parse("volume 50"), Parsed::Unknown("volume".to_string()));
assert!(matches!(parse("volume 150"), Parsed::Invalid(_)));
assert!(matches!(parse("volume"), Parsed::Invalid(_)));
assert_eq!(
parse("frobnicate"),
Parsed::Unknown("frobnicate".to_string())
);
}
#[test]
fn search_is_live() {
assert!(is_live(&Command::Search("x".into())));
assert!(!is_live(&Command::Quit));
}
}
+17 -2
View File
@@ -2,8 +2,8 @@ use std::sync::Arc;
use crate::api::auth::AuthSession;
use crate::api::models::{
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
TrackItem,
ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TrackItem,
};
use crate::art::ArtImage;
@@ -57,9 +57,24 @@ pub enum AppEvent {
track_id: i64,
liked: bool,
},
/// Connected-devices poll result; carries device list, active id,
/// remote playback state and commands for this TUI.
DevicesPolled(Result<DevicePollResponse, String>),
/// Response from switching the active device.
DeviceActivated(Result<DevicePollResponse, String>),
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
next: bool,
},
PlaylistCreated {
result: Result<PlaylistCard, String>,
/// Add this track to the new playlist right away (Shift-P flow).
add_track: Option<TrackItem>,
},
PlaylistTracksAdded {
playlist_id: i64,
playlist_title: String,
result: Result<(), String>,
},
}
+1 -3
View File
@@ -107,9 +107,7 @@ fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> {
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers
.difference(KeyModifiers::SHIFT)
.is_empty()
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
}
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
+610 -17
View File
@@ -3,6 +3,7 @@ mod cmdline;
pub mod command;
pub mod event;
mod login;
mod popup;
mod sso;
pub mod state;
pub mod update;
@@ -28,6 +29,7 @@ use state::{AppState, Screen};
use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
const DEVICE_POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime {
@@ -43,6 +45,9 @@ pub struct Runtime {
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 device_id: String,
pub last_device_poll: Option<std::time::Instant>,
pub device_poll_in_flight: bool,
}
pub async fn run(
@@ -53,10 +58,12 @@ pub async fn run(
mut event_rx: mpsc::UnboundedReceiver<AppEvent>,
media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
) -> Result<()> {
let device_id = crate::config::load_or_create_device_id();
let mut state = AppState {
status_message: startup_warning,
..AppState::default()
};
state.devices.device_id = device_id.clone();
let player_events = event_tx.clone();
let mut runtime = Runtime {
@@ -72,6 +79,9 @@ pub async fn run(
last_state_push: None,
media_tx,
last_media_push: None,
device_id,
last_device_poll: None,
device_poll_in_flight: false,
};
match auth::load_session() {
@@ -100,10 +110,22 @@ pub async fn run(
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() {
if state.player.current.is_some() && state.devices.is_playback_device() {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
} else if state.player.current.is_some()
&& state.player.playing
&& !state.player.paused
{
state.player.position_secs += TICK_INTERVAL.as_secs_f64();
if let Some(track) = &state.player.current {
if track.duration_seconds > 0.0 {
state.player.position_secs =
state.player.position_secs.min(track.duration_seconds);
}
}
}
maybe_poll_devices(&state, &mut runtime);
maybe_prefetch_next(&mut state, &runtime);
maybe_push_state(&state, &mut runtime);
push_media_update(&state, &mut runtime, false);
@@ -144,8 +166,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
// Keep at least a full screen plus a margin loaded, and stay ahead
// of the cursor: a big terminal fills itself on startup without any
// scrolling, page after page.
let needed = artist_grid_capacity()
.max(global.selected + ARTISTS_PREFETCH_MARGIN)
let needed = artist_grid_capacity().max(global.selected + ARTISTS_PREFETCH_MARGIN)
+ ARTISTS_PREFETCH_MARGIN;
if global.has_more
&& !global.loading
@@ -185,8 +206,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
});
}
// Playlists tab data.
if state.active_tab == state::Tab::Playlists {
// Playlists tab data (also wanted while the add-to-playlist picker is
// open from any tab).
let picker_open = matches!(state.popup, Some(state::Popup::AddToPlaylist { .. }));
if state.active_tab == state::Tab::Playlists || picker_open {
if state.playlists.list.is_none() {
state.playlists.list = Some(state::Loadable::Loading);
let api = Arc::clone(&api);
@@ -202,8 +225,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
}
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)
if let std::collections::hash_map::Entry::Vacant(entry) = state.playlist_views.entry(id)
{
entry.insert(state::Loadable::Loading);
let api = Arc::clone(&api);
@@ -317,6 +339,63 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
}
}
fn maybe_poll_devices(state: &AppState, runtime: &mut Runtime) {
if state.screen != Screen::Main || runtime.device_poll_in_flight {
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
let due = runtime
.last_device_poll
.is_none_or(|at| at.elapsed() >= DEVICE_POLL_INTERVAL);
if !due {
return;
}
runtime.last_device_poll = Some(std::time::Instant::now());
runtime.device_poll_in_flight = true;
let device_id = runtime.device_id.clone();
let playback_state = state
.devices
.is_playback_device()
.then(|| device_playback_state(state))
.flatten();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.poll_device(&device_id, playback_state).await {
Ok(response) => AppEvent::DevicesPolled(Ok(response)),
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::DevicesPolled(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
fn device_playback_state(state: &AppState) -> Option<crate::api::models::DevicePlaybackState> {
let player = &state.player;
let current = player.current.as_ref()?;
if player.queue.is_empty() {
return None;
}
Some(crate::api::models::DevicePlaybackState {
track: serde_json::to_value(current).ok(),
tracks: player
.queue
.iter()
.filter_map(|track| serde_json::to_value(track).ok())
.collect(),
index: player.queue_pos as i32,
position_seconds: player.position_secs,
duration_seconds: current.duration_seconds,
paused: player.paused || !player.playing,
shuffle: player.shuffle,
repeat_mode: player.repeat.label().to_string(),
volume: f64::from(player.volume) / 100.0,
updated_at_ms: 0,
})
}
fn spawn_art_fetch(
runtime: &Runtime,
api: Arc<ApiClient>,
@@ -352,6 +431,9 @@ fn spawn_art_fetch(
/// Execute a side effect requested by update().
fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
if perform_remote_effect(state, runtime, effect) {
return;
}
match effect {
Effect::PlayCurrent => {
play_current(state, runtime);
@@ -370,9 +452,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
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));
runtime
.player
.seek(std::time::Duration::from_secs_f64(target));
}
Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)),
Effect::SetOptions => {
push_state_now(state, runtime);
}
Effect::EnqueueRelease { id, next } => {
let Some(api) = runtime.api.clone() else {
return;
@@ -413,9 +500,99 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
}
fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect) -> bool {
let Some(target) = state.devices.remote_target_id().map(str::to_string) else {
return false;
};
match effect {
Effect::PlayCurrent => {
if let Some(payload) =
device_playback_state(state).and_then(|state| serde_json::to_value(state).ok())
{
send_device_command(runtime, target, "play_from_index", payload);
state.status_message = Some("sent play command to active device".into());
}
true
}
Effect::TogglePause => {
let command = if state.player.paused {
"pause"
} else {
"resume"
};
send_device_command(runtime, target, command, serde_json::json!({}));
true
}
Effect::StopPlayback => {
send_device_command(runtime, target, "queue_clear", serde_json::json!({}));
true
}
Effect::SeekBy(delta) => {
let target_time = (state.player.position_secs + delta as f64).max(0.0);
state.player.position_secs = target_time;
send_device_command(
runtime,
target,
"seek",
serde_json::json!({ "time": target_time }),
);
true
}
Effect::SetVolume(volume) => {
send_device_command(
runtime,
target,
"set_volume",
serde_json::json!({ "volume": f64::from(volume) / 100.0 }),
);
true
}
Effect::SetOptions => {
send_device_command(
runtime,
target,
"set_options",
serde_json::json!({
"shuffle": state.player.shuffle,
"repeat_mode": state.player.repeat.label(),
}),
);
true
}
Effect::EnqueueRelease { .. } | Effect::ToggleLike { .. } => false,
}
}
fn send_device_command(
runtime: &Runtime,
target_device_id: String,
command: &'static str,
payload: serde_json::Value,
) {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api
.send_device_command(Some(&target_device_id), command, &payload)
.await
{
Ok(()) => AppEvent::StatusMessage(format!("sent {command} to active device")),
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::StatusMessage(format!("device command failed: {err}")),
};
let _ = tx.send(event);
});
}
/// 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) {
start_current_audio(state, runtime, 0.0, false);
}
fn start_current_audio(state: &mut AppState, runtime: &Runtime, position_secs: f64, paused: bool) {
let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else {
return;
};
@@ -424,7 +601,7 @@ fn play_current(state: &mut AppState, runtime: &Runtime) {
};
// 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 {
if state.player.playing && previous.id != track.id {
report_history(
runtime,
previous.id,
@@ -436,19 +613,29 @@ fn play_current(state: &mut AppState, runtime: &Runtime) {
}
state.player.current = Some(track.clone());
state.player.playing = true;
state.player.paused = false;
state.player.position_secs = 0.0;
state.player.paused = paused;
state.player.position_secs = position_secs.max(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()));
report_now_playing(runtime, track.id);
if !paused {
report_now_playing(runtime, track.id);
}
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),
Ok((reader, byte_len)) => {
controller.play(reader, byte_len, volume);
if position_secs > 0.0 {
controller.seek(std::time::Duration::from_secs_f64(position_secs));
}
if paused {
controller.pause();
}
}
Err(ApiError::SessionExpired) => {
let _ = tx.send(AppEvent::SessionExpired);
}
@@ -464,6 +651,9 @@ fn play_current(state: &mut AppState, runtime: &Runtime) {
/// device gap.
fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
const PREFETCH_MARGIN_SECS: f64 = 30.0;
if !state.devices.is_playback_device() {
return;
}
let player = &state.player;
if !player.playing || player.paused || player.prefetched_pos.is_some() {
return;
@@ -504,7 +694,7 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
/// 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 {
if !state.player.playing || !state.devices.is_playback_device() {
return;
}
let due = runtime
@@ -601,7 +791,9 @@ fn spawn_session_check(runtime: &Runtime, api: Arc<ApiClient>) {
}
Err(err) => {
tracing::warn!(%err, "session check failed");
let _ = tx.send(AppEvent::StatusMessage(format!("server unreachable: {err}")));
let _ = tx.send(AppEvent::StatusMessage(format!(
"server unreachable: {err}"
)));
}
}
});
@@ -622,12 +814,14 @@ fn handle_terminal_event(
}
match state.screen {
Screen::Login => login::handle_key(state, runtime, key),
Screen::Main if state.popup.is_some() => popup::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.popup.is_some() => popup::handle_paste(state, &pasted),
Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted),
Screen::Main => {}
},
@@ -635,12 +829,19 @@ fn handle_terminal_event(
}
}
fn handle_main_key(state: &mut AppState, keymap: &mut Keymap, runtime: &mut Runtime, key: KeyEvent) {
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");
// trace, not debug: on the Logs tab every keypress would
// otherwise append a line and pollute what's being read.
tracing::trace!(?action, "key resolved");
// Logout needs the Runtime, which pure update() never touches.
if action == action::Action::Logout {
perform_logout(state, runtime);
@@ -667,6 +868,8 @@ fn perform_logout(state: &mut AppState, runtime: &mut Runtime) {
});
}
auth::delete_session();
runtime.last_device_poll = None;
runtime.device_poll_in_flight = false;
runtime.player.stop();
state.player = state::PlayerBar::default();
state.user = None;
@@ -689,6 +892,12 @@ fn reset_library_state(state: &mut AppState) {
state.queue_tab = state::QueueTab::default();
state.pending_release_focus = None;
state.jump_origin = None;
state.popup = None;
let device_id = state.devices.device_id.clone();
state.devices = state::DevicesState {
device_id,
..state::DevicesState::default()
};
state.likes.clear();
state.likes_loaded = false;
state.search = state::SearchState::default();
@@ -696,6 +905,296 @@ fn reset_library_state(state: &mut AppState) {
state.art.clear();
}
fn apply_devices_response(
state: &mut AppState,
runtime: &mut Runtime,
response: crate::api::models::DevicePollResponse,
from_activation: bool,
) {
let was_playback_device = state.devices.is_playback_device();
state.devices.device_id = response.device_id;
state.devices.active_device_id = response.active_device_id;
state.devices.devices = response.devices;
state.devices.poll_error = None;
if from_activation {
state.devices.switching_to = None;
if matches!(state.popup, Some(state::Popup::Devices { .. })) {
state.popup = None;
}
}
let is_playback_device = state.devices.is_playback_device();
if was_playback_device && !is_playback_device {
runtime.player.stop();
}
if !is_playback_device {
if let Some(playback_state) = &response.playback_state {
apply_device_playback_state(state, runtime, playback_state, false);
} else {
runtime.player.stop();
}
} else if from_activation {
if let Some(playback_state) = &response.playback_state {
apply_device_playback_state(state, runtime, playback_state, true);
}
}
for command in response.commands {
execute_device_command(state, runtime, command);
}
}
fn apply_device_playback_state(
state: &mut AppState,
runtime: &mut Runtime,
playback_state: &crate::api::models::DevicePlaybackState,
start_audio: bool,
) {
let mut tracks = tracks_from_values(&playback_state.tracks);
let track = playback_state
.track
.as_ref()
.and_then(track_from_value)
.or_else(|| {
usize::try_from(playback_state.index)
.ok()
.and_then(|index| tracks.get(index).cloned())
});
if tracks.is_empty() {
if let Some(track) = track.clone() {
tracks.push(track);
}
}
let mut index = usize::try_from(playback_state.index).unwrap_or(0);
if let Some(track) = &track {
index = tracks
.iter()
.position(|item| item.id == track.id)
.unwrap_or(index);
}
if !tracks.is_empty() {
index = index.min(tracks.len() - 1);
} else {
index = 0;
}
state.player.queue = tracks;
state.player.queue_pos = index;
state.player.current = track.or_else(|| state.player.queue.get(index).cloned());
state.player.playing = state.player.current.is_some();
state.player.paused = playback_state.paused;
state.player.position_secs = playback_state.position_seconds.max(0.0);
state.player.prefetched_pos = None;
state.player.original_order = None;
state.player.shuffle = playback_state.shuffle;
state.player.repeat = repeat_from_label(&playback_state.repeat_mode);
state.player.volume = volume_percent(playback_state.volume);
state.queue_tab.cursor = state
.queue_tab
.cursor
.min(state.player.queue.len().saturating_sub(1));
if start_audio && state.player.current.is_some() {
start_current_audio(
state,
runtime,
playback_state.position_seconds,
playback_state.paused,
);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
} else {
runtime.player.stop();
}
}
fn track_from_value(value: &serde_json::Value) -> Option<crate::api::models::TrackItem> {
serde_json::from_value(value.clone())
.map_err(|err| tracing::warn!(%err, "invalid track in device payload"))
.ok()
}
fn tracks_from_values(values: &[serde_json::Value]) -> Vec<crate::api::models::TrackItem> {
values.iter().filter_map(track_from_value).collect()
}
fn repeat_from_label(label: &str) -> state::RepeatMode {
match label {
"one" => state::RepeatMode::One,
"all" => state::RepeatMode::All,
_ => state::RepeatMode::Off,
}
}
fn volume_percent(volume: f64) -> u8 {
(volume.clamp(0.0, 1.0) * 100.0).round() as u8
}
fn payload_playback_state(payload: &serde_json::Value) -> crate::api::models::DevicePlaybackState {
serde_json::from_value(payload.clone()).unwrap_or_default()
}
fn payload_tracks(payload: &serde_json::Value) -> Vec<crate::api::models::TrackItem> {
if let Some(values) = payload.get("tracks").and_then(serde_json::Value::as_array) {
let tracks = tracks_from_values(values);
if !tracks.is_empty() {
return tracks;
}
}
payload
.get("track")
.and_then(track_from_value)
.into_iter()
.collect()
}
fn payload_index(payload: &serde_json::Value, key: &str) -> Option<usize> {
payload
.get(key)
.and_then(serde_json::Value::as_i64)
.and_then(|value| usize::try_from(value).ok())
}
fn payload_f64(payload: &serde_json::Value, key: &str) -> Option<f64> {
payload.get(key).and_then(serde_json::Value::as_f64)
}
fn execute_device_command(
state: &mut AppState,
runtime: &mut Runtime,
command: crate::api::models::DeviceCommandDto,
) {
let payload = command.payload;
tracing::debug!(command = %command.command, id = ?command.id, "device command");
match command.command.as_str() {
"transfer_state" | "play_track" | "play_from_index" => {
let playback_state = payload_playback_state(&payload);
let start_audio = state.devices.is_playback_device();
apply_device_playback_state(state, runtime, &playback_state, start_audio);
}
"pause" => {
state.player.paused = true;
runtime.player.pause();
push_media_update(state, runtime, true);
}
"resume" | "play" => {
state.player.paused = false;
runtime.player.resume();
push_media_update(state, runtime, true);
}
"seek" => {
if let Some(time) =
payload_f64(&payload, "time").or_else(|| payload_f64(&payload, "position_seconds"))
{
state.player.position_secs = time.max(0.0);
runtime.player.seek(std::time::Duration::from_secs_f64(
state.player.position_secs,
));
}
}
"next" => {
apply_options_payload(state, &payload);
if let Some(effect) = update::update(state, action::Action::NextTrack) {
perform_effect(state, runtime, effect);
}
}
"prev" | "previous" => {
if let Some(effect) = update::update(state, action::Action::PrevTrack) {
perform_effect(state, runtime, effect);
}
}
"set_volume" | "volume" => {
if let Some(volume) = payload_f64(&payload, "volume") {
state.player.volume = volume_percent(volume);
runtime
.player
.set_volume(player::amplitude(state.player.volume));
}
}
"set_options" => apply_options_payload(state, &payload),
"queue_add_end" => {
update::enqueue_tracks(state, payload_tracks(&payload), false);
}
"queue_add_next" => {
update::enqueue_tracks(state, payload_tracks(&payload), true);
}
"queue_remove" => {
if let Some(index) = payload_index(&payload, "index") {
remove_queue_index(state, runtime, index);
}
}
"queue_move" => {
if let (Some(from), Some(to)) = (
payload_index(&payload, "from_index"),
payload_index(&payload, "to_index"),
) {
move_queue_index(state, from, to);
}
}
"queue_clear" => {
state.player = state::PlayerBar::default();
state.queue_tab.cursor = 0;
runtime.player.stop();
push_media_update(state, runtime, true);
}
_ => {}
}
}
fn apply_options_payload(state: &mut AppState, payload: &serde_json::Value) {
if let Some(shuffle) = payload.get("shuffle").and_then(serde_json::Value::as_bool) {
if shuffle != state.player.shuffle {
state.player.shuffle = shuffle;
if shuffle {
update::shuffle_upcoming(&mut state.player);
} else {
update::restore_queue_order(&mut state.player);
}
}
}
if let Some(repeat) = payload
.get("repeat_mode")
.and_then(serde_json::Value::as_str)
{
state.player.repeat = repeat_from_label(repeat);
}
}
fn remove_queue_index(state: &mut AppState, runtime: &Runtime, index: usize) {
if index >= state.player.queue.len() {
return;
}
let current_id = state.player.current.as_ref().map(|track| track.id);
state.player.queue.remove(index);
if state.player.queue.is_empty() {
state.player = state::PlayerBar::default();
runtime.player.stop();
return;
}
state.player.queue_pos = current_id
.and_then(|id| state.player.queue.iter().position(|track| track.id == id))
.unwrap_or_else(|| state.player.queue_pos.min(state.player.queue.len() - 1));
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
}
fn move_queue_index(state: &mut AppState, from: usize, to: usize) {
if from >= state.player.queue.len() || to >= state.player.queue.len() || from == to {
return;
}
let current_id = state.player.current.as_ref().map(|track| track.id);
let track = state.player.queue.remove(from);
state.player.queue.insert(to, track);
if let Some(id) = current_id {
if let Some(position) = state.player.queue.iter().position(|track| track.id == id) {
state.player.queue_pos = position;
}
}
state.queue_tab.cursor = to;
state.player.prefetched_pos = None;
}
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
match event {
AppEvent::StatusMessage(message) => state.status_message = Some(message),
@@ -706,6 +1205,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
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)));
runtime.last_device_poll = None;
runtime.device_poll_in_flight = false;
state.login = state::LoginForm::default();
state.screen = Screen::Main;
}
@@ -727,6 +1228,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
}
AppEvent::SessionExpired => {
runtime.device_poll_in_flight = false;
state.user = None;
state.login = state::LoginForm::default();
if let Some(api) = runtime.api.take() {
@@ -892,8 +1394,46 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
"like removed".to_string()
});
}
AppEvent::DevicesPolled(result) => {
runtime.device_poll_in_flight = false;
match result {
Ok(response) => apply_devices_response(state, runtime, response, false),
Err(message) => {
tracing::warn!(%message, "device poll failed");
state.devices.poll_error = Some(message);
}
}
}
AppEvent::DeviceActivated(result) => match result {
Ok(response) => apply_devices_response(state, runtime, response, true),
Err(message) => {
tracing::warn!(%message, "device activation failed");
state.devices.switching_to = None;
state.devices.poll_error = Some(message.clone());
state.status_message = Some(format!("device switch failed: {message}"));
}
},
AppEvent::EnqueueTracks { tracks, next } => {
let count = tracks.len();
if let Some(target) = state.devices.remote_target_id().map(str::to_string) {
let payload = serde_json::json!({ "tracks": tracks });
send_device_command(
runtime,
target,
if next {
"queue_add_next"
} else {
"queue_add_end"
},
payload,
);
state.status_message = Some(if next {
format!("{count} tracks queued next on active device")
} else {
format!("{count} tracks queued on active device")
});
return;
}
update::enqueue_tracks(state, tracks, next);
state.status_message = Some(if next {
format!("{count} tracks queued next")
@@ -901,6 +1441,59 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
format!("{count} tracks queued")
});
}
AppEvent::PlaylistCreated { result, add_track } => match result {
Ok(playlist) => {
tracing::info!(title = %playlist.title, "playlist created");
state.status_message = Some(format!("playlist \"{}\" created", playlist.title));
state.popup = None;
// The list is stale; refetch when next needed.
state.playlists.list = None;
if let Some(track) = add_track {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
let (id, title) = (playlist.id, playlist.title.clone());
tokio::spawn(async move {
let result = api
.add_tracks_to_playlist(id, &[track.id])
.await
.map_err(|e| e.to_string());
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id: id,
playlist_title: title,
result,
});
});
}
}
Err(message) => {
tracing::warn!(%message, "playlist creation failed");
state.status_message = Some(format!("create failed: {message}"));
if let Some(state::Popup::NewPlaylist { busy, .. }) = &mut state.popup {
*busy = false;
}
}
},
AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
result,
} => {
state.popup = None;
match result {
Ok(()) => {
state.status_message = Some(format!("added to \"{playlist_title}\""));
// Counts and contents changed; refetch lazily.
state.playlist_views.remove(&playlist_id);
state.playlists.list = None;
}
Err(message) => {
tracing::warn!(%message, playlist_id, "adding to playlist failed");
state.status_message = Some(format!("add failed: {message}"));
}
}
}
AppEvent::Media(command) => {
use crate::media::MediaCommand;
tracing::debug!(?command, "media key");
+228
View File
@@ -0,0 +1,228 @@
//! Modal dialog input: the add-to-playlist picker and new-playlist name
//! entry. The popup is taken out of the state, handled as an owned value
//! and put back unless the action closed it.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::models::TrackItem;
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::state::{AppState, Popup, addable_playlists};
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
let Some(popup) = state.popup.take() else {
return;
};
match popup {
Popup::AddToPlaylist { track, cursor } => {
handle_picker(state, runtime, track, cursor, key);
}
Popup::NewPlaylist {
for_track,
input,
busy,
} => handle_name_entry(state, runtime, for_track, input, busy, key),
Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key),
Popup::LogDetail(entry) => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::LogDetail(entry)),
},
}
}
/// Pasted text goes into the name field when it is open.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup {
if !*busy {
input.extend(pasted.chars().filter(|c| !c.is_control()));
}
}
}
fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
let len = state.devices.devices.len();
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::Devices {
cursor: cursor.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::Devices {
cursor: if len == 0 {
0
} else {
(cursor + 1).min(len - 1)
},
});
}
KeyCode::Enter => {
if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) {
let target = device.id.clone();
state.devices.switching_to = Some(target.clone());
state.popup = Some(Popup::Devices { cursor });
spawn_select_device(runtime, target);
} else {
state.popup = Some(Popup::Devices { cursor: 0 });
}
}
_ => {
state.popup = Some(Popup::Devices {
cursor: cursor.min(len.saturating_sub(1)),
})
}
}
}
fn handle_picker(
state: &mut AppState,
runtime: &Runtime,
track: TrackItem,
cursor: usize,
key: KeyEvent,
) {
let options = addable_playlists(state);
match key.code {
KeyCode::Esc => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::AddToPlaylist {
track,
cursor: cursor.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::AddToPlaylist {
track,
cursor: (cursor + 1).min(options.len()),
});
}
KeyCode::Enter => {
if cursor == 0 {
state.popup = Some(Popup::NewPlaylist {
for_track: Some(track),
input: String::new(),
busy: false,
});
} else if let Some((id, title)) = options.get(cursor - 1).cloned() {
spawn_add_track(runtime, id, title, track);
}
}
_ => state.popup = Some(Popup::AddToPlaylist { track, cursor }),
}
}
fn handle_name_entry(
state: &mut AppState,
runtime: &Runtime,
for_track: Option<TrackItem>,
mut input: String,
busy: bool,
key: KeyEvent,
) {
if busy {
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy,
});
return;
}
match key.code {
KeyCode::Esc => {
// Reached from the picker → step back to it; otherwise close.
if let Some(track) = for_track {
state.popup = Some(Popup::AddToPlaylist { track, cursor: 0 });
}
}
KeyCode::Enter => {
let title = input.trim().to_string();
if title.is_empty() {
state.status_message = Some("playlist name is empty".into());
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy: false,
});
return;
}
spawn_create_playlist(runtime, title, for_track.clone());
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy: true,
});
}
KeyCode::Backspace => {
input.pop();
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy: false,
});
}
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
input.push(c);
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy: false,
});
}
_ => {
state.popup = Some(Popup::NewPlaylist {
for_track,
input,
busy: false,
});
}
}
}
fn spawn_select_device(runtime: &Runtime, target_device_id: String) {
let Some(api) = runtime.api.clone() else {
return;
};
let current_device_id = runtime.device_id.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api
.select_device(&target_device_id, &current_device_id)
.await
{
Ok(response) => AppEvent::DeviceActivated(Ok(response)),
Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::DeviceActivated(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api
.add_tracks_to_playlist(playlist_id, &[track.id])
.await
.map_err(|e| e.to_string());
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
result,
});
});
}
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api.create_playlist(&title).await.map_err(|e| e.to_string());
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
});
}
+81 -7
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::api::models::{
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem, User,
};
use crate::art::ArtImage;
@@ -57,10 +57,18 @@ pub enum ArtState {
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 },
Artist {
id: i64,
cursor: usize,
},
Release {
id: i64,
cursor: usize,
},
/// Linear cursor over search results: artists, then releases, then tracks.
Search { cursor: usize },
Search {
cursor: usize,
},
}
/// The Global tab: the whole server library of artists.
@@ -194,8 +202,9 @@ pub struct LogsTab {
pub level_index: usize,
/// Stick to the newest entries as they arrive.
pub follow: bool,
/// When not following: how many (filtered) entries back from the end.
pub scroll_from_end: usize,
/// 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 {
@@ -203,11 +212,74 @@ impl Default for LogsTab {
Self {
level_index: 2,
follow: true,
scroll_from_end: 0,
selected_seq: None,
}
}
}
#[derive(Debug, Default)]
pub struct DevicesState {
pub device_id: String,
pub active_device_id: Option<String>,
pub devices: Vec<DeviceDto>,
pub poll_error: Option<String>,
pub switching_to: Option<String>,
}
impl DevicesState {
pub fn is_playback_device(&self) -> bool {
self.active_device_id
.as_deref()
.is_none_or(|active| active == self.device_id)
}
pub fn remote_target_id(&self) -> Option<&str> {
self.active_device_id
.as_deref()
.filter(|active| *active != self.device_id)
}
pub fn active_device_name(&self) -> Option<&str> {
let active = self.active_device_id.as_deref()?;
self.devices
.iter()
.find(|device| device.id == active)
.map(|device| device.name.as_str())
}
}
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the user's playlists (row 0 = "create new"); the track
/// is added on Enter.
AddToPlaylist { track: TrackItem, cursor: usize },
/// Name input for a new playlist; when `for_track` is set, the track is
/// added to it right after creation.
NewPlaylist {
for_track: Option<TrackItem>,
input: String,
busy: bool,
},
/// Connected devices list; Enter transfers active playback to the row.
Devices { cursor: usize },
/// Full, wrapped view of one log entry (Enter on the Logs tab).
LogDetail(crate::config::logging::LogEntry),
}
/// User's own 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.is_own && 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 {
@@ -445,6 +517,7 @@ pub struct AppState {
pub likes: std::collections::HashSet<i64>,
pub likes_loaded: bool,
pub logs: LogsTab,
pub devices: DevicesState,
pub queue_tab: QueueTab,
/// Shift-J jump in flight: focus this (release, track) once the release
/// view finishes loading.
@@ -453,6 +526,7 @@ pub struct AppState {
/// 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
+189 -62
View File
@@ -4,7 +4,7 @@ use super::action::Action;
use crate::api::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, Tab, TILE_HEIGHT, TILE_WIDTH,
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
ViewMode, release_display_order, release_rows,
};
@@ -22,9 +22,15 @@ pub enum Effect {
/// Seek relative to the current position, in seconds.
SeekBy(i64),
SetVolume(u8),
SetOptions,
/// Fetch a release and append all its tracks to the queue.
EnqueueRelease { id: i64, next: bool },
ToggleLike { track_id: i64 },
EnqueueRelease {
id: i64,
next: bool,
},
ToggleLike {
track_id: i64,
},
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -35,6 +41,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
.is_some_and(|deadline| Instant::now() <= deadline);
state.status_message = None;
match action {
// While the help window is open, quit/back just close it.
Action::Quit | Action::Back if state.help_visible => {
state.help_visible = false;
None
}
Action::Quit => {
if quit_armed {
state.should_quit = true;
@@ -48,10 +59,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.help_visible = !state.help_visible;
None
}
Action::Back if state.help_visible => {
state.help_visible = false;
None
}
Action::NextTab => {
switch_tab(state, state.active_tab.next());
None
@@ -85,9 +92,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
Action::NextTrack => queue_step(state, 1),
Action::PrevTrack => queue_step(state, -1),
Action::SeekForward { seconds } => {
state.player.current.is_some().then_some(Effect::SeekBy(seconds as i64))
}
Action::SeekForward { seconds } => state
.player
.current
.is_some()
.then_some(Effect::SeekBy(seconds as i64)),
Action::SeekBackward { seconds } => state
.player
.current
@@ -111,11 +120,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
} else {
restore_queue_order(&mut state.player);
}
None
Some(Effect::SetOptions)
}
Action::CycleRepeat => {
state.player.repeat = state.player.repeat.next();
None
Some(Effect::SetOptions)
}
Action::MoveUp => {
move_selection(state, 0, -1);
@@ -156,7 +165,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
Tab::Logs => {
state.logs.level_index =
(state.logs.level_index + 1) % super::state::LOG_LEVELS.len();
state.logs.scroll_from_end = 0;
state.logs.selected_seq = None;
state.logs.follow = true;
}
_ => {}
@@ -168,15 +177,41 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.cmdline.input.clear();
None
}
Action::OpenSearch => {
// The command line opens pre-filled with "/": typing continues
// the live search, exactly as if `:` then `/` were pressed.
state.cmdline.active = true;
state.cmdline.input = "/".to_string();
state.cmdline.live = true;
state.search = SearchState::default();
state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
state.global.stack.push(GlobalView::Search { cursor: 0 });
}
None
}
Action::OpenDevices => {
let cursor = state
.devices
.devices
.iter()
.position(|device| {
device.id == state.devices.active_device_id.clone().unwrap_or_default()
})
.unwrap_or(0);
state.popup = Some(super::state::Popup::Devices { cursor });
None
}
Action::Select => select_current(state),
Action::Back => {
go_back(state);
None
}
Action::ToggleLike => {
let target = selected_track(state)
.map(|t| t.id)
.or(state.player.current.as_ref().map(|t| t.id));
let target =
selected_track(state)
.map(|t| t.id)
.or(state.player.current.as_ref().map(|t| t.id));
match target {
Some(track_id) => Some(Effect::ToggleLike { track_id }),
None => {
@@ -195,6 +230,24 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
None
}
Action::AddToPlaylist => {
let track = selected_track(state).or_else(|| state.player.current.clone());
match track {
Some(track) => {
state.popup = Some(super::state::Popup::AddToPlaylist { track, cursor: 0 });
}
None => state.status_message = Some("no track selected".into()),
}
None
}
Action::NewPlaylist => {
state.popup = Some(super::state::Popup::NewPlaylist {
for_track: None,
input: String::new(),
busy: false,
});
None
}
Action::ClearQueue => {
let had_tracks = !state.player.queue.is_empty();
state.player.queue.clear();
@@ -223,7 +276,16 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => detail.top_tracks.get(*cursor).cloned(),
Loadable::Ready(detail) => {
let tracks = detail.top_tracks.len();
if *cursor < tracks {
detail.top_tracks.get(*cursor).cloned()
} else {
cursor
.checked_sub(tracks + detail.releases.len())
.and_then(|i| detail.featured_tracks.get(i).cloned())
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
@@ -238,7 +300,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)?.get(opened.cursor).cloned()
playlist_tracks(state, opened.id)?
.get(opened.cursor)
.cloned()
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Logs => None,
@@ -315,7 +379,10 @@ fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
let origin = state.active_tab;
state.active_tab = Tab::Global;
match state.global.stack.last_mut() {
Some(GlobalView::Release { id, cursor: current }) if *id == release_id => {
Some(GlobalView::Release {
id,
cursor: current,
}) if *id == release_id => {
*current = cursor;
}
_ => state.global.stack.push(GlobalView::Release {
@@ -478,7 +545,9 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
})
.collect();
keyed.sort_by_key(|(key, position, _)| (*key, *position));
player.queue.extend(keyed.into_iter().map(|(_, _, track)| track));
player
.queue
.extend(keyed.into_iter().map(|(_, _, track)| track));
player.prefetched_pos = None;
}
@@ -513,14 +582,17 @@ fn page_step(state: &AppState) -> isize {
ViewMode::Table => lines,
},
Some(GlobalView::Artist { id, cursor }) => {
let in_tracks = match state.artist_views.get(id) {
Some(Loadable::Ready(detail)) => *cursor < detail.top_tracks.len(),
_ => true,
let in_release_tiles = match state.artist_views.get(id) {
Some(Loadable::Ready(detail)) => {
*cursor >= detail.top_tracks.len()
&& *cursor < detail.top_tracks.len() + detail.releases.len()
}
_ => false,
};
if in_tracks || state.global.view == ViewMode::Table {
lines
} else {
if in_release_tiles && state.global.view == ViewMode::Tiles {
tile_rows
} else {
lines
}
}
Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) => lines,
@@ -529,15 +601,21 @@ fn page_step(state: &AppState) -> isize {
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
if state.active_tab == Tab::Logs {
let total = crate::config::logging::buffer().map_or(0, |b| b.len());
let logs = &mut state.logs;
if dy < 0 {
logs.follow = false;
logs.scroll_from_end = (logs.scroll_from_end + dy.unsigned_abs()).min(total);
} else if dy > 0 {
logs.scroll_from_end = logs.scroll_from_end.saturating_sub(dy as usize);
if logs.scroll_from_end == 0 {
logs.follow = true;
// The cursor anchors to an entry's seq, so freshly appended log
// lines (including ones caused by this very keypress) don't shift
// the selection.
if dy != 0 {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
let current = if state.logs.follow {
None
} else {
state.logs.selected_seq
};
if let Some((seq, is_newest)) = buffer.move_selection(level, current, dy) {
state.logs.selected_seq = Some(seq);
state.logs.follow = is_newest && dy > 0;
}
}
}
return;
@@ -589,24 +667,23 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
return;
};
let tracks = detail.top_tracks.len();
let total = tracks + detail.releases.len();
let releases = detail.releases.len();
let featured = detail.featured_tracks.len();
let total = tracks + releases + featured;
if total == 0 {
return;
}
let next = if cursor < tracks {
// Top-tracks zone: vertical only; stepping past the last
// track enters the releases zone (its first item).
let in_release_tiles = state.global.view == ViewMode::Tiles
&& cursor >= tracks
&& cursor < tracks + releases;
let next = if !in_release_tiles {
// List zones (top tracks, featured tracks; releases in
// table mode): plain vertical steps cross zone boundaries
// in flat order.
(cursor as isize + dy).clamp(0, total as isize - 1) as usize
} else if state.global.view == ViewMode::Table {
let next = cursor as isize + dy;
if next < tracks as isize && dy < 0 && tracks > 0 {
tracks - 1
} else {
next.clamp(0, total as isize - 1) as usize
}
} else {
// Tiles: move by visual rows (groups break rows), keeping
// the column, so Up lands on the tile directly above.
// Release tiles: move by visual rows (groups break rows),
// keeping the column, so Up lands on the tile above.
let rows = release_rows(&detail.releases, grid_columns());
let position = cursor - tracks;
let (row, column) = rows
@@ -617,18 +694,21 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
})
.unwrap_or((0, 0));
if dx != 0 {
let last = detail.releases.len() as isize - 1;
let last = releases as isize - 1;
tracks + (position as isize + dx).clamp(0, last) as usize
} else {
let target = row as isize + dy;
if target < 0 {
if tracks > 0 {
tracks - 1
if tracks > 0 { tracks - 1 } else { cursor }
} else if target as usize >= rows.len() {
// Below the last release row: the featured section.
if featured > 0 {
tracks + releases
} else {
cursor
}
} else {
let items = &rows[(target as usize).min(rows.len() - 1)];
let items = &rows[target as usize];
tracks + items[column.min(items.len() - 1)]
}
}
@@ -688,7 +768,9 @@ fn current_view_len(state: &AppState) -> usize {
match state.global.stack.last() {
None => state.global.artists.len(),
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(d)) => d.top_tracks.len() + d.releases.len(),
Some(Loadable::Ready(d)) => {
d.top_tracks.len() + d.releases.len() + d.featured_tracks.len()
}
_ => 0,
},
Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) {
@@ -709,11 +791,16 @@ fn jump_selection(state: &mut AppState, first: bool) {
}
if state.active_tab == Tab::Logs {
if first {
state.logs.follow = false;
state.logs.scroll_from_end = crate::config::logging::buffer().map_or(0, |b| b.len());
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
}
} else {
state.logs.follow = true;
state.logs.scroll_from_end = 0;
state.logs.selected_seq = None;
}
return;
}
@@ -768,6 +855,21 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
if state.active_tab == Tab::Playlists {
return select_playlist(state);
}
// Logs: open the full, wrapped entry under the cursor.
if state.active_tab == Tab::Logs {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
let selected = if state.logs.follow {
None
} else {
state.logs.selected_seq
};
if let Some(entry) = buffer.entry_at(level, selected) {
state.popup = Some(super::state::Popup::LogDetail(entry));
}
}
return None;
}
// Queue: jump playback to the track under the cursor. Earlier tracks
// stay in the queue as "played"; picking one of them just moves the
// playing position back.
@@ -784,7 +886,10 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}
enum Outcome {
Push(GlobalView),
Play { tracks: Vec<crate::api::models::TrackItem>, start: usize },
Play {
tracks: Vec<crate::api::models::TrackItem>,
start: usize,
},
Nothing,
}
let outcome = match state.global.stack.last().copied() {
@@ -798,12 +903,13 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) {
Some(Loadable::Ready(detail)) => {
let tracks = detail.top_tracks.len();
let releases = detail.releases.len();
if cursor < tracks {
Outcome::Play {
tracks: detail.top_tracks.clone(),
start: cursor,
}
} else {
} else if cursor < tracks + releases {
let order = release_display_order(&detail.releases);
match order.get(cursor - tracks) {
Some(&original) => Outcome::Push(GlobalView::Release {
@@ -812,6 +918,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}),
None => Outcome::Nothing,
}
} else if detail
.featured_tracks
.get(cursor - tracks - releases)
.is_some()
{
Outcome::Play {
tracks: detail.featured_tracks.clone(),
start: cursor - tracks - releases,
}
} else {
Outcome::Nothing
}
}
_ => Outcome::Nothing,
@@ -945,7 +1062,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
Tab::Playlists => state.playlists.opened = None,
Tab::Logs => {
state.logs.follow = true;
state.logs.scroll_from_end = 0;
state.logs.selected_seq = None;
}
Tab::Queue => {}
}
@@ -1104,6 +1221,7 @@ mod tests {
total_track_count: 0,
total_play_count: 0,
top_tracks: vec![],
featured_tracks: vec![],
releases: vec![
release(10, "album"),
release(11, "album"),
@@ -1115,7 +1233,10 @@ mod tests {
};
let mut state = AppState::default();
state.artist_views.insert(1, Loadable::Ready(detail));
state.global.stack.push(GlobalView::Artist { id: 1, cursor: 4 });
state
.global
.stack
.push(GlobalView::Artist { id: 1, cursor: 4 });
// Up from the first compilation lands on the album row directly
// above (position 3), not three flat items back.
@@ -1132,7 +1253,10 @@ mod tests {
);
// Up from the second compilation clamps to the single tile above.
state.global.stack.pop();
state.global.stack.push(GlobalView::Artist { id: 1, cursor: 5 });
state
.global
.stack
.push(GlobalView::Artist { id: 1, cursor: 5 });
update(&mut state, Action::MoveUp);
assert_eq!(
state.global.stack.last(),
@@ -1260,7 +1384,10 @@ mod tests {
update(&mut state, Action::MoveUp);
update(&mut state, Action::MoveUp);
assert_eq!(state.queue_tab.cursor, 0);
assert_eq!(update(&mut state, Action::Select), Some(Effect::PlayCurrent));
assert_eq!(
update(&mut state, Action::Select),
Some(Effect::PlayCurrent)
);
assert_eq!(state.player.queue_pos, 0);
assert_eq!(state.player.queue.len(), 3);