From b553a94875330424f71f12ed90560b52c57aee29 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 28 Jun 2026 16:59:49 -0400 Subject: [PATCH] feat(music): relocate playlist into a now-playing bar + slide-out drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full music panel was rendered inline at all times (its own card in the 3-column layout, stuffed into the Controls panel otherwise), which crowded every layout. Move it behind two toggles: - A room-only ♪ button in the top bar shows/hides a slim 56px now-playing player bar (track + ⏮ ⏸/▶ ⏭ + position + expand). The preference persists (AppConfig.show_player_bar). - The bar's ⤢ button opens the full panel in a resizable right-edge drawer (DividerKind::PlaylistDrawer, mirrors the Chat drawer). When open, body_w shrinks so the layouts' fixed panels don't overflow. Removes all inline playlist placement (3-col card + ThreeColPlaylist divider, ctrl_music block) and the now-dead clamp/consts. Pure now_playing_label seam + drawer-width clamp test. 474 lib tests, clippy -D warnings clean, release build green. Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude. Co-Authored-By: Codex Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 294 ++++++++++++++++++++++++++++++++++++------------- src/config.rs | 18 +++ 2 files changed, 234 insertions(+), 78 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 2433154..a8ad0d9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -237,15 +237,15 @@ pub enum DividerKind { /// Horizontal divider between the main row and the Chat dock (resizes the /// Chat dock height). Chat, - /// Horizontal divider between Chat and the standalone Playlist card in the - /// 3-column layout (resizes the Playlist card height). - ThreeColPlaylist, /// Vertical divider between Chat and Controls in the 3-column layout (resizes /// the Controls panel width). Controls, /// Vertical divider on the left edge of the Chat drawer (resizes the drawer /// width) in the drawer layout. ChatDrawer, + /// Vertical divider on the left edge of the Playlist drawer (resizes the + /// drawer width). + PlaylistDrawer, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -264,11 +264,6 @@ const CHAT_MIN_H: f32 = 110.0; /// Minimum height reserved above the Chat dock (header + main row) when resizing /// the dock (px). const ABOVE_CHAT_MIN_H: f32 = 300.0; -/// Minimum height of the standalone Playlist card in the 3-column layout (px). -const THREECOL_PLAYLIST_MIN_H: f32 = 150.0; -/// Minimum height reserved for the Chat above the Playlist card in the 3-column -/// layout when resizing the card (px). -const THREECOL_CHAT_MIN_H: f32 = 160.0; /// Thickness of a draggable divider (px). const DIVIDER_THICKNESS: f32 = 8.0; /// Upper bound for waiting on orderly core shutdown before letting the window exit. @@ -290,13 +285,6 @@ fn clamp_chat_height(height: f32, window_h: f32) -> f32 { height.clamp(CHAT_MIN_H, max) } -/// Clamp the 3-column Playlist card height so neither it nor the Chat above it -/// drops below its minimum, given the current window height. -fn clamp_threecol_playlist_height(height: f32, window_h: f32) -> f32 { - let max = (window_h - THREECOL_CHAT_MIN_H).max(THREECOL_PLAYLIST_MIN_H); - height.clamp(THREECOL_PLAYLIST_MIN_H, max) -} - /// Minimum width of the Chat column / drawer (px). const CHAT_MIN_W: f32 = 200.0; @@ -315,6 +303,13 @@ fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 { width.clamp(CHAT_MIN_W, max) } +/// Clamp the Playlist drawer width so neither it nor the room body drops below +/// its minimum, given the current window width. +fn clamp_playlist_drawer_width(width: f32, window_w: f32) -> f32 { + let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W); + width.clamp(CHAT_MIN_W, max) +} + #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum AppMessage { @@ -444,6 +439,10 @@ pub enum AppMessage { MusicSetVolume(f32), /// Set the tuned-in source's music playback volume (and persist it). MusicSetSourceVolume(f32), + /// Show / hide the room now-playing player bar (persisted). + TogglePlayerBar, + /// Open / close the full playlist drawer. + TogglePlaylistDrawer, /// Remove the playlist track at this index. MusicRemove(usize), /// Move a personal playlist track one slot up/down. @@ -754,6 +753,8 @@ pub struct AppState { clock_skew_warning: Option, /// Whether the Chat drawer is open (drawer layout only). drawer_chat_open: bool, + /// Whether the Playlist drawer is open beside the room body. + playlist_drawer_open: bool, /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter. mic_level: f32, /// Whether the standalone (off-call) mic test stream is running. @@ -871,10 +872,10 @@ impl Default for AppState { config.participants_width = clamp_participants_width(config.participants_width, ww); config.chat_height = clamp_chat_height(config.chat_height, wh); - config.threecol_playlist_height = - clamp_threecol_playlist_height(config.threecol_playlist_height, wh); config.controls_width = clamp_controls_width(config.controls_width, ww); config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww); + config.playlist_drawer_width = + clamp_playlist_drawer_width(config.playlist_drawer_width, ww); notify::set_enabled(config.notifications_enabled); for sound in Sound::ALL { notify::set_sound_enabled(sound, config.sound_enabled(sound)); @@ -1003,6 +1004,7 @@ impl Default for AppState { share_app_audio_supported: true, clock_skew_warning: None, drawer_chat_open: false, + playlist_drawer_open: false, mic_level: 0.0, mic_test_active: false, connecting: HashSet::new(), @@ -2056,14 +2058,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.window_size.height, ); } - DividerKind::ThreeColPlaylist => { - // The Playlist card sits at the bottom of the middle column; dragging the - // divider down (positive delta) gives Chat more room and shrinks the card. - state.config.threecol_playlist_height = clamp_threecol_playlist_height( - state.config.threecol_playlist_height - delta, - state.window_size.height, - ); - } DividerKind::Controls => { // Controls sits on the right; dragging the divider right (positive // delta) gives Chat more room and shrinks Controls. @@ -2080,6 +2074,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.window_size.width, ); } + DividerKind::PlaylistDrawer => { + // The drawer sits on the right; dragging its left-edge divider + // left (negative delta) widens the drawer. + state.config.playlist_drawer_width = clamp_playlist_drawer_width( + state.config.playlist_drawer_width - delta, + state.window_size.width, + ); + } } } AppMessage::OpenLayoutPicker => { @@ -2350,6 +2352,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::ToggleDrawerChat => { state.drawer_chat_open = !state.drawer_chat_open; } + AppMessage::TogglePlayerBar => { + state.config.show_player_bar = !state.config.show_player_bar; + if !state.config.show_player_bar { + state.playlist_drawer_open = false; + } + state.config.save(); + } + AppMessage::TogglePlaylistDrawer => { + state.playlist_drawer_open = !state.playlist_drawer_open; + } AppMessage::ChatSubmit => { let text = sanitize_chat(&state.chat_input); if !text.is_empty() { @@ -2783,12 +2795,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { clamp_participants_width(state.config.participants_width, size.width); state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height); - state.config.threecol_playlist_height = - clamp_threecol_playlist_height(state.config.threecol_playlist_height, size.height); state.config.controls_width = clamp_controls_width(state.config.controls_width, size.width); state.config.chat_drawer_width = clamp_chat_drawer_width(state.config.chat_drawer_width, size.width); + state.config.playlist_drawer_width = + clamp_playlist_drawer_width(state.config.playlist_drawer_width, size.width); } AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => { // Remember the position in-memory; written to disk once on close. @@ -3121,6 +3133,15 @@ fn can_broadcast_music(state: &AppState) -> bool { state.music_broadcasting && state.music_listening_to.is_none() } +/// Text shown on the now-playing player bar. Pure for testing. +fn now_playing_label(listening_to: Option<&str>, current_track: Option<&str>) -> String { + match (listening_to, current_track) { + (Some(peer), _) => format!("Tuned in to {peer}"), + (None, Some(track)) => track.to_string(), + (None, None) => "Nothing playing".to_string(), + } +} + fn stop_music_broadcast(state: &mut AppState) { let old_current = state.music_broadcast_id.take(); let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id); @@ -4015,7 +4036,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { }; // The Hotkeys info button is always available (hotkeys are app-wide). The - // room-layout button is hidden on the Home screen, leaving only it + Settings. + // room-only player button is hidden off-room; the layout button is hidden on Home. let info_button = tooltip( button(icon(IconKind::Info, 18.0, color_text)) .on_press(AppMessage::OpenHotkeyInfo) @@ -4028,6 +4049,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .gap(8); + let player_bar_button: Element<'_, AppMessage> = + if state.current_screen == Screen::Room { + let active = state.config.show_player_bar; + let bg = if active { color_blue } else { color_surface }; + let fg = if active { color_crust } else { color_text }; + tooltip( + button(text("♪").size(15)) + .on_press(AppMessage::TogglePlayerBar) + .style(b_style(bg, color_blue, fg, 6.0)) + .padding(8), + container(text("Player bar").size(11).color(color_text)) + .padding(8) + .style(c_style(color_crust, color_surface, 6.0)), + iced::widget::tooltip::Position::Bottom, + ) + .gap(8) + .into() + } else { + iced::widget::Space::new().width(0.0).height(0.0).into() + }; + let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home { iced::widget::Space::new().width(0.0).height(0.0).into() } else { @@ -4051,6 +4093,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let top_bar = row![ horizontal_space(), + player_bar_button, info_button, layout_button, button( @@ -5664,37 +5707,65 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) .into() }; - // W22: in the 3-column layout the Playlist gets its own card stacked under Chat - // (built below in the ThreeColumn body arm); in every other layout it stays in - // the Controls panel. `music_panel` is consumed by exactly one of these. - let three_col = matches!(state.config.room_layout, RoomLayout::ThreeColumn); - let (ctrl_music, playlist_card): (Element<'_, AppMessage>, Option>) = - if three_col { - let card = container( - column![ - text("Playlist").size(18).color(color_blue), - vertical_space(8.0), - scrollable(music_panel) - .width(iced::Length::Fill) - .height(iced::Length::Fill), - ] - ) - .style(c_style(color_mantle, color_surface, 8.0)) - .padding(12) - .width(iced::Length::Fill) - .height(iced::Length::Fill); - (column![].into(), Some(card.into())) - } else { - ( - column![ - vertical_space(20.0), - text("Playlist").size(18).color(color_blue), - music_panel, - ] - .into(), - None, - ) - }; + let music_bar_status = status_snapshot(&state.music_status); + let music_bar_playing = music_bar_status.playing_id.is_some(); + let music_bar_play_label = if music_bar_playing && music_bar_status.paused { + "▶" + } else if music_bar_playing { + "⏸" + } else { + "▶" + }; + let listening_peer_name = state + .music_listening_to + .and_then(|peer| state.peers.get(&peer).map(|p| p.name.as_str())); + let current_track_name = state + .music_current + .and_then(|i| state.music_playlist.get(i).map(|track| track.name.as_str())); + let elapsed = format_clip_time(music_bar_status.position); + let duration = music_bar_status + .total + .map(format_clip_time) + .unwrap_or_else(|| "--:--".to_string()); + let player_bar = container( + row![ + text("♪").size(18).color(color_blue), + text(now_playing_label(listening_peer_name, current_track_name)) + .size(13) + .color(color_text) + .width(iced::Length::Fill), + button(text("⏮").size(13)) + .on_press(AppMessage::MusicPrev) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(7), + button(text(music_bar_play_label).size(13)) + .on_press(AppMessage::MusicPlayPause) + .style(b_style(color_blue, color_lavender, color_crust, 6.0)) + .padding(7), + button(text("⏭").size(13)) + .on_press(AppMessage::MusicNext) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(7), + text(format!("{elapsed} / {duration}")) + .size(11) + .color(color_subtext), + button(text(if state.playlist_drawer_open { "⤡" } else { "⤢" }).size(13)) + .on_press(AppMessage::TogglePlaylistDrawer) + .style(b_style( + if state.playlist_drawer_open { color_blue } else { color_surface }, + color_blue, + if state.playlist_drawer_open { color_crust } else { color_text }, + 6.0, + )) + .padding(7), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + ) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(10) + .width(iced::Length::Fill) + .height(iced::Length::Fixed(56.0)); let ctrl_buttons = column![ button(btn_content(mute_kind, mute_text, mute_fg)) .on_press(AppMessage::ToggleMutePressed) @@ -5798,7 +5869,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(14) .width(iced::Length::Fill) }, - ctrl_music, ]; // Leave is the exit control, so it's pinned below the scrolling controls @@ -6112,13 +6182,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fixed(DIVIDER_THICKNESS)) }; + let playlist_drawer = container( + column![ + text("Playlist").size(18).color(color_blue), + vertical_space(8.0), + scrollable(music_panel) + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ] + .spacing(0) + ) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(12) + .width(iced::Length::Fixed(state.config.playlist_drawer_width)) + .height(iced::Length::Fill); + + let drawer_open = state.playlist_drawer_open && state.config.show_player_bar; + let body_w = if drawer_open { + state.window_size.width - state.config.playlist_drawer_width - DIVIDER_THICKNESS + } else { + state.window_size.width + }; + // Assemble the body per the chosen room layout. `chat_inner` is moved into // exactly one arm (allowed across mutually-exclusive match arms). let pw = state.config.participants_width; let body: Element<'_, AppMessage> = match state.config.room_layout { RoomLayout::BottomDock => { // Cap Participants so the Fill Controls panel keeps its minimum. - let avail = state.window_size.width - 30.0; + let avail = body_w - 30.0; let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); let main = row![ peers_panel.width(iced::Length::Fixed(pwb)), @@ -6135,7 +6227,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { column![main, hdiv(DividerKind::Chat), chat].into() } RoomLayout::ThreeColumn => { - let avail = state.window_size.width - 30.0; // outer padding + let avail = body_w - 30.0; // outer padding let pw3 = pw.min( (avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS) .max(PARTICIPANTS_MIN_W), @@ -6145,19 +6237,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(12) .width(iced::Length::Fill) .height(iced::Length::Fill); - // The Playlist card was built above iff this is the 3-column layout. - let card = playlist_card.expect("playlist_card is Some for ThreeColumn"); - let middle = column![ - chat, - hdiv(DividerKind::ThreeColPlaylist), - container(card).height(iced::Length::Fixed(state.config.threecol_playlist_height)), - ] - .width(iced::Length::Fill) - .height(iced::Length::Fill); row![ peers_panel.width(iced::Length::Fixed(pw3)), vdiv(DividerKind::Panels), - middle, + chat, vdiv(DividerKind::Controls), control_panel.width(iced::Length::Fixed(state.config.controls_width)), ] @@ -6166,7 +6249,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() } RoomLayout::Drawer => { - let avail = state.window_size.width - 30.0; + let avail = body_w - 30.0; if state.drawer_chat_open { // Participants + Chat drawer are both fixed; cap Participants so // the Fill Controls panel between them keeps its minimum. @@ -6238,9 +6321,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { iced::widget::Space::new().width(0.0).height(0.0).into() }; - let room = container( - column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body] - ) + let main_area = column![ + header_container, + clock_skew_banner, + vertical_space(12.0), + body, + ]; + let main_with_bar: Element<'_, AppMessage> = if state.config.show_player_bar { + column![main_area.height(iced::Length::Fill), player_bar].into() + } else { + main_area.into() + }; + let inner: Element<'_, AppMessage> = if drawer_open { + row![ + column![main_with_bar].width(iced::Length::Fill), + vdiv(DividerKind::PlaylistDrawer), + playlist_drawer, + ] + .height(iced::Length::Fill) + .into() + } else { + main_with_bar + }; + + let room = container(column![top_bar, inner]) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) @@ -7653,8 +7757,8 @@ impl Program for Icon { mod tests { use super::{ attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration, - format_duration, format_relative_ago, initial_window_position, reconnect_attempt_chime, reconnected_chime, - set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, + format_duration, format_relative_ago, initial_window_position, now_playing_label, + reconnect_attempt_chime, reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS, }; @@ -8330,6 +8434,40 @@ mod tests { assert!(d.is_finite() && d >= CHAT_MIN_W); } + #[test] + fn playlist_drawer_width_clamps() { + use super::{clamp_playlist_drawer_width, CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W}; + let window_w = 1000.0; + let max = window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W; + // Mid-range passes through. + assert_eq!(clamp_playlist_drawer_width(320.0, window_w), 320.0); + // Below minimum snaps up. + assert_eq!(clamp_playlist_drawer_width(10.0, window_w), CHAT_MIN_W); + // Above maximum leaves the rest of the room at its reserved width. + assert_eq!(clamp_playlist_drawer_width(800.0, window_w), max); + // Tiny window stays finite and at/above the minimum (no inverted range). + let d = clamp_playlist_drawer_width(400.0, 100.0); + assert!(d.is_finite() && d >= CHAT_MIN_W); + } + + #[test] + fn now_playing_label_prefers_tuned_peer() { + assert_eq!( + now_playing_label(Some("Alice"), Some("local.flac")), + "Tuned in to Alice" + ); + } + + #[test] + fn now_playing_label_uses_current_track_when_local() { + assert_eq!(now_playing_label(None, Some("local.flac")), "local.flac"); + } + + #[test] + fn now_playing_label_handles_empty_player() { + assert_eq!(now_playing_label(None, None), "Nothing playing"); + } + use crate::notify::Sound; use iroh::EndpointId; use std::collections::HashSet; diff --git a/src/config.rs b/src/config.rs index b60a5a9..602d6c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -138,6 +138,10 @@ fn default_chat_drawer_width() -> f32 { 320.0 } +fn default_playlist_drawer_width() -> f32 { + 320.0 +} + fn default_window_width() -> f32 { 900.0 } @@ -174,6 +178,9 @@ pub struct AppConfig { /// W22 music: opt-in shared listening broadcast toggle. Local preference. #[serde(default)] pub music_broadcast: bool, + /// Show the slim now-playing player bar in the room screen. + #[serde(default = "default_true")] + pub show_player_bar: bool, /// When true, `clip_volume` governs every clip. When false, each clip keeps /// its own (in-memory) level and the universal slider is inactive. #[serde(default = "default_true")] @@ -207,6 +214,9 @@ pub struct AppConfig { /// Chat drawer width for the drawer layout (px). #[serde(default = "default_chat_drawer_width")] pub chat_drawer_width: f32, + /// Playlist drawer width for the room-screen right-edge music panel (px). + #[serde(default = "default_playlist_drawer_width")] + pub playlist_drawer_width: f32, /// Chosen arrangement of the in-call room screen. #[serde(default)] pub room_layout: RoomLayout, @@ -348,6 +358,7 @@ impl Default for AppConfig { music_playlist: Vec::new(), music_volume: 1.0, music_broadcast: false, + show_player_bar: true, clip_volume_universal: true, network_mode: NetworkMode::default(), presence_mode: crate::presence::PresenceMode::default(), @@ -358,6 +369,7 @@ impl Default for AppConfig { threecol_playlist_height: default_threecol_playlist_height(), controls_width: default_controls_width(), chat_drawer_width: default_chat_drawer_width(), + playlist_drawer_width: default_playlist_drawer_width(), room_layout: RoomLayout::default(), theme: AppTheme::default(), avatar: crate::avatar::Avatar::default(), @@ -517,6 +529,8 @@ mod tests { assert_eq!(deserialized.room_layout, RoomLayout::BottomDock); assert_eq!(deserialized.controls_width, 280.0); assert_eq!(deserialized.chat_drawer_width, 320.0); + assert_eq!(deserialized.playlist_drawer_width, 320.0); + assert!(deserialized.show_player_bar); assert!(deserialized.custom_sound_self_join.is_none()); assert!(deserialized.custom_sound_peer_join.is_none()); assert!(deserialized.custom_sound_peer_leave.is_none()); @@ -701,6 +715,7 @@ mod tests { assert!(def.music_playlist.is_empty()); assert_eq!(def.music_volume, 1.0); assert!(!def.music_broadcast); + assert!(def.show_player_bar); assert!(def.clip_volume_universal); // Missing in JSON → unity (serde default). @@ -712,6 +727,7 @@ mod tests { assert!(cfg_missing.music_playlist.is_empty()); assert_eq!(cfg_missing.music_volume, 1.0); assert!(!cfg_missing.music_broadcast); + assert!(cfg_missing.show_player_bar); // Configs predating the toggle default to universal mode. assert!(cfg_missing.clip_volume_universal); @@ -723,6 +739,7 @@ mod tests { music_playlist: vec!["/tmp/song.ogg".to_string()], music_volume: 0.6, music_broadcast: true, + show_player_bar: false, clip_volume_universal: false, ..AppConfig::default() }; @@ -734,6 +751,7 @@ mod tests { assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); assert_eq!(round_tripped.music_volume, 0.6); assert!(round_tripped.music_broadcast); + assert!(!round_tripped.show_player_bar); assert!(!round_tripped.clip_volume_universal); }