Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]

### Added

- **Click and drag to seek on the playbar**: The progress bar is now interactive. Click anywhere on the gauge to jump to that position, or click and drag to scrub. Control buttons keep priority, the time label stays non-clickable, and seeks reuse the existing native and throttled-API paths ([#157](https://github.com/LargeModGames/spotatui/issues/157)).

### Fixed

- **Search box no longer traps focus on submit**: Pressing `Enter` to run a search now always moves focus to the results list, including when re-searching while already on the Search screen (previously focus stayed stuck in the input box) ([#191](https://github.com/LargeModGames/spotatui/issues/191)).
- **Cover-art load failures are non-fatal**: A failed album-image fetch is now logged and ignored instead of surfacing a blocking error and aborting the now-playing update, so playback metadata keeps updating when artwork can't be loaded ([#142](https://github.com/LargeModGames/spotatui/issues/142)).

## [v0.38.6] 2026-05-28

### Fixed
Expand Down
44 changes: 44 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,50 @@ impl App {
self.queue_api_seek(new_progress);
}

/// Seek to an absolute position within the current track (e.g. from clicking or
/// dragging on the playbar progress line). The target is clamped to the track
/// duration. Mirrors the dispatch logic of [`Self::seek_forwards`].
pub fn seek_to(&mut self, position_ms: u32) {
if let Some(CurrentPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
let duration_ms = match item {
PlayableItem::Track(track) => track.duration.num_milliseconds() as u32,
PlayableItem::Episode(episode) => episode.duration.num_milliseconds() as u32,
_ => return,
};

let new_progress = position_ms.min(duration_ms);
self.seek_ms = Some(new_progress as u128);

// Use native streaming player for instant control (bypasses event channel latency)
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback() && self.streaming_player.is_some() {
// Always update UI immediately
self.song_progress_ms = new_progress as u128;
self.seek_ms = None;

// Throttle actual seeks to avoid overwhelming librespot (max ~20/sec)
const SEEK_THROTTLE_MS: u128 = 50;
let should_seek_now = self
.last_native_seek
.is_none_or(|t| t.elapsed().as_millis() >= SEEK_THROTTLE_MS);

if should_seek_now {
self.execute_native_seek(new_progress);
} else {
// Queue the seek - will be flushed by tick loop or next seek
self.pending_native_seek = Some(new_progress);
}
return;
}

// Fallback: API-based seek for external devices (with throttling)
self.queue_api_seek(new_progress);
}
}

/// Queue an API-based seek with throttling (for external device control)
fn queue_api_seek(&mut self, position_ms: u32) {
// Always update UI immediately
Expand Down
8 changes: 4 additions & 4 deletions src/infra/network/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,10 @@ impl PlaybackNetwork for Network {
};

if let Some(image) = image {
if let anyhow::Result::Err(err) = app.cover_art.refresh(image).await {
drop(app);
self.handle_error(err).await;
return;
// Cover art is non-essential: a failed image fetch must not surface a
// blocking error or abort the rest of the playback-context update (#142).
if let Err(err) = app.cover_art.refresh(image).await {
log::warn!("ignoring cover art load failure: {err}");
}
}
}
Expand Down
35 changes: 35 additions & 0 deletions src/tui/handlers/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ fn process_input(app: &mut App, input: String) {
// Default fallback behavior: treat the input as a raw search phrase.
app.dispatch(IoEvent::GetSearchResults(input, app.get_user_country()));
app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);
// push_navigation_stack is a no-op when the Search route is already on top, which
// otherwise leaves focus trapped in the input box. Force focus onto the results so
// submitting a search always escapes the search box (#191).
app.set_current_route_state(
Some(ActiveBlock::SearchResultBlock),
Some(ActiveBlock::SearchResultBlock),
);
}

fn process_playlist_track_search(app: &mut App, input: String) {
Expand Down Expand Up @@ -389,6 +396,34 @@ mod tests {
assert!(rx.try_recv().is_err());
}

#[test]
fn search_enter_on_search_route_moves_focus_to_results() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(
tx,
crate::core::user_config::UserConfig::new(),
std::time::SystemTime::now(),
);

// Already on the Search route with focus in the input box. This is the case
// that previously left focus trapped in the search box on submit (#191).
app.push_navigation_stack(RouteId::Search, ActiveBlock::Input);
app.input = str_to_vec_char("daft punk");
app.input_idx = app.input.len();
app.input_cursor_position = app.input.len() as u16;

handler(Key::Enter, &mut app);

match rx.recv().unwrap() {
IoEvent::GetSearchResults(query, _) => assert_eq!(query, "daft punk"),
_ => panic!("unexpected event"),
}

let route = app.get_current_route();
assert_eq!(route.active_block, ActiveBlock::SearchResultBlock);
assert_eq!(route.hovered_block, ActiveBlock::SearchResultBlock);
}

#[test]
fn test_input_handler_on_enter_text() {
let mut app = App::default();
Expand Down
177 changes: 167 additions & 10 deletions src/tui/handlers/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::core::layout::{
sidebar_constraints,
};
use crate::tui::event::Key;
use crate::tui::ui::player::playbar_control_at;
use crate::tui::ui::player::{playbar_control_at, playbar_progress_line};
use crate::tui::ui::tables::table_scroll_offset;
use crate::tui::ui::util::{get_main_layout_margin, SMALL_TERMINAL_WIDTH};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
Expand Down Expand Up @@ -227,16 +227,34 @@ fn handle_playbar_mouse(
focus_block: ActiveBlock,
app: &mut App,
) {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return;
}

let Some(control) = playbar_control_at(app, playbar_area, mouse.column, mouse.row) else {
return;
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
// Control buttons take precedence; they sit on a different row than the gauge.
if let Some(control) = playbar_control_at(app, playbar_area, mouse.column, mouse.row) {
focus_playbar(focus_block, app);
playbar::handle_control(control, app);
return;
}

focus_playbar(focus_block, app);
playbar::handle_control(control, app);
// Otherwise, a click on the progress line seeks to that position (#157).
if let Some(line) = playbar_progress_line(app, playbar_area) {
if line.contains(mouse.column, mouse.row) {
focus_playbar(focus_block, app);
app.seek_to(line.position_at(mouse.column));
}
}
}
// Dragging along the progress row scrubs; the column is clamped into the line.
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(line) = playbar_progress_line(app, playbar_area) {
if line.on_row(mouse.row) {
focus_playbar(focus_block, app);
app.seek_to(line.position_at(mouse.column));
}
}
}
_ => {}
}
}

fn handle_settings_screen_mouse(mouse: MouseEvent, app: &mut App) {
Expand Down Expand Up @@ -1265,6 +1283,145 @@ mod tests {
assert_eq!(route.hovered_block, ActiveBlock::PlayBar);
}

#[test]
fn click_main_layout_playbar_progress_seeks() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.push_navigation_stack(RouteId::Home, ActiveBlock::Home);
with_playbar_context(&mut app); // 180_000 ms track
app.song_progress_ms = 0;

let areas = main_layout_areas(&app).expect("layout areas");
let line = playbar_progress_line(&app, areas.playbar).expect("progress line");

// A click a quarter of the way along the gauge seeks to ~25% of the track.
let quarter_x = line.start + line.width / 4;
handler(
mouse_event(MouseEventKind::Down(MouseButton::Left), quarter_x, line.row),
&mut app,
);
let after_quarter = app.song_progress_ms;
assert!(
(35_000..=60_000).contains(&after_quarter),
"quarter click gave {after_quarter}"
);

// A click three quarters along seeks further into the track.
let three_quarter_x = line.start + (line.width * 3) / 4;
handler(
mouse_event(
MouseEventKind::Down(MouseButton::Left),
three_quarter_x,
line.row,
),
&mut app,
);
assert!(
(120_000..=150_000).contains(&app.song_progress_ms),
"three-quarter click gave {}",
app.song_progress_ms
);
assert!(app.song_progress_ms > after_quarter);

assert_eq!(app.get_current_route().active_block, ActiveBlock::PlayBar);
}

#[test]
fn click_playbar_time_label_does_not_seek() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.push_navigation_stack(RouteId::Home, ActiveBlock::Home);
with_playbar_context(&mut app);
app.song_progress_ms = 12_345;

let areas = main_layout_areas(&app).expect("layout areas");
let line = playbar_progress_line(&app, areas.playbar).expect("progress line");

// Clicking the time label (left of the gauge line) must not seek.
let label_x = line.start - 2;
handler(
mouse_event(MouseEventKind::Down(MouseButton::Left), label_x, line.row),
&mut app,
);
assert_eq!(app.song_progress_ms, 12_345);
}

#[test]
fn drag_playbar_progress_seeks() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.push_navigation_stack(RouteId::Home, ActiveBlock::Home);
with_playbar_context(&mut app);
app.song_progress_ms = 0;

let areas = main_layout_areas(&app).expect("layout areas");
let line = playbar_progress_line(&app, areas.playbar).expect("progress line");

let mid_x = line.start + line.width / 2;
handler(
mouse_event(MouseEventKind::Drag(MouseButton::Left), mid_x, line.row),
&mut app,
);
assert!(
(80_000..=100_000).contains(&app.song_progress_ms),
"drag to midpoint gave {}",
app.song_progress_ms
);
}

// Non-circular geometry check: the seek tests above derive their click column
// from `playbar_progress_line`, so a wrong `start`/`width` would cancel out. This
// renders the real playbar and asserts the computed line start matches the column
// where the actual gauge symbols begin in the rendered buffer.
#[test]
fn playbar_progress_line_start_matches_rendered_gauge() {
use crate::tui::ui::player::draw_playbar;
use ratatui::{backend::TestBackend, Terminal};

let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.push_navigation_stack(RouteId::Home, ActiveBlock::Home);
with_playbar_context(&mut app);
app.song_progress_ms = 60_000; // 1/3 of the 180s track -> a partly-filled gauge

let areas = main_layout_areas(&app).expect("layout areas");
let line = playbar_progress_line(&app, areas.playbar).expect("progress line");

let mut terminal = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|f| draw_playbar(f, &app, areas.playbar))
.unwrap();
let buffer = terminal.backend().buffer();

// The LineGauge fills with "⣿" and "⣉"; neither appears in the time label, so the
// first such cell on the progress row is the true gauge start column.
let rendered_start = (areas.playbar.x..areas.playbar.x + areas.playbar.width)
.find(|&x| {
matches!(
buffer.cell((x, line.row)).map(|c| c.symbol()),
Some("⣿") | Some("⣉")
)
})
.expect("expected rendered gauge cells on the progress row");

assert_eq!(
rendered_start, line.start,
"computed gauge start must match the rendered bar"
);
}

#[test]
fn click_lyrics_view_playbar_control_triggers_action() {
let mut app = App::default();
Expand Down
Loading
Loading