From 341d0f88419de03c6b365e1e7d6fe30a1c0edca0 Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Wed, 8 Apr 2026 19:38:12 +0200 Subject: [PATCH 1/2] feat(fullscreen): implement dynamic playbar resizing and update layout handling --- CHANGELOG.md | 5 ++ src/core/layout.rs | 43 ++++++++++++++- src/tui/cover_art.rs | 11 ++++ src/tui/handlers/mouse.rs | 70 +++++++++++++++++++++---- src/tui/ui/player.rs | 107 ++++++++++++++++++++------------------ src/tui/ui/util.rs | 1 - 6 files changed, 174 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ecc37f..44f0b89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/core/layout.rs b/src/core/layout.rs index 58f9ce69..67f7be68 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -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). @@ -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) { + 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::*; @@ -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))); + } } diff --git a/src/tui/cover_art.rs b/src/tui/cover_art.rs index 01f8f0e4..790d526c 100644 --- a/src/tui/cover_art.rs +++ b/src/tui/cover_art.rs @@ -117,6 +117,10 @@ impl CoverArt { Self::render_state(&self.fullscreen_state, f, area); } + pub fn fullscreen_size_for(&self, area: Rect) -> Option { + Self::size_for_state(&self.fullscreen_state, area) + } + fn render_state(state: &Mutex>, f: &mut Frame, area: Rect) { let mut lock = state.lock().unwrap(); if let Some(sp) = lock.as_mut() { @@ -127,4 +131,11 @@ impl CoverArt { ); } } + + fn size_for_state(state: &Mutex>, area: Rect) -> Option { + let lock = state.lock().unwrap(); + lock + .as_ref() + .map(|sp| sp.image.size_for(Resize::Fit(None), area)) + } } diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index ce9771e9..70e36915 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -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}; @@ -709,12 +709,8 @@ fn fullscreen_view_playbar_area(app: &App) -> Option { } 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 { @@ -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(); diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index 35615b68..d4876959 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -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::{ @@ -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] = [ @@ -246,31 +248,32 @@ fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, playbar_area: Rect) { } } +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")] @@ -299,42 +302,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( @@ -825,4 +822,12 @@ mod tests { PlaybarControl::VolumeUp ); } + + #[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)); + } } diff --git a/src/tui/ui/util.rs b/src/tui/ui/util.rs index 28d5805e..826f6f59 100644 --- a/src/tui/ui/util.rs +++ b/src/tui/ui/util.rs @@ -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; From 1c172b24a0c92ce69f29684172665c8ba9575d8b Mon Sep 17 00:00:00 2001 From: LargeModGames Date: Thu, 9 Apr 2026 11:00:04 +0200 Subject: [PATCH 2/2] fix clippy warnings --- src/tui/ui/player.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index d4876959..e0d570d9 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -248,6 +248,7 @@ 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, @@ -823,6 +824,7 @@ mod tests { ); } + #[cfg(feature = "cover-art")] #[test] fn center_rect_within_centers_smaller_rect() { let bounds = Rect::new(10, 20, 100, 50);