feat(bar): enhancing the media widget

The Media widget has been enhanced with a new MediaDisplayFormat
configuration option that controls how the widget is displayed. It
supports seven formats: Icon, Text, IconAndText (the default),
ControlsOnly, IconAndControls, TextAndControls, and Full.

The widget now detects whether the Previous and Next buttons are
actually available for the current media session using the Windows Media
Control API. When a button is not available, it appears dimmed at 50
percent opacity and clicking it has no effect.

Tooltips were added to improve usability. Hovering over the media info
label shows the full artist and title text, which is helpful when the
text is truncated. The Play/Pause button also shows the media info on
hover.

The rendering logic was refactored to properly handle right-aligned
widgets.

When the Media widget is placed in right_widgets, the UI renders items
from right to left, so the code now renders elements in reverse order to
ensure the visual appearance remains consistent regardless of which
panel the widget is placed in.
This commit is contained in:
Csaba
2026-02-04 08:56:35 +01:00
committed by LGUG2Z
parent 5d7a0ea9ad
commit 9977cca500
3 changed files with 297 additions and 16 deletions

View File

@@ -621,6 +621,26 @@ extend_enum!(
AllIconsAndTextOnSelected,
});
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Media widget display format
pub enum MediaDisplayFormat {
/// Show only the media info icon
Icon,
/// Show only the media info text (artist - title)
Text,
/// Show both icon and text
IconAndText,
/// Show only the control buttons (previous, play/pause, next)
ControlsOnly,
/// Show icon with control buttons
IconAndControls,
/// Show text with control buttons
TextAndControls,
/// Show icon, text, and control buttons
Full,
}
#[cfg(test)]
mod tests {
use serde::Deserialize;

View File

@@ -1,4 +1,6 @@
use crate::MAX_LABEL_WIDTH;
use crate::bar::Alignment;
use crate::config::MediaDisplayFormat;
use crate::render::RenderConfig;
use crate::selected_frame::SelectableFrame;
use crate::ui::CustomUi;
@@ -14,6 +16,7 @@ use serde::Deserialize;
use serde::Serialize;
use std::sync::atomic::Ordering;
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@@ -21,24 +24,31 @@ use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
pub struct MediaConfig {
/// Enable the Media widget
pub enable: bool,
/// Display format of the media widget (defaults to IconAndText)
pub display: Option<MediaDisplayFormat>,
}
impl From<MediaConfig> for Media {
fn from(value: MediaConfig) -> Self {
Self::new(value.enable)
Self::new(
value.enable,
value.display.unwrap_or(MediaDisplayFormat::IconAndText),
)
}
}
#[derive(Clone, Debug)]
pub struct Media {
pub enable: bool,
pub display: MediaDisplayFormat,
pub session_manager: GlobalSystemMediaTransportControlsSessionManager,
}
impl Media {
pub fn new(enable: bool) -> Self {
pub fn new(enable: bool, display: MediaDisplayFormat) -> Self {
Self {
enable,
display,
session_manager: GlobalSystemMediaTransportControlsSessionManager::RequestAsync()
.unwrap()
.join()
@@ -54,6 +64,58 @@ impl Media {
}
}
pub fn previous(&self) {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(op) = session.TrySkipPreviousAsync()
{
op.join().unwrap_or_default();
}
}
pub fn next(&self) {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(op) = session.TrySkipNextAsync()
{
op.join().unwrap_or_default();
}
}
fn is_playing(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(status) = info.PlaybackStatus()
{
return status == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing;
}
false
}
fn is_previous_enabled(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(controls) = info.Controls()
&& let Ok(enabled) = controls.IsPreviousEnabled()
{
return enabled;
}
false
}
fn is_next_enabled(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(controls) = info.Controls()
&& let Ok(enabled) = controls.IsNextEnabled()
{
return enabled;
}
false
}
fn has_session(&self) -> bool {
self.session_manager.GetCurrentSession().is_ok()
}
fn output(&mut self) -> String {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(operation) = session.TryGetMediaPropertiesAsync()
@@ -78,28 +140,96 @@ impl Media {
impl BarWidget for Media {
fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable {
// Don't render if there's no active media session
if !self.has_session() {
return;
}
let output = self.output();
if !output.is_empty() {
let mut layout_job = LayoutJob::simple(
let show_icon = matches!(
self.display,
MediaDisplayFormat::Icon
| MediaDisplayFormat::IconAndText
| MediaDisplayFormat::IconAndControls
| MediaDisplayFormat::Full
);
let show_text = matches!(
self.display,
MediaDisplayFormat::Text
| MediaDisplayFormat::IconAndText
| MediaDisplayFormat::TextAndControls
| MediaDisplayFormat::Full
);
let show_controls = matches!(
self.display,
MediaDisplayFormat::ControlsOnly
| MediaDisplayFormat::IconAndControls
| MediaDisplayFormat::TextAndControls
| MediaDisplayFormat::Full
);
// Don't render if there's no media info and we're not showing controls-only
if output.is_empty() && !show_controls {
return;
}
let icon_font_id = config.icon_font_id.clone();
let text_font_id = config.text_font_id.clone();
let icon_color = ctx.style().visuals.selection.stroke.color;
let text_color = ctx.style().visuals.text_color();
let mut layout_job = LayoutJob::default();
if show_icon {
layout_job = LayoutJob::simple(
egui_phosphor::regular::HEADPHONES.to_string(),
config.icon_font_id.clone(),
ctx.style().visuals.selection.stroke.color,
icon_font_id.clone(),
icon_color,
100.0,
);
}
if show_text {
layout_job.append(
&output,
10.0,
if show_icon { 10.0 } else { 0.0 },
TextFormat {
font_id: config.text_font_id.clone(),
color: ctx.style().visuals.text_color(),
font_id: text_font_id,
color: text_color,
valign: Align::Center,
..Default::default()
},
);
}
config.apply_on_widget(false, ui, |ui| {
if SelectableFrame::new(false)
let is_playing = self.is_playing();
let is_previous_enabled = self.is_previous_enabled();
let is_next_enabled = self.is_next_enabled();
let disabled_color = text_color.gamma_multiply(0.5);
let is_reversed = matches!(config.alignment, Some(Alignment::Right));
let prev_color = if is_previous_enabled {
text_color
} else {
disabled_color
};
let next_color = if is_next_enabled {
text_color
} else {
disabled_color
};
let play_pause_icon = if is_playing {
egui_phosphor::regular::PAUSE
} else {
egui_phosphor::regular::PLAY
};
let show_label = |ui: &mut Ui| {
if (show_icon || show_text)
&& SelectableFrame::new(false)
.show(ui, |ui| {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
@@ -109,15 +239,95 @@ impl BarWidget for Media {
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(layout_job).selectable(false).truncate(),
Label::new(layout_job.clone()).selectable(false).truncate(),
)
})
.on_hover_text(&output)
.clicked()
{
self.toggle();
{
self.toggle();
}
};
let show_previous = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
egui_phosphor::regular::SKIP_BACK.to_string(),
icon_font_id.clone(),
prev_color,
100.0,
))
.selectable(false),
)
})
.clicked()
&& is_previous_enabled
{
self.previous();
}
};
let show_play_pause = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
play_pause_icon.to_string(),
icon_font_id.clone(),
text_color,
100.0,
))
.selectable(false),
)
})
.on_hover_text(&output)
.clicked()
{
self.toggle();
}
};
let show_next = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
egui_phosphor::regular::SKIP_FORWARD.to_string(),
icon_font_id.clone(),
next_color,
100.0,
))
.selectable(false),
)
})
.clicked()
&& is_next_enabled
{
self.next();
}
};
config.apply_on_widget(false, ui, |ui| {
if is_reversed {
// Right panel renders right-to-left, so reverse order
if show_controls {
show_next(ui);
show_play_pause(ui);
show_previous(ui);
}
});
}
show_label(ui);
} else {
// Left/center panel renders left-to-right, normal order
show_label(ui);
if show_controls {
show_previous(ui);
show_play_pause(ui);
show_next(ui);
}
}
});
}
}
}

View File

@@ -3863,6 +3863,17 @@
"description": "Media widget configuration",
"type": "object",
"properties": {
"display": {
"description": "Display format of the media widget (defaults to IconAndText)",
"anyOf": [
{
"$ref": "#/$defs/MediaDisplayFormat"
},
{
"type": "null"
}
]
},
"enable": {
"description": "Enable the Media widget",
"type": "boolean"
@@ -3872,6 +3883,46 @@
"enable"
]
},
"MediaDisplayFormat": {
"description": "Media widget display format",
"oneOf": [
{
"description": "Show only the media info icon",
"type": "string",
"const": "Icon"
},
{
"description": "Show only the media info text (artist - title)",
"type": "string",
"const": "Text"
},
{
"description": "Show both icon and text",
"type": "string",
"const": "IconAndText"
},
{
"description": "Show only the control buttons (previous, play/pause, next)",
"type": "string",
"const": "ControlsOnly"
},
{
"description": "Show icon with control buttons",
"type": "string",
"const": "IconAndControls"
},
{
"description": "Show text with control buttons",
"type": "string",
"const": "TextAndControls"
},
{
"description": "Show icon, text, and control buttons",
"type": "string",
"const": "Full"
}
]
},
"MemoryConfig": {
"description": "Memory widget configuration",
"type": "object",