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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

## [Unreleased]

### Changed

- **Fullscreen playbar resizing**: `LyricsView` and `CoverArtView` now honor the existing playbar height setting, so the lower player can be resized or fully hidden with the same keybindings used on the main screen (fixes [#208](https://github.com/LargeModGames/spotatui/issues/208)).

### Fixed

- **Hidden fullscreen cover-art centering**: Fixed the fullscreen cover art image rendering slightly off-center when the playbar was completely hidden.
- **Volume display glitch on rapid changes**: Fixed the volume percentage briefly reverting to an old value after the user changed it, especially noticeable when spamming volume up/down. The UI now always shows the user's intended volume until Spotify's API confirms it matches.

## [v0.38.0] - 2026-03-23
Expand Down
43 changes: 42 additions & 1 deletion src/core/layout.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::core::user_config::BehaviorConfig;
use ratatui::layout::Constraint;
use ratatui::layout::{Constraint, Layout, Rect};

/// Returns horizontal constraints for the [sidebar, content] split based on config.
/// When sidebar_width_percent is 0, the sidebar is hidden (zero length).
Expand Down Expand Up @@ -29,6 +29,25 @@ pub fn library_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] {
]
}

/// Returns the fullscreen content/playbar split used by lyrics and cover-art views.
///
/// When `playbar_height_rows` is 0, the playbar is hidden and the content area fills the screen.
pub fn fullscreen_view_layout(behavior: &BehaviorConfig, area: Rect) -> (Rect, Option<Rect>) {
if behavior.playbar_height_rows == 0 {
return (area, None);
}

let chunks = Layout::vertical([
Constraint::Min(0),
Constraint::Length(behavior.playbar_height_rows),
])
.split(area);
let content_area = chunks[0];
let playbar_area = chunks[1];

(content_area, Some(playbar_area))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -120,4 +139,26 @@ mod tests {
assert_eq!(lib, Constraint::Percentage(100));
assert_eq!(playlists, Constraint::Percentage(0));
}

#[test]
fn fullscreen_layout_hides_playbar_when_height_is_zero() {
let b = make_behavior_with(20, 0);
let area = Rect::new(2, 4, 80, 24);

let (content, playbar) = fullscreen_view_layout(&b, area);

assert_eq!(content, area);
assert!(playbar.is_none());
}

#[test]
fn fullscreen_layout_splits_content_and_playbar_when_height_is_set() {
let b = make_behavior_with(20, 6);
let area = Rect::new(2, 4, 80, 24);

let (content, playbar) = fullscreen_view_layout(&b, area);

assert_eq!(content, Rect::new(2, 4, 80, 18));
assert_eq!(playbar, Some(Rect::new(2, 22, 80, 6)));
}
}
11 changes: 11 additions & 0 deletions src/tui/cover_art.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ impl CoverArt {
Self::render_state(&self.fullscreen_state, f, area);
}

pub fn fullscreen_size_for(&self, area: Rect) -> Option<Rect> {
Self::size_for_state(&self.fullscreen_state, area)
}

fn render_state(state: &Mutex<Option<CoverArtState>>, f: &mut Frame, area: Rect) {
let mut lock = state.lock().unwrap();
if let Some(sp) = lock.as_mut() {
Expand All @@ -127,4 +131,11 @@ impl CoverArt {
);
}
}

fn size_for_state(state: &Mutex<Option<CoverArtState>>, area: Rect) -> Option<Rect> {
let lock = state.lock().unwrap();
lock
.as_ref()
.map(|sp| sp.image.size_for(Resize::Fit(None), area))
}
}
70 changes: 60 additions & 10 deletions src/tui/handlers/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ use super::{library, playbar, playlist, settings, track_table};
use crate::core::app::{
ActiveBlock, App, RouteId, SettingValue, SettingsCategory, LIBRARY_OPTIONS,
};
use crate::core::layout::{library_constraints, playbar_constraint, sidebar_constraints};
use crate::core::layout::{
fullscreen_view_layout, library_constraints, playbar_constraint, sidebar_constraints,
};
use crate::tui::event::Key;
use crate::tui::ui::player::playbar_control_at;
use crate::tui::ui::util::{
get_main_layout_margin, FULLSCREEN_VIEW_PLAYBAR_HEIGHT, SMALL_TERMINAL_WIDTH,
};
use crate::tui::ui::util::{get_main_layout_margin, SMALL_TERMINAL_WIDTH};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
Expand Down Expand Up @@ -709,12 +709,8 @@ fn fullscreen_view_playbar_area(app: &App) -> Option<Rect> {
}

let root = Rect::new(0, 0, app.size.width, app.size.height);
let [_lyrics_area, playbar_area] = root.layout(&Layout::vertical([
Constraint::Min(0),
Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT),
]));

Some(playbar_area)
let (_, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, root);
playbar_area
}

fn main_layout_areas(app: &App) -> Option<MainLayoutAreas> {
Expand Down Expand Up @@ -1099,6 +1095,60 @@ mod tests {
assert_eq!(route.hovered_block, ActiveBlock::LyricsView);
}

#[test]
fn fullscreen_view_playbar_area_uses_configured_height() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.user_config.behavior.playbar_height_rows = 8;

let playbar_area = fullscreen_view_playbar_area(&app).expect("fullscreen playbar area");

assert_eq!(playbar_area, Rect::new(0, 42, 160, 8));
}

#[test]
fn fullscreen_view_playbar_area_is_hidden_when_height_is_zero() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.user_config.behavior.playbar_height_rows = 0;

assert!(fullscreen_view_playbar_area(&app).is_none());
}

#[test]
fn click_hidden_lyrics_view_playbar_area_does_nothing() {
let mut app = App::default();
app.size = Size {
width: 160,
height: 50,
};
app.user_config.behavior.playbar_height_rows = 0;
app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView);
with_playbar_context(&mut app);

let (initial_route_id, initial_active_block, initial_hovered_block) = {
let route = app.get_current_route();
(route.id.clone(), route.active_block, route.hovered_block)
};

handler(
mouse_event(MouseEventKind::Down(MouseButton::Left), 80, 49),
&mut app,
);

assert!(!app.is_loading);
let route = app.get_current_route();
assert_eq!(route.id, initial_route_id);
assert_eq!(route.active_block, initial_active_block);
assert_eq!(route.hovered_block, initial_hovered_block);
}

#[test]
fn resized_playbar_control_click_still_maps_correctly() {
let mut app = App::default();
Expand Down
109 changes: 58 additions & 51 deletions src/tui/ui/player.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use crate::core::app::{ActiveBlock, App};
use crate::core::{
app::{ActiveBlock, App},
layout::fullscreen_view_layout,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
layout::{Alignment, Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{
Expand All @@ -15,7 +18,6 @@ use rspotify::prelude::Id;

use super::util::{
create_artist_string, display_track_progress, get_color, get_track_progress_percentage,
FULLSCREEN_VIEW_PLAYBAR_HEIGHT,
};

const PLAYBAR_CONTROLS: [PlaybarControl; 8] = [
Expand Down Expand Up @@ -246,31 +248,33 @@ fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, playbar_area: Rect) {
}
}

#[cfg(feature = "cover-art")]
fn center_rect_within(bounds: Rect, size: Rect) -> Rect {
Rect {
x: bounds.x + bounds.width.saturating_sub(size.width.min(bounds.width)) / 2,
y: bounds.y + bounds.height.saturating_sub(size.height.min(bounds.height)) / 2,
width: size.width.min(bounds.width),
height: size.height.min(bounds.height),
}
}

pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Lyrics Area taking all available space above
Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT), // Playbar at the bottom
])
.split(f.area());

draw_lyrics(f, app, chunks[0]);
draw_playbar(f, app, chunks[1]);
let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area());

draw_lyrics(f, app, content_area);
if let Some(playbar_area) = playbar_area {
draw_playbar(f, app, playbar_area);
}
}

#[cfg(feature = "cover-art")]
pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT),
])
.split(f.area());

draw_cover_art_content(f, app, chunks[0]);
draw_playbar(f, app, chunks[1]);
let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area());

draw_cover_art_content(f, app, content_area);
if let Some(playbar_area) = playbar_area {
draw_playbar(f, app, playbar_area);
}
}

#[cfg(feature = "cover-art")]
Expand Down Expand Up @@ -299,42 +303,36 @@ fn draw_cover_art_content(f: &mut Frame<'_>, app: &App, area: Rect) {
return;
}

// Reserve 3 rows at the bottom for song info (1 blank + 1 title + 1 artist)
let info_height = 3_u16;
let img_area_height = area.height.saturating_sub(info_height);

// Calculate image dimensions for a square album cover
// Terminal characters are taller than wide, so we use a ratio to get a square.
let char_aspect_ratio = 1.9_f32;

let max_height = img_area_height.saturating_sub(2);
let max_width = area.width.saturating_sub(2);

let img_width_from_height = ((max_height as f32) * char_aspect_ratio).ceil() as u16;

let (img_width, img_height) = if img_width_from_height > max_width {
let h = ((max_width as f32) / char_aspect_ratio).floor() as u16;
(max_width, h)
let show_title = track_name.is_some();
let show_artist = show_title && artist_str.is_some();
let info_height = if show_title {
1 + 1 + u16::from(show_artist)
} else {
(img_width_from_height, max_height)
0
};

// Center the image horizontally, vertically within the image area
let x = area.x + (area.width.saturating_sub(img_width)) / 2;
let y = area.y + (img_area_height.saturating_sub(img_height)) / 2;

let centered_area = Rect {
x,
y,
width: img_width,
height: img_height,
let image_bounds = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(info_height),
};
let available_image_size = Rect::new(
0,
0,
image_bounds.width.saturating_sub(2),
image_bounds.height.saturating_sub(2),
);
let fitted_image_size = app
.cover_art
.fullscreen_size_for(available_image_size)
.unwrap_or(available_image_size);
let centered_area = center_rect_within(image_bounds, fitted_image_size);

app.cover_art.render_fullscreen(f, centered_area);

// Draw song info below the cover art
if let Some(name) = track_name {
let title_y = y + img_height + 1;
let title_y = centered_area.y + centered_area.height + 1;
if title_y < area.y + area.height {
let title = Paragraph::new(name)
.style(
Expand Down Expand Up @@ -825,4 +823,13 @@ mod tests {
PlaybarControl::VolumeUp
);
}

#[cfg(feature = "cover-art")]
#[test]
fn center_rect_within_centers_smaller_rect() {
let bounds = Rect::new(10, 20, 100, 50);
let size = Rect::new(0, 0, 80, 40);

assert_eq!(center_rect_within(bounds, size), Rect::new(20, 25, 80, 40));
}
}
1 change: 0 additions & 1 deletion src/tui/ui/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use ratatui::{
use rspotify::model::artist::SimplifiedArtist;
use std::time::Duration;

pub const FULLSCREEN_VIEW_PLAYBAR_HEIGHT: u16 = 6;
pub const SMALL_TERMINAL_WIDTH: u16 = 150;
pub const SMALL_TERMINAL_HEIGHT: u16 = 45;

Expand Down
Loading