use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Alignment, Length, Rectangle, mouse}; use cosmic::widget::{button, column, container, image, mouse_area, row, scrollable, slider}; use cosmic::{Element, theme}; use ferese_ipc::media::Playback; use ferese_theme::icons; use serde_json::json; use super::{Menu, MenuRows, MenuStyle}; use crate::{ BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, }; const SEEK_THUMB_DIAMETER: f32 = 10.0; fn seek_slider_style(style: MenuStyle) -> cosmic::theme::iced::Slider { let paint = std::rc::Rc::new(move |_: &cosmic::Theme| { use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style}; let accent = color_with_opacity(style.theme.accent, style.opacity); Style { rail: Rail { backgrounds: (accent.into(), style.muted.scale_alpha(0.10).into()), width: 4.0, border: cosmic::iced::Border { shape: BorderShape::Circular, radius: 2.0.into(), ..Default::default() }, }, handle: Handle { corner_shape: BorderShape::Circular, shape: HandleShape::Circle { radius: SEEK_THUMB_DIAMETER / 2.0, }, background: accent.into(), border_width: 0.0, border_color: cosmic::iced::Color::TRANSPARENT, }, breakpoint: Breakpoint { color: cosmic::iced::Color::TRANSPARENT, }, } }); cosmic::theme::iced::Slider::Custom { active: paint.clone(), hovered: paint.clone(), dragging: paint, } } fn seek_pill<'a>( content: impl Into>>, style: MenuStyle, ) -> Element<'a, cosmic::Action> { container(content) .width(Length::Fill) .padding([2, 0]) .class(theme::Container::custom(move |_| container::Style { background: Some(color_with_opacity(style.theme.border, style.opacity).into()), border: cosmic::iced::Border { shape: BorderShape::Circular, radius: 12.0.into(), ..Default::default() }, ..Default::default() })) .into() } pub(super) fn bar<'a>( shell: &'a FereseShell, palette: ShellTheme, metrics: BarMetrics, representation: crate::panel::Representation, selected: bool, width: Option, ) -> Element<'a, cosmic::Action> { let Some(player) = &shell.media.snapshot.selected else { return shell.render.text("").into(); }; let foreground = color(if selected { palette.accent } else { palette.text_primary }); let vertical_padding = ((metrics.group_item_height - f32::from(metrics.icon_size)) * 0.5).clamp(0.0, 2.0); let height = metrics.group_item_height - 2.0 * vertical_padding; let side = height.min(24.0); let cover: Element<'_, cosmic::Action> = match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { Some(handle) => image(handle.clone()) .width(side) .height(side) .content_fit(cosmic::iced::ContentFit::Cover) .border_radius(shell.render.radius(6.0)) .shape(BorderShape::Continuous) .into(), None => container(accented_icon( icons::MEDIA, side as u16, foreground, color(palette.accent), )) .center_x(side) .center_y(side) .width(side) .into(), }; let label = shell .render .text(player.label()) .size(metrics.text_size) .wrapping(cosmic::iced::widget::text::Wrapping::None) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )) .width(Length::Shrink) .height(height) .align_y(cosmic::iced::alignment::Vertical::Center) .class(theme::Text::Color(foreground)); let compact = representation == crate::panel::Representation::Compact; let preferred_label_width: f32 = if compact { 112. } else { 320. }; // Outer padding, the transport button, details padding, and cover spacing // share the allocation with the label. let chrome = 4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. }; let label_width = width.map_or(preferred_label_width, |width| { (width - chrome).max(0.).min(preferred_label_width) }); let label = container(label).max_width(label_width); let details = button::custom(bar_content( if representation == crate::panel::Representation::Compact { row![label].align_y(Alignment::Center) } else { row![cover, label].spacing(8).align_y(Alignment::Center) }, height, )) .name(format!("Now playing: {}", player.label())) .padding([0, 4]) .height(height) .on_press_with_rectangle(move |offset, bounds| { cosmic::Action::App(Message::OpenMenu( Menu::Media, Rectangle { x: (bounds.x - offset.x).round() as i32, y: (bounds.y - offset.y).round() as i32, width: bounds.width.round() as i32, height: bounds.height.round() as i32, }, )) }); let playing = player.status == Playback::Playing; let mut toggle = button::custom(bar_content( accented_icon( if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, metrics.icon_size, foreground, color(palette.accent), ), height, )) .name(if playing { "Pause" } else { "Play" }) .height(height) .padding([0, 6]); if player.can_toggle() { toggle = toggle.on_press(cosmic::Action::App(Message::MediaAction( shell.media.action("play-pause"), ))); } let bar_style = ferese_theme::controls::surface_appearance( color_with_opacity(palette.border, 0.30), palette.material_radius.min(16.), ); let controls = container( row![ motion::button(shell.render, details, foreground, false, 1.0), motion::button(shell.render, toggle, foreground, false, 1.0) ] .spacing(2) .align_y(Alignment::Center), ) .padding([vertical_padding, 2.0]) .height(metrics.group_item_height) .align_y(cosmic::iced::alignment::Vertical::Center) .class(theme::Container::custom(move |_| bar_style)); let mut area = mouse_area(controls); if player.can_raise { area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(shell.media.action("raise")))); } if player.can_control && player.volume.is_some() { let action = shell.media.action("volume"); area = area.on_scroll(move |delta| { let mut action = action.clone(); action["delta"] = json!(volume_delta(delta)); cosmic::Action::App(Message::MediaAction(action)) }); } area.into() } fn volume_delta(delta: mouse::ScrollDelta) -> f64 { let delta = match delta { mouse::ScrollDelta::Lines { y, .. } => f64::from(y) * 0.05, mouse::ScrollDelta::Pixels { y, .. } => f64::from(y) * 0.001, }; if delta.is_finite() { delta.clamp(-1.0, 1.0) } else { 0.0 } } fn transport<'a>( shell: &'a FereseShell, source: &'static [u8], label: &'static str, action: &str, enabled: bool, style: MenuStyle, ) -> Element<'a, cosmic::Action> { transport_sized(shell, source, label, action, enabled, style, 44.) } fn transport_sized<'a>( shell: &'a FereseShell, source: &'static [u8], label: &'static str, action: &str, enabled: bool, style: MenuStyle, size: f32, ) -> Element<'a, cosmic::Action> { let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted }); let foreground = match fill { Some(fill) => cosmic::iced::Color { a: fill.a, ..ferese_theme::foreground(fill, color(style.theme.surface_popover)) }, None => { if enabled { style.primary } else { style.muted } } }; let mut control = button::custom( container( accented_icon( source, (size - 20.) as u16, cosmic::iced::Color { a: 1., ..foreground }, color(style.theme.accent), ) .opacity(foreground.a), ) .center_x(size - 20.) .center_y(size - 20.), ) .padding(10) .width(size) .height(size) .name(label); if enabled { control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action)))); } motion::circular_button(shell.render, control, foreground, fill, style.opacity, size) } pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { let Some(player) = &shell.media.snapshot.selected else { return rows.push( shell .render .text("No active player") .size(14) .class(theme::Text::Color(style.muted)), ); }; let cover: Element<'_, cosmic::Action> = match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { Some(handle) => image(handle.clone()) .width(64) .height(64) .opacity(style.opacity) .content_fit(cosmic::iced::ContentFit::Cover) .border_radius(10.) .into(), None => container( accented_icon( icons::MEDIA, 26, color(style.theme.text_primary), color(style.theme.accent), ) .opacity(style.opacity), ) .center_x(64) .center_y(64) .class(theme::Container::custom(move |_| { ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.) })) .into(), }; let mut info = column![ shell .render .text(player.label()) .size(14) .class(theme::Text::Color(style.primary)) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1) )), shell .render .text(&player.artist) .size(12) .class(theme::Text::Color(style.muted)) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1) )) ] .spacing(4) .width(Length::Fill); if let (Some(length), Some(position)) = ( player.length_us.filter(|length| *length > 0), player.position_at(shell.media.now_us), ) { let value = (shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64 * 10_000.).round() as u32; if player.can_control && player.can_seek && player.track_id.is_some() { info = info.push( slider(0..=10_000_u32, value, move |value| { cosmic::Action::App(Message::MediaSeek(seek_position(length, value))) }) .on_release(cosmic::Action::App(Message::MediaSeekCommit)) .height(16) .handle_width(SEEK_THUMB_DIAMETER) .class(seek_slider_style(style)) .width(Length::Fill), ); } } let rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center)); let playing = player.status == Playback::Playing; rows.push( container( row![ transport_sized( shell, icons::MEDIA_PREVIOUS, "Previous track", "previous", player.can_control && player.can_previous, style, 36. ), transport_sized( shell, if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, if playing { "Pause" } else { "Play" }, "play-pause", player.can_toggle(), style, 36. ), transport_sized( shell, icons::MEDIA_NEXT, "Next track", "next", player.can_control && player.can_next, style, 36. ), ] .spacing(8), ) .center_x(Length::Fill), ) } pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { if let Some(player) = &shell.media.snapshot.selected { let cover: Element<'_, cosmic::Action> = match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) { Some(handle) => image(handle.clone()) .width(64) .height(64) .content_fit(cosmic::iced::ContentFit::Contain) .border_radius(shell.render.radius(12.0)) .shape(BorderShape::Continuous) .into(), None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent))) .center_x(64) .center_y(64) .into(), }; let text_size = if player.label().len() >= 24 { 13 } else { 15 }; let mut info = column![ shell .render .text(player.label()) .size(text_size) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2) )) ] .spacing(4) .width(Length::Fill); if !player.artist.is_empty() { info = info.push( shell .render .text(&player.artist) .size(12) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )) .class(theme::Text::Color(style.muted)), ); } if !player.album.is_empty() { info = info.push( shell .render .text(&player.album) .size(11) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )) .class(theme::Text::Color(style.muted)), ); } rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center)); let mut playback = column::with_capacity(2).spacing(6).width(Length::Fill); if let (Some(length), Some(position)) = ( player.length_us.filter(|length| *length > 0), player.position_at(shell.media.now_us), ) { let position = shell.media.seeking.unwrap_or(position).min(length); let mut progress = column::with_capacity(2).spacing(2); if player.can_control && player.can_seek && player.track_id.is_some() { let value = (position as f64 / length as f64 * 10_000.0).round() as u32; progress = progress.push(seek_pill( container( slider(0..=10_000_u32, value, move |value| { cosmic::Action::App(Message::MediaSeek(seek_position(length, value))) }) .on_release(cosmic::Action::App(Message::MediaSeekCommit)) .handle_width(SEEK_THUMB_DIAMETER) .height(20) .class(seek_slider_style(style)) .width(Length::Fill), ) // Extend the native slider's layout by its reserved thumb // radius so its rail and pointer mapping reach both edges. .padding(cosmic::iced::Padding { left: -SEEK_THUMB_DIAMETER / 2.0, right: -SEEK_THUMB_DIAMETER / 2.0, ..Default::default() }), style, )); } else { progress = progress.push(seek_pill( container( cosmic::iced::widget::ProgressBar::new(0.0..=1.0, (position as f64 / length as f64) as f32) .girth(4) .length(Length::Fill) .class(cosmic::theme::iced::ProgressBar::custom(move |_| { cosmic::iced::widget::progress_bar::Style { background: style.muted.scale_alpha(0.35).into(), bar: color_with_opacity(style.theme.accent, style.opacity).into(), border: cosmic::iced::Border { shape: BorderShape::Circular, radius: 2.0.into(), ..Default::default() }, } })), ) .center_y(20), style, )); } progress = progress.push( row![ shell .render .text(time_label(position)) .font(cosmic::iced::Font::MONOSPACE) .size(11) .class(theme::Text::Color(style.muted)) .width(Length::Fill), shell .render .text(time_label(length)) .font(cosmic::iced::Font::MONOSPACE) .size(11) .class(theme::Text::Color(style.muted)) ] .spacing(12), ); playback = playback.push(progress); } let playing = player.status == Playback::Playing; playback = playback.push( container( row![ transport( shell, icons::MEDIA_PREVIOUS, "Previous track", "previous", player.can_control && player.can_previous, style ), transport( shell, if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY }, if playing { "Pause" } else { "Play" }, "play-pause", player.can_toggle(), style ), transport( shell, icons::MEDIA_NEXT, "Next track", "next", player.can_control && player.can_next, style ), ] .spacing(12), ) .center_x(Length::Fill), ); rows = rows.push(playback); let label = if shell.media.snapshot.pinned.is_some() { format!("{} ยท pinned", player.identity) } else { player.identity.clone() }; rows = rows.push(motion::button( shell.render, button::custom(shell.render.text(label).size(12)) .padding([6, 8]) .on_press(cosmic::Action::App(Message::MediaChoose)), style.primary, false, style.opacity, )); } else { rows = rows.push( shell .render .text("No active player") .size(13) .class(theme::Text::Color(style.muted)), ); } if shell.media.choosing || shell.media.snapshot.selected.is_none() { let mut choices = column::with_capacity(shell.media.snapshot.players.len() + 1).spacing(4); choices = choices.push(super::controls::menu_button( shell.render, "Choose automatically", Message::MediaAction(json!({"action":"auto"})), style.primary, style.opacity, )); for player in &shell.media.snapshot.players { let args = json!({"player":player.name,"owner":player.owner}); let mut pin = args.clone(); pin["action"] = json!("pin"); let mut ignore = args; ignore["action"] = json!("ignore"); ignore["ignored"] = json!(!player.ignored); let label = if player.identity.is_empty() { &player.name } else { &player.identity }; choices = choices.push( row![ super::controls::menu_button( shell.render, label, Message::MediaAction(pin), if player.ignored { style.muted } else { style.primary }, style.opacity ), super::controls::menu_button( shell.render, if player.ignored { "Show" } else { "Ignore" }, Message::MediaAction(ignore), style.muted, style.opacity ), ] .spacing(6), ); } rows = rows.push(scrollable(choices).height(Length::Fixed(140.0))); } if let Some(error) = &shell.media.error { rows = rows.push(shell.render.text(error).size(12).class(theme::Text::Color(style.muted))); } rows } fn seek_position(length: u64, value: u32) -> u64 { (u128::from(length) * u128::from(value.min(10_000)) / 10_000) as u64 } fn time_label(us: u64) -> String { let seconds = us / 1_000_000; format!("{}:{:02}", seconds / 60, seconds % 60) } #[cfg(test)] mod tests { use super::*; #[test] fn seeking_handles_short_tracks_and_large_durations() { assert_eq!(seek_position(500_000, 5000), 250_000); assert_eq!(seek_position(500_000, 10_000), 500_000); assert_eq!(seek_position(u64::MAX, 10_000), u64::MAX); assert_eq!(seek_position(1, 100_000), 1); } #[test] fn wheel_changes_are_bounded_and_pixel_scrolling_is_not_a_full_step() { assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }), 0.05); assert_eq!(volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }), 0.001); assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: f32::NAN }), 0.0); assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }), 1.0); } }