LibWeb: Use UA shadow DOM for media elements' controls

Instead of using a custom paintable to draw the controls for video and
audio elements, we build them out of plain old HTML elements within a
shadow root.

This required a few hacks in the previous commits in order to allow a
replaced element to host children within a shadow root, but it's
fairly self-contained.

A big benefit is that we can drive all the UI updates off of plain old
DOM events (except the play button overlay on videos, which uses the
video element representation), so we can test our media and input event
handling more thoroughly. :^)

The control bar visibility is now more similar to how other browsers
handle it. It will show upon hovering over the element, but if the
cursor is kept still for more than a second, it will hide again. While
dragging, the controls remain visible, and will then hide after the
mouse button is released.

The icons have been redesigned from scratch, and the mute icon now
visualizes the volume level along with indicating the mute state.
This commit is contained in:
Zaggy1024 2026-02-21 00:14:45 -06:00 committed by Alexander Kalenik
parent bacf689c88
commit 21019c2fa9
21 changed files with 1124 additions and 846 deletions

View file

@ -547,6 +547,7 @@ set(SOURCES
HTML/HTMLMapElement.cpp
HTML/HTMLMarqueeElement.cpp
HTML/HTMLMediaElement.cpp
HTML/MediaControls.cpp
HTML/HTMLMenuElement.cpp
HTML/HTMLMetaElement.cpp
HTML/HTMLMeterElement.cpp
@ -818,7 +819,6 @@ set(SOURCES
Page/InputEvent.cpp
Page/Page.cpp
Painting/AccumulatedVisualContext.cpp
Painting/AudioPaintable.cpp
Painting/BackgroundPainting.cpp
Painting/BackingStoreManager.cpp
Painting/Blending.cpp
@ -840,7 +840,6 @@ set(SOURCES
Painting/ImagePaintable.cpp
Painting/LabelablePaintable.cpp
Painting/MarkerPaintable.cpp
Painting/MediaPaintable.cpp
Painting/NavigableContainerViewportPaintable.cpp
Painting/Paintable.cpp
Painting/PaintableBox.cpp
@ -1182,6 +1181,7 @@ generate_html_implementation()
set(GENERATED_SOURCES
ARIA/AriaRoles.cpp
CSS/DefaultStyleSheetSource.cpp
CSS/MediaControlsStyleSheetSource.cpp
CSS/DescriptorID.cpp
CSS/Enums.cpp
CSS/EnvironmentVariable.cpp

View file

@ -729,6 +729,10 @@ video {
object-fit: contain;
}
audio {
width: 300px;
}
/* 15.4.3 Attributes for embedded content and images
* https://html.spec.whatwg.org/multipage/rendering.html#attributes-for-embedded-content-and-images
*/

View file

@ -96,6 +96,11 @@ void HTMLMediaElement::adjust_computed_style(CSS::ComputedProperties& style)
// https://drafts.csswg.org/css-display-3/#unbox
if (style.display().is_contents())
style.set_property(CSS::PropertyID::Display, CSS::DisplayStyleValue::create(CSS::Display::from_short(CSS::Display::Short::None)));
// AD-HOC: We rewrite `display: inline` to `display: inline-block`.
// This is required for the internal shadow tree to work correctly in layout.
if (style.display().is_inline_outside() && style.display().is_flow_inside())
style.set_property(CSS::PropertyID::Display, CSS::DisplayStyleValue::create(CSS::Display::from_short(CSS::Display::Short::InlineBlock)));
}
// https://html.spec.whatwg.org/multipage/media.html#queue-a-media-element-task
@ -127,6 +132,11 @@ void HTMLMediaElement::attribute_changed(FlyString const& name, Optional<String>
load_element().release_value_but_fixme_should_propagate_errors();
} else if (name == HTML::AttributeNames::crossorigin) {
m_crossorigin = cors_setting_attribute_from_keyword(value);
} else if (name == HTML::AttributeNames::controls) {
if (value.has_value() || is_scripting_disabled())
create_controls();
else
destroy_controls();
}
}
@ -472,9 +482,6 @@ void HTMLMediaElement::volume_or_muted_attribute_changed()
// FIXME: Then, if the media element is not allowed to play, the user agent must run the internal pause steps for the media element.
if (auto* paintable = this->paintable())
paintable->set_needs_display();
update_volume();
}
@ -1260,10 +1267,6 @@ Painting::ExternalContentSource& HTMLMediaElement::ensure_external_content_sourc
void HTMLMediaElement::set_selected_video_track(Badge<VideoTrack>, GC::Ptr<HTML::VideoTrack> video_track)
{
set_needs_style_update(true);
if (auto layout_node = this->layout_node())
layout_node->set_needs_layout_update(DOM::SetNeedsLayoutReason::HTMLVideoElementSetVideoTrack);
if (m_selected_video_track) {
VERIFY(m_selected_video_track_sink);
m_playback_manager->remove_the_displaying_video_sink_for_track(m_selected_video_track->track_in_playback_manager());
@ -1282,12 +1285,12 @@ void HTMLMediaElement::update_video_frame_and_timeline()
if (!m_playback_manager)
return;
auto new_frame_available = false;
if (m_selected_video_track_sink) {
auto sink_update_result = m_selected_video_track_sink->update();
if (sink_update_result == Media::DisplayingVideoSinkUpdateResult::NewFrameAvailable) {
ensure_external_content_source().update(m_selected_video_track_sink->current_frame());
new_frame_available = true;
if (paintable())
paintable()->set_needs_display();
}
}
@ -1296,21 +1299,9 @@ void HTMLMediaElement::update_video_frame_and_timeline()
if (seeking())
return;
auto needs_display_list_rebuild = false;
auto new_position = m_playback_manager->current_time().to_seconds_f64();
if (new_position != m_current_playback_position) {
if (new_position != m_current_playback_position)
set_current_playback_position(new_position);
needs_display_list_rebuild = true;
}
if (!paintable())
return;
if (needs_display_list_rebuild) {
paintable()->set_needs_display(InvalidateDisplayList::Yes);
} else if (new_frame_available) {
paintable()->set_needs_display(InvalidateDisplayList::No);
}
}
void HTMLMediaElement::on_audio_track_added(Media::Track const& track)
@ -1411,6 +1402,10 @@ void HTMLMediaElement::on_video_track_added(Media::Track const& track)
auto event = TrackEvent::create(realm, HTML::EventNames::addtrack, move(event_init));
m_video_tracks->dispatch_event(event);
// Update the VideoPaintable, so that it can potentially change representations based on the new video track count.
if (paintable())
paintable()->set_needs_display();
}
void HTMLMediaElement::on_metadata_parsed()
@ -2371,86 +2366,15 @@ void HTMLMediaElement::reject_pending_play_promises(ReadonlySpan<GC::Ref<WebIDL:
WebIDL::reject_promise(realm, promise, error);
}
bool HTMLMediaElement::handle_keydown(Badge<Web::EventHandler>, UIEvents::KeyCode key, u32 modifiers)
void HTMLMediaElement::create_controls()
{
if (modifiers != UIEvents::KeyModifier::Mod_None)
return false;
switch (key) {
case UIEvents::KeyCode::Key_Space:
toggle_playback();
break;
case UIEvents::KeyCode::Key_Home:
set_current_time(0);
break;
case UIEvents::KeyCode::Key_End:
set_current_time(duration());
break;
case UIEvents::KeyCode::Key_Left:
case UIEvents::KeyCode::Key_Right: {
static constexpr double time_skipped_per_key_press = 5.0;
auto current_time = this->current_time();
if (key == UIEvents::KeyCode::Key_Left)
current_time = max(0.0, current_time - time_skipped_per_key_press);
else
current_time = min(duration(), current_time + time_skipped_per_key_press);
set_current_time(current_time);
break;
}
case UIEvents::KeyCode::Key_Up:
case UIEvents::KeyCode::Key_Down: {
static constexpr double volume_change_per_key_press = 0.1;
auto volume = this->volume();
if (key == UIEvents::KeyCode::Key_Up)
volume = min(1.0, volume + volume_change_per_key_press);
else
volume = max(0.0, volume - volume_change_per_key_press);
// This should never fail since volume is clamped to 0.0..1.0 above.
MUST(set_volume(volume));
break;
}
case UIEvents::KeyCode::Key_M:
set_muted(!muted());
break;
default:
return false;
}
return true;
if (!m_controls.has_value())
m_controls.emplace(*this);
}
void HTMLMediaElement::set_layout_display_time(Badge<Painting::MediaPaintable>, Optional<double> display_time)
void HTMLMediaElement::destroy_controls()
{
if (display_time.has_value()) {
if (potentially_playing() && !m_tracking_mouse_position_while_playing) {
m_tracking_mouse_position_while_playing = true;
m_playback_manager->pause();
}
} else if (!display_time.has_value()) {
if (m_tracking_mouse_position_while_playing) {
m_tracking_mouse_position_while_playing = false;
m_playback_manager->play();
}
}
m_display_time = move(display_time);
if (auto* paintable = this->paintable())
paintable->set_needs_display();
}
double HTMLMediaElement::layout_display_time(Badge<Painting::MediaPaintable>) const
{
return m_display_time.value_or(current_time());
m_controls.clear();
}
}

View file

@ -19,9 +19,9 @@
#include <LibWeb/HTML/CORSSettingAttribute.h>
#include <LibWeb/HTML/EventLoop/Task.h>
#include <LibWeb/HTML/HTMLElement.h>
#include <LibWeb/HTML/MediaControls.h>
#include <LibWeb/Painting/ExternalContentSource.h>
#include <LibWeb/PixelUnits.h>
#include <LibWeb/UIEvents/KeyCode.h>
#include <LibWeb/WebIDL/DOMException.h>
namespace Web::HTML {
@ -144,36 +144,8 @@ public:
GC::Ref<TextTrack> add_text_track(Bindings::TextTrackKind kind, String const& label, String const& language);
bool handle_keydown(Badge<Web::EventHandler>, UIEvents::KeyCode, u32 modifiers);
enum class MediaComponent {
PlaybackButton,
SpeakerButton,
Timeline,
Volume,
};
void set_layout_mouse_tracking_component(Badge<Painting::MediaPaintable>, Optional<MediaComponent> mouse_tracking_component) { m_mouse_tracking_component = move(mouse_tracking_component); }
Optional<MediaComponent> const& layout_mouse_tracking_component(Badge<Painting::MediaPaintable>) const { return m_mouse_tracking_component; }
void set_layout_hovered_component(Badge<Painting::MediaPaintable>, Optional<MediaComponent> hovered_component) { m_hovered_component = hovered_component; }
Optional<MediaComponent> const& layout_hovered_component(Badge<Painting::MediaPaintable>) const { return m_hovered_component; }
void set_layout_mouse_position(Badge<Painting::MediaPaintable>, Optional<CSSPixelPoint> mouse_position) { m_mouse_position = move(mouse_position); }
Optional<CSSPixelPoint> const& layout_mouse_position(Badge<Painting::MediaPaintable>) const { return m_mouse_position; }
void set_layout_display_time(Badge<Painting::MediaPaintable>, Optional<double> display_time);
double layout_display_time(Badge<Painting::MediaPaintable>) const;
struct CachedLayoutBoxes {
Optional<CSSPixelRect> control_box_rect;
Optional<CSSPixelRect> playback_button_rect;
Optional<CSSPixelRect> timeline_rect;
Optional<CSSPixelRect> speaker_button_rect;
Optional<CSSPixelRect> volume_rect;
Optional<CSSPixelRect> volume_scrub_rect;
};
CachedLayoutBoxes& cached_layout_boxes(Badge<Painting::MediaPaintable>) const { return m_layout_boxes; }
void create_controls();
void destroy_controls();
CORSSettingAttribute crossorigin() const { return m_crossorigin; }
@ -366,13 +338,7 @@ private:
bool m_loop_was_specified_when_reaching_end_of_media_resource { false };
// Cached state for layout.
Optional<MediaComponent> m_mouse_tracking_component;
Optional<MediaComponent> m_hovered_component;
bool m_tracking_mouse_position_while_playing { false };
Optional<CSSPixelPoint> m_mouse_position;
Optional<double> m_display_time;
mutable CachedLayoutBoxes m_layout_boxes;
Optional<MediaControls> m_controls;
bool m_has_enabled_preferred_audio_track { false };
bool m_has_selected_preferred_video_track { false };

View file

@ -0,0 +1,704 @@
/*
* Copyright (c) 2026, Gregory Bertilson <gregory@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/NumberFormat.h>
#include <LibJS/Runtime/NativeFunction.h>
#include <LibWeb/CSS/CSSStyleProperties.h>
#include <LibWeb/CSS/PropertyID.h>
#include <LibWeb/DOM/DOMTokenList.h>
#include <LibWeb/DOM/ElementFactory.h>
#include <LibWeb/DOM/Event.h>
#include <LibWeb/DOM/IDLEventListener.h>
#include <LibWeb/DOM/ShadowRoot.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/HTML/AudioTrackList.h>
#include <LibWeb/HTML/EventNames.h>
#include <LibWeb/HTML/HTMLMediaElement.h>
#include <LibWeb/HTML/HTMLVideoElement.h>
#include <LibWeb/HTML/MediaControls.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Namespace.h>
#include <LibWeb/SVG/AttributeNames.h>
#include <LibWeb/SVG/TagNames.h>
#include <LibWeb/UIEvents/EventNames.h>
#include <LibWeb/UIEvents/KeyboardEvent.h>
#include <LibWeb/UIEvents/MouseEvent.h>
#include <LibWeb/WebIDL/CallbackType.h>
namespace Web::CSS {
extern String media_controls_stylesheet_source;
}
namespace Web::HTML {
MediaControls::MediaControls(HTMLMediaElement& media_element)
: m_media_element(media_element)
{
create_shadow_tree();
set_up_event_listeners();
}
MediaControls::~MediaControls()
{
remove_event_listeners();
if (auto media_element = m_media_element.ptr())
media_element->set_shadow_root(nullptr);
}
static GC::Ref<DOM::Element> create_html_element(DOM::Document& document, FlyString const& tag, StringView class_name = {})
{
auto element = MUST(DOM::create_element(document, tag, Namespace::HTML));
if (!class_name.is_empty())
element->set_attribute_value(HTML::AttributeNames::class_, String::from_utf8(class_name).release_value());
return element;
}
static GC::Ref<DOM::Element> create_svg_element(DOM::Document& document, FlyString const& tag, StringView class_name = {})
{
auto element = MUST(DOM::create_element(document, tag, Namespace::SVG));
if (!class_name.is_empty())
element->set_attribute_value(SVG::AttributeNames::class_, String::from_utf8(class_name).release_value());
return element;
}
static String s_icon_view_box = "0 0 24 24"_string;
static GC::Ref<DOM::Element> create_play_icon(DOM::Document& document, StringView class_name)
{
auto icon = create_svg_element(document, SVG::TagNames::svg, class_name);
icon->set_attribute_value(SVG::AttributeNames::viewBox, s_icon_view_box);
auto path = create_svg_element(document, SVG::TagNames::path, "play-path"sv);
path->set_attribute_value(SVG::AttributeNames::d, "m6 5 13 7-13 7Z"_string);
MUST(icon->append_child(path));
return icon;
}
static GC::Ref<DOM::Element> create_mute_icon(DOM::Document& document, StringView class_name)
{
auto icon = create_svg_element(document, SVG::TagNames::svg, class_name);
icon->set_attribute_value(SVG::AttributeNames::viewBox, s_icon_view_box);
// Muted clipping path
auto defs = create_svg_element(document, SVG::TagNames::defs);
MUST(icon->append_child(defs));
auto muted_clip_path = create_svg_element(document, SVG::TagNames::clipPath);
muted_clip_path->set_attribute_value(AttributeNames::id, "muted-clip"_string);
MUST(defs->append_child(muted_clip_path));
auto muted_clip_path_path = create_svg_element(document, SVG::TagNames::path);
muted_clip_path_path->set_attribute_value(SVG::AttributeNames::d, "M3 0h21v21ZM0 0v24h24z"_string);
MUST(muted_clip_path->append_child(muted_clip_path_path));
// Muted cross-out line
auto muted_line = create_svg_element(document, SVG::TagNames::path, "muted-line"_string);
muted_line->set_attribute_value(SVG::AttributeNames::d, "m5 5 14 14-1.5 1.5-14-14z"_string);
MUST(icon->append_child(muted_line));
// High volume wave path
auto volume_high = create_svg_element(document, SVG::TagNames::path, "volume-high"_string);
volume_high->set_attribute_value(SVG::AttributeNames::d, "M14 4.08v2.04c2.23.55 4 2.9 4 5.88 0 2.97-1.77 5.33-4 5.88v2.04c3.45-.56 6-3.96 6-7.92s-2.55-7.36-6-7.92Z"_string);
MUST(icon->append_child(volume_high));
// Low volume wave path
auto volume_low = create_svg_element(document, SVG::TagNames::path, "volume-low"_string);
volume_low->set_attribute_value(SVG::AttributeNames::d, "M14 7.67v8.66c.35-.25.66-.55.92-.9A5.7 5.7 0 0 0 16 12c0-1.3-.39-2.5-1.08-3.43a4.24 4.24 0 0 0-.92-.9Z"_string);
MUST(icon->append_child(volume_low));
// Speaker path
auto speaker = create_svg_element(document, SVG::TagNames::path, "speaker"_string);
speaker->set_attribute_value(SVG::AttributeNames::d, "M4 9v6h4l4 5V4L8 9Z"_string);
MUST(icon->append_child(speaker));
return icon;
}
void MediaControls::create_shadow_tree()
{
auto& media_element = *m_media_element;
auto& document = media_element.document();
auto& realm = media_element.realm();
bool is_video = is<HTMLVideoElement>(media_element);
auto shadow_root = realm.create<DOM::ShadowRoot>(document, media_element, Bindings::ShadowRootMode::Closed);
shadow_root->set_user_agent_internal(true);
media_element.set_shadow_root(shadow_root);
// Scoped stylesheet
auto style_element = create_html_element(document, HTML::TagNames::style);
MUST(style_element->set_text_content(Utf16String::from_utf8(CSS::media_controls_stylesheet_source)));
MUST(shadow_root->append_child(style_element));
// Controls container
auto controls_container = create_html_element(document, HTML::TagNames::div, is_video ? "container video"sv : "container audio"sv);
MUST(shadow_root->append_child(controls_container));
// Video overlay — covers the full video area to catch clicks for play/pause toggle.
// Also contains the placeholder circle shown when no video data is available.
if (is_video) {
m_video_overlay = create_html_element(document, HTML::TagNames::div, "video-overlay"sv);
MUST(controls_container->append_child(*m_video_overlay));
m_placeholder_circle = create_html_element(document, HTML::TagNames::div, "placeholder-circle"sv);
MUST(m_video_overlay->append_child(*m_placeholder_circle));
auto placeholder_icon = create_play_icon(document, "placeholder-icon"sv);
MUST(m_placeholder_circle->append_child(placeholder_icon));
}
// Control bar container
m_control_bar = create_html_element(document, HTML::TagNames::div, "controls"sv);
MUST(controls_container->append_child(*m_control_bar));
// Timeline
m_timeline_element = create_html_element(document, HTML::TagNames::div, "timeline"sv);
MUST(m_control_bar->append_child(*m_timeline_element));
m_timeline_fill = create_html_element(document, HTML::TagNames::div, "timeline-fill"sv);
MUST(m_timeline_element->append_child(*m_timeline_fill));
// Button bar
auto button_bar = create_html_element(document, HTML::TagNames::div, "button-bar"sv);
MUST(m_control_bar->append_child(button_bar));
// Play/pause button
m_play_button = create_html_element(document, HTML::TagNames::button, "control-button play-pause-button"sv);
MUST(button_bar->append_child(*m_play_button));
// Play/pause icon
m_play_pause_icon = create_play_icon(document, "icon play-pause-icon"sv);
MUST(m_play_button->append_child(*m_play_pause_icon));
auto pause_path = create_svg_element(document, SVG::TagNames::path, "pause-path"sv);
pause_path->set_attribute_value(SVG::AttributeNames::d, "M14 5h4v14h-4Zm-4 0H6v14h4z"_string);
MUST(m_play_pause_icon->append_child(pause_path));
// Timestamp
m_timestamp_element = create_html_element(document, HTML::TagNames::span, "timestamp"sv);
MUST(m_timestamp_element->set_text_content(Utf16String::from_utf8("0:00 / 0:00"sv)));
MUST(button_bar->append_child(*m_timestamp_element));
// Speaker button
m_mute_button = create_html_element(document, HTML::TagNames::button, "control-button mute-button"sv);
MUST(button_bar->append_child(*m_mute_button));
auto mute_icon = create_mute_icon(document, "icon"sv);
MUST(m_mute_button->append_child(mute_icon));
// Volume slider
m_volume_area = create_html_element(document, HTML::TagNames::div, "volume-area"sv);
MUST(button_bar->append_child(*m_volume_area));
m_volume_element = create_html_element(document, HTML::TagNames::div, "volume"sv);
MUST(m_volume_area->append_child(*m_volume_element));
m_volume_fill = create_html_element(document, HTML::TagNames::div, "volume-fill"sv);
MUST(m_volume_element->append_child(*m_volume_fill));
// Initialize state
update_play_pause_icon();
update_timestamp();
update_volume_and_mute_indicator();
update_placeholder_visibility();
show_controls();
}
template<typename T, CallableAs<bool, T&> Handler>
GC::Ref<DOM::IDLEventListener> MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, ListenOnce listen_once, Handler handler)
{
auto callback_function = JS::NativeFunction::create(
realm, [handler = move(handler)](JS::VM& vm) {
T* event = vm.argument(0).as_if<T>();
if (event) {
if (handler(*event))
event->prevent_default();
}
return JS::js_undefined();
},
0, Utf16FlyString {}, &realm);
auto callback = realm.heap().allocate<WebIDL::CallbackType>(*callback_function, realm);
auto listener = DOM::IDLEventListener::create(realm, callback);
DOM::AddEventListenerOptions options;
options.once = listen_once == ListenOnce::Yes;
target.add_event_listener(event_name, listener, options);
m_registered_event_listeners.empend(target, event_name, listener);
return listener;
}
template<CallableAs<bool> Handler>
GC::Ref<DOM::IDLEventListener> MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler)
{
return add_event_listener<DOM::Event>(realm, target, event_name, ListenOnce::No, [handler = move(handler)](DOM::Event&) {
return handler();
});
}
template<CallableAs<bool, UIEvents::MouseEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler)
{
return add_event_listener<UIEvents::MouseEvent>(realm, target, event_name, ListenOnce::No, handler);
}
template<CallableAs<bool, UIEvents::MouseEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, ListenOnce listen_once, Handler handler)
{
return add_event_listener<UIEvents::MouseEvent>(realm, target, event_name, listen_once, handler);
}
template<CallableAs<bool, UIEvents::KeyboardEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler)
{
return add_event_listener<UIEvents::KeyboardEvent>(realm, target, event_name, ListenOnce::No, handler);
}
void MediaControls::remove_event_listeners()
{
for (auto const& [target, event_name, listener] : m_registered_event_listeners) {
if (!target)
continue;
if (!listener)
continue;
target->remove_event_listener_without_options(event_name, *listener);
}
m_registered_event_listeners.clear();
}
void MediaControls::set_up_event_listeners()
{
auto& media_element = *m_media_element;
auto& realm = media_element.realm();
// Media element state events
add_event_listener(realm, media_element, HTML::EventNames::play, [this]() {
update_play_pause_icon();
update_placeholder_visibility();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::pause, [this] {
update_play_pause_icon();
update_placeholder_visibility();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::playing, [this] {
update_play_pause_icon();
update_placeholder_visibility();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::seeked, [this] {
update_placeholder_visibility();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::timeupdate, [this] {
update_timeline();
update_timestamp();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::durationchange, [this] {
update_timeline();
update_timestamp();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::volumechange, [this] {
update_volume_and_mute_indicator();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::loadedmetadata, [this] {
update_timestamp();
update_volume_and_mute_indicator();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::addtrack, [this] {
update_volume_and_mute_indicator();
return true;
});
add_event_listener(realm, media_element, HTML::EventNames::emptied, [this] {
update_placeholder_visibility();
update_timeline();
update_timestamp();
return true;
});
// Play/pause button
add_event_listener(realm, *m_play_button, UIEvents::EventNames::click, [this] {
toggle_playback();
return true;
});
// Video overlay click — toggle playback when clicking outside the controls
if (m_video_overlay) {
add_event_listener(realm, *m_video_overlay, UIEvents::EventNames::click, [this] {
toggle_playback();
return true;
});
}
// Timeline scrubbing
static constexpr auto compute_timeline_position = [](UIEvents::MouseEvent const& event, DOM::Element& timeline_element, double duration) -> Optional<double> {
if (isnan(duration) || duration == 0.0)
return {};
auto rect = timeline_element.get_bounding_client_rect();
auto fraction = clamp((event.client_x() - rect.left().to_double()) / rect.width().to_double(), 0.0, 1.0);
return fraction * duration;
};
add_event_listener(realm, *m_timeline_element, UIEvents::EventNames::mousedown, [this](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_timeline_element);
auto position = compute_timeline_position(event, *m_timeline_element, m_media_element->duration());
if (!position.has_value())
return false;
m_scrubbing_timeline = Scrubbing::WhilePaused;
if (!m_media_element->paused()) {
m_media_element->pause();
m_scrubbing_timeline = Scrubbing::WhilePlaying;
}
set_current_time(*position);
auto& realm = m_media_element->realm();
auto& window = static_cast<HTML::Window&>(relevant_global_object(*m_media_element));
auto mousemove_listener = add_event_listener(realm, window, UIEvents::EventNames::mousemove, [this](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_timeline_element);
auto position = compute_timeline_position(event, *m_timeline_element, m_media_element->duration());
if (!position.has_value())
return false;
set_current_time(*position);
return true;
});
add_event_listener(realm, window, UIEvents::EventNames::mouseup, ListenOnce::Yes, [this, mousemove_listener](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_timeline_element);
auto was_playing = m_scrubbing_timeline == Scrubbing::WhilePlaying;
m_scrubbing_timeline = Scrubbing::No;
auto position = compute_timeline_position(event, *m_timeline_element, m_media_element->duration());
if (position.has_value())
set_current_time(*position);
if (was_playing) {
if (m_media_element->ended()) {
auto loop = m_media_element->has_attribute(HTML::AttributeNames::loop);
if (loop)
m_media_element->play();
} else {
m_media_element->play();
}
}
update_play_pause_icon();
auto& window_inner = static_cast<HTML::Window&>(relevant_global_object(*m_media_element));
window_inner.remove_event_listener_without_options(UIEvents::EventNames::mousemove, mousemove_listener);
return true;
});
return true;
});
// Speaker button
add_event_listener(realm, *m_mute_button, UIEvents::EventNames::click, [this] {
VERIFY(m_media_element);
m_media_element->set_muted(!m_media_element->muted());
return true;
});
// Volume scrubbing
static constexpr auto compute_volume = [](UIEvents::MouseEvent const& event, DOM::Element& volume_element) -> Optional<double> {
auto rect = volume_element.get_bounding_client_rect();
return clamp((event.client_x() - rect.left().to_double()) / rect.width().to_double(), 0.0, 1.0);
};
add_event_listener(realm, *m_volume_area, UIEvents::EventNames::mousedown, [this](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_volume_element);
auto volume = compute_volume(event, *m_volume_element);
if (!volume.has_value())
return false;
m_scrubbing_volume = true;
set_volume(*volume);
auto& realm = m_media_element->realm();
auto& window = static_cast<HTML::Window&>(relevant_global_object(*m_media_element));
auto mousemove_listener = add_event_listener(realm, window, UIEvents::EventNames::mousemove, [this](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_volume_element);
auto volume = compute_volume(event, *m_volume_element);
if (!volume.has_value())
return false;
set_volume(*volume);
return true;
});
add_event_listener(realm, window, UIEvents::EventNames::mouseup, ListenOnce::Yes, [this, mousemove_listener](UIEvents::MouseEvent const& event) {
VERIFY(m_media_element);
VERIFY(m_volume_element);
m_scrubbing_volume = false;
auto volume = compute_volume(event, *m_volume_element);
if (volume.has_value())
set_volume(*volume);
auto& window_inner = static_cast<HTML::Window&>(relevant_global_object(*m_media_element));
window_inner.remove_event_listener_without_options(UIEvents::EventNames::mousemove, mousemove_listener);
return true;
});
return true;
});
// Hover detection for video controls visibility
if (is<HTMLVideoElement>(media_element)) {
add_event_listener(realm, media_element, UIEvents::EventNames::mouseenter, [this] {
show_controls();
return true;
});
add_event_listener(realm, media_element, UIEvents::EventNames::mousemove, [this] {
show_controls();
return true;
});
add_event_listener(realm, media_element, UIEvents::EventNames::mouseleave, [this] {
hide_controls();
return true;
});
add_event_listener(realm, *m_control_bar, UIEvents::EventNames::mouseenter, [this] {
m_hovering_controls = true;
show_controls();
return true;
});
add_event_listener(realm, *m_control_bar, UIEvents::EventNames::mouseleave, [this] {
m_hovering_controls = false;
show_controls();
return true;
});
}
// Keyboard handling
add_event_listener(realm, media_element, UIEvents::EventNames::keydown, [this](UIEvents::KeyboardEvent const& event) {
VERIFY(m_media_element);
constexpr double arrow_time_step = 5.0;
constexpr double arrow_volume_step = 0.1;
auto key = event.key();
if (key == " ") {
toggle_playback();
} else if (key == "Home") {
set_current_time(0);
} else if (key == "End") {
set_current_time(m_media_element->duration());
} else if (key == "ArrowLeft") {
set_current_time(m_media_element->current_time() - arrow_time_step);
} else if (key == "ArrowRight") {
set_current_time(m_media_element->current_time() + arrow_time_step);
} else if (key == "ArrowUp") {
set_volume(m_media_element->volume() + arrow_volume_step);
} else if (key == "ArrowDown") {
set_volume(m_media_element->volume() - arrow_volume_step);
} else if (key == "m" || key == "M") {
toggle_mute();
} else {
return false;
}
return true;
});
}
void MediaControls::toggle_playback()
{
if (m_scrubbing_timeline != Scrubbing::No)
return;
m_media_element->toggle_playback();
show_controls();
}
void MediaControls::set_current_time(double time)
{
m_media_element->set_current_time(time);
update_timeline();
update_timestamp();
show_controls();
}
void MediaControls::set_volume(double volume)
{
volume = clamp(volume, 0.0, 1.0);
MUST(m_media_element->set_volume(volume));
m_media_element->set_muted(false);
show_controls();
}
void MediaControls::toggle_mute()
{
m_media_element->set_muted(!m_media_element->muted());
show_controls();
}
void MediaControls::update_play_pause_icon()
{
VERIFY(m_media_element);
VERIFY(m_play_pause_icon);
auto paused = [&] {
if (m_scrubbing_timeline != Scrubbing::No)
return m_scrubbing_timeline == Scrubbing::WhilePaused;
return m_media_element->paused();
}();
static String s_playing_class = "playing"_string;
MUST(m_play_pause_icon->class_list()->toggle(s_playing_class, !paused));
}
void MediaControls::update_timeline()
{
VERIFY(m_media_element);
VERIFY(m_timeline_fill);
auto duration = m_media_element->duration();
double percentage = 0.0;
if (!isnan(duration) && duration > 0.0)
percentage = (m_media_element->current_time() / duration) * 100.0;
MUST(m_timeline_fill->style_for_bindings()->set_property(CSS::PropertyID::Width, MUST(String::formatted("{}%", percentage))));
}
void MediaControls::update_timestamp()
{
VERIFY(m_media_element);
VERIFY(m_timestamp_element);
auto current = human_readable_digital_time(round_to<i64>(m_media_element->current_time()));
auto duration = m_media_element->duration();
auto total = human_readable_digital_time(isnan(duration) ? 0 : round_to<i64>(duration));
MUST(m_timestamp_element->set_text_content(Utf16String::formatted("{} / {}", current, total)));
}
void MediaControls::update_volume_and_mute_indicator()
{
VERIFY(m_media_element);
VERIFY(m_volume_fill);
VERIFY(m_mute_button);
auto volume = m_media_element->volume();
auto has_audio = m_media_element->audio_tracks()->length() > 0;
auto muted = !has_audio || m_media_element->muted();
if (muted) {
MUST(m_volume_fill->style_for_bindings()->set_property(CSS::PropertyID::Width, "0"sv));
} else {
auto percentage = volume * 100.0;
MUST(m_volume_fill->style_for_bindings()->set_property(CSS::PropertyID::Width, MUST(String::formatted("{}%", percentage))));
}
auto new_volume_icon_state = [&] {
if (volume > 0.5)
return MuteIconState::High;
if (volume > 0)
return MuteIconState::Low;
return MuteIconState::Empty;
}();
static constexpr auto icon_class = [](MuteIconState state) {
static Vector<String> s_no_volume_class = {};
static Vector<String> s_low_volume_class = { "low"_string };
static Vector<String> s_high_volume_class = { "high"_string };
switch (state) {
case MuteIconState::Empty:
return s_no_volume_class;
case MuteIconState::Low:
return s_low_volume_class;
case MuteIconState::High:
return s_high_volume_class;
}
VERIFY_NOT_REACHED();
};
if (new_volume_icon_state != m_mute_icon_state) {
MUST(m_mute_button->class_list()->remove(icon_class(m_mute_icon_state)));
MUST(m_mute_button->class_list()->add(icon_class(new_volume_icon_state)));
m_mute_icon_state = new_volume_icon_state;
}
static Vector<String> s_muted_class = { "muted"_string };
if (muted != m_was_muted) {
MUST(m_mute_button->class_list()->toggle("muted"_string, muted));
m_was_muted = muted;
}
static Vector<String> s_hidden_class = { "hidden"_string };
if (has_audio != m_had_audio) {
MUST(m_volume_area->class_list()->toggle("hidden"_string, !has_audio));
m_had_audio = has_audio;
}
}
void MediaControls::update_placeholder_visibility()
{
VERIFY(m_media_element);
if (!m_placeholder_circle)
return;
auto const& video_element = as<HTMLVideoElement>(*m_media_element);
auto representation = video_element.current_representation();
auto show_placeholder = representation != HTML::HTMLVideoElement::Representation::VideoFrame;
MUST(m_placeholder_circle->style_for_bindings()->set_property(CSS::PropertyID::Display, show_placeholder ? "flex"_string : "none"_string));
}
static Vector<String> s_visible_class = { "visible"_string };
void MediaControls::show_controls()
{
VERIFY(m_control_bar);
MUST(m_control_bar->class_list()->add(s_visible_class));
if (!m_hover_timer) {
constexpr int hover_timeout_ms = 1000;
m_hover_timer = Core::Timer::create_single_shot(hover_timeout_ms, [&] {
hide_controls();
});
m_hover_timer->start();
} else {
m_hover_timer->restart();
}
}
void MediaControls::hide_controls()
{
VERIFY(m_control_bar);
if (m_scrubbing_timeline != Scrubbing::No || m_scrubbing_volume || m_hovering_controls)
return;
MUST(m_control_bar->class_list()->remove(s_visible_class));
m_hover_timer.clear();
}
}

View file

@ -0,0 +1,197 @@
.container {
--accent-color: #9d7cf2;
--dark-accent-color: #8a64e5;
--light-accent-color: #e0d4ff;
--foreground-color: white;
--track-color: rgb(20, 20, 20);
--panel-color: rgb(38, 38, 38);
overflow: hidden;
}
.container.video {
position: relative;
width: 100%;
height: 100%;
}
.controls {
display: flex;
flex-direction: column;
font-family: sans-serif;
font-size: 12px;
color: var(--foreground-color);
user-select: none;
}
.container.video > .controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: color-mix(in srgb, var(--panel-color) 82%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease-in-out;
}
.container.video > .controls.visible {
opacity: 1;
pointer-events: auto;
}
.container.audio > .controls {
inset: 0;
background: var(--panel-color);
justify-content: center;
}
.timeline {
z-index: 1;
height: 5px;
padding: 5px 0;
margin: -5px 0;
background: var(--track-color);
background-clip: content-box;
cursor: pointer;
flex-shrink: 0;
}
.timeline-fill {
height: 100%;
background: var(--accent-color);
width: 0%;
pointer-events: none;
}
.button-bar {
display: flex;
align-items: center;
padding: 4px 8px;
gap: 8px;
min-height: 30px;
}
.control-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.icon {
width: 24px;
height: 24px;
}
.control-button .icon path {
fill: var(--foreground-color);
}
.control-button:hover .icon path {
fill: var(--light-accent-color);
}
.play-pause-icon path {
display: none;
}
.play-pause-icon:not(.playing) .play-path {
display: inline;
}
.play-pause-icon.playing .pause-path {
display: inline;
}
.mute-button {
margin-left: auto;
}
.mute-button:not(.low, .high) > .icon > .volume-low {
display: none;
}
.mute-button:not(.high) > .icon > .volume-high {
display: none;
}
.mute-button.muted > .icon > .volume-low,
.mute-button.muted > .icon > .volume-high,
.mute-button.muted > .icon > .speaker {
clip-path: url(#muted-clip);
}
.mute-button:not(.muted) > .icon > .muted-line {
display: none;
}
.timestamp {
min-width: 0;
overflow: clip;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.volume-area {
max-width: 60px;
flex-grow: 1;
padding: 5px;
margin: -5px;
cursor: pointer;
}
.volume-area.hidden {
display: none;
}
.volume {
height: 6px;
background: var(--track-color);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.volume-fill {
height: 100%;
width: 100%;
background: var(--accent-color);
pointer-events: none;
}
.video-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--panel-color);
opacity: 80%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.placeholder-circle:hover {
background: var(--panel-color);
opacity: 100%;
}
.placeholder-circle path {
fill: var(--light-accent-color);
}
.placeholder-icon {
width: 36px;
height: 36px;
margin-left: 4px;
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2026, Gregory Bertilson <gregory@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/FlyString.h>
#include <AK/Vector.h>
#include <LibCore/Timer.h>
#include <LibGC/Weak.h>
#include <LibWeb/Forward.h>
namespace Web::HTML {
class HTMLMediaElement;
class MediaControls {
public:
explicit MediaControls(HTMLMediaElement&);
~MediaControls();
private:
void create_shadow_tree();
enum class ListenOnce : bool {
No,
Yes,
};
template<typename T, CallableAs<bool, T&> Handler>
GC::Ref<DOM::IDLEventListener> add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, ListenOnce, Handler);
template<CallableAs<bool> Handler>
GC::Ref<DOM::IDLEventListener> add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_nam, Handler);
template<CallableAs<bool, UIEvents::MouseEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, Handler);
template<CallableAs<bool, UIEvents::MouseEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, ListenOnce, Handler);
template<CallableAs<bool, UIEvents::KeyboardEvent const&> Handler>
GC::Ref<DOM::IDLEventListener> add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, Handler);
void remove_event_listeners();
void set_up_event_listeners();
void toggle_playback();
void set_current_time(double);
void set_volume(double);
void toggle_mute();
void update_play_pause_icon();
void update_timeline();
void update_timestamp();
void update_volume_and_mute_indicator();
void update_placeholder_visibility();
void show_controls();
void hide_controls();
GC::Weak<HTMLMediaElement> m_media_element;
GC::Weak<DOM::Element> m_control_bar;
GC::Weak<DOM::Element> m_timeline_element;
GC::Weak<DOM::Element> m_timeline_fill;
GC::Weak<DOM::Element> m_play_button;
GC::Weak<DOM::Element> m_play_pause_icon;
GC::Weak<DOM::Element> m_timestamp_element;
GC::Weak<DOM::Element> m_mute_button;
GC::Weak<DOM::Element> m_volume_area;
GC::Weak<DOM::Element> m_volume_element;
GC::Weak<DOM::Element> m_volume_fill;
GC::Weak<DOM::Element> m_video_overlay;
GC::Weak<DOM::Element> m_placeholder_circle;
struct RegisteredEventListener {
GC::Weak<DOM::EventTarget> target;
FlyString event_name;
GC::Weak<DOM::IDLEventListener> listener;
};
Vector<RegisteredEventListener> m_registered_event_listeners;
enum class Scrubbing : u8 {
No,
WhilePaused,
WhilePlaying,
};
Scrubbing m_scrubbing_timeline { Scrubbing::No };
bool m_scrubbing_volume { false };
bool m_hovering_controls { false };
RefPtr<Core::Timer> m_hover_timer;
bool m_had_audio { true };
bool m_was_muted { false };
enum class MuteIconState : u8 {
Empty,
Low,
High,
};
MuteIconState m_mute_icon_state { MuteIconState::Empty };
};
}

View file

@ -6,7 +6,7 @@
#include <LibWeb/HTML/HTMLAudioElement.h>
#include <LibWeb/Layout/AudioBox.h>
#include <LibWeb/Painting/AudioPaintable.h>
#include <LibWeb/Painting/PaintableBox.h>
namespace Web::Layout {
@ -27,16 +27,15 @@ HTML::HTMLAudioElement const& AudioBox::dom_node() const
return static_cast<HTML::HTMLAudioElement const&>(*ReplacedBox::dom_node());
}
bool AudioBox::can_have_children() const
{
// If we allow children when controls are disabled, innerText may be non-empty.
return dom_node().shadow_root() != nullptr;
}
GC::Ptr<Painting::Paintable> AudioBox::create_paintable() const
{
return Painting::AudioPaintable::create(*this);
}
CSS::SizeWithAspectRatio AudioBox::natural_size() const
{
if (dom_node().should_paint())
return { 300, 40, {} };
return { 0, 0, {} };
return Painting::PaintableBox::create(*this);
}
}

View file

@ -20,10 +20,14 @@ public:
HTML::HTMLAudioElement& dom_node();
HTML::HTMLAudioElement const& dom_node() const;
virtual bool can_have_children() const override;
virtual GC::Ptr<Painting::Paintable> create_paintable() const override;
private:
virtual CSS::SizeWithAspectRatio natural_size() const override;
// Treat the audio element as if it was not a replaced element, sizing based on its content.
// Thus, it can fit to the shadow DOM controls, instead of having a hardcoded height.
virtual bool has_auto_content_box_size() const override { return false; }
AudioBox(DOM::Document&, DOM::Element&, GC::Ref<CSS::ComputedProperties>);
};

View file

@ -38,6 +38,12 @@ HTML::HTMLVideoElement const& VideoBox::dom_node() const
return static_cast<HTML::HTMLVideoElement const&>(*ReplacedBox::dom_node());
}
bool VideoBox::can_have_children() const
{
// If we allow children when controls are disabled, innerText may be non-empty.
return dom_node().shadow_root() != nullptr;
}
CSS::SizeWithAspectRatio VideoBox::natural_size() const
{
CSSPixels width = dom_node().video_width();

View file

@ -24,6 +24,8 @@ public:
HTML::HTMLVideoElement& dom_node();
HTML::HTMLVideoElement const& dom_node() const;
virtual bool can_have_children() const override;
virtual GC::Ptr<Painting::Paintable> create_paintable() const override;
private:

View file

@ -1604,12 +1604,6 @@ EventResult EventHandler::handle_keydown(UIEvents::KeyCode key, u32 modifiers, u
// instead interpret this interaction as some other action, instead of interpreting it as a close request.
}
auto focused_area = m_navigable->active_document()->focused_area();
if (auto* media_element = as_if<HTML::HTMLMediaElement>(focused_area.ptr())) {
if (media_element->handle_keydown({}, key, modifiers))
return EventResult::Handled;
}
auto* target = document->active_input_events_target();
if (target) {
if (key == UIEvents::KeyCode::Key_Backspace) {

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Array.h>
#include <AK/NumberFormat.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/HTML/AudioTrackList.h>
#include <LibWeb/HTML/HTMLAudioElement.h>
#include <LibWeb/HTML/HTMLMediaElement.h>
#include <LibWeb/Layout/AudioBox.h>
#include <LibWeb/Painting/AudioPaintable.h>
#include <LibWeb/Painting/BorderRadiusCornerClipper.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
namespace Web::Painting {
GC_DEFINE_ALLOCATOR(AudioPaintable);
GC::Ref<AudioPaintable> AudioPaintable::create(Layout::AudioBox const& layout_box)
{
return layout_box.heap().allocate<AudioPaintable>(layout_box);
}
AudioPaintable::AudioPaintable(Layout::AudioBox const& layout_box)
: MediaPaintable(layout_box)
{
}
void AudioPaintable::paint(DisplayListRecordingContext& context, PaintPhase phase) const
{
if (!is_visible())
return;
auto const& audio_element = as<HTML::HTMLAudioElement const>(*dom_node());
if (!audio_element.should_paint())
return;
Base::paint(context, phase);
if (phase != PaintPhase::Foreground)
return;
DisplayListRecorderStateSaver saver { context.display_list_recorder() };
auto audio_rect = context.rounded_device_rect(absolute_rect());
context.display_list_recorder().add_clip_rect(audio_rect.to_type<int>());
ScopedCornerRadiusClip corner_clip { context, audio_rect, normalized_border_radii_data(ShrinkRadiiForBorders::Yes) };
auto mouse_position = MediaPaintable::mouse_position(context, audio_element);
paint_media_controls(context, audio_element, audio_rect, mouse_position);
}
}

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWeb/Forward.h>
#include <LibWeb/Painting/MediaPaintable.h>
namespace Web::Painting {
class AudioPaintable final : public MediaPaintable {
GC_CELL(AudioPaintable, MediaPaintable);
GC_DECLARE_ALLOCATOR(AudioPaintable);
public:
static GC::Ref<AudioPaintable> create(Layout::AudioBox const&);
virtual void paint(DisplayListRecordingContext&, PaintPhase) const override;
private:
explicit AudioPaintable(Layout::AudioBox const&);
};
}

View file

@ -1,459 +0,0 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Array.h>
#include <AK/NumberFormat.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/HTML/BrowsingContext.h>
#include <LibWeb/HTML/HTMLAudioElement.h>
#include <LibWeb/HTML/HTMLVideoElement.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/Layout/ReplacedBox.h>
#include <LibWeb/Page/EventHandler.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/MediaPaintable.h>
#include <LibWeb/Painting/PaintStyle.h>
#include <LibWeb/UIEvents/MouseButton.h>
namespace Web::Painting {
static constexpr auto CONTROL_BOX_COLOR = Gfx::Color::from_bgrx(0x26'26'26);
static constexpr auto CONTROL_BUTTON_COLOR = Gfx::Color::branded_color(Gfx::Color::BrandedColor::Violet);
static constexpr auto CONTROL_HIGHLIGHT_COLOR = Gfx::Color::branded_color(Gfx::Color::BrandedColor::Violet60);
static constexpr Gfx::Color control_button_color(bool is_hovered)
{
if (!is_hovered)
return Color::White;
return CONTROL_BUTTON_COLOR;
}
MediaPaintable::MediaPaintable(Layout::ReplacedBox const& layout_box)
: PaintableBox(layout_box)
{
}
Optional<DevicePixelPoint> MediaPaintable::mouse_position(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element)
{
auto const& layout_mouse_position = media_element.layout_mouse_position({});
if (layout_mouse_position.has_value() && media_element.document().hovered_node() == &media_element)
return context.rounded_device_point(*layout_mouse_position);
return {};
}
void MediaPaintable::fill_triangle(DisplayListRecorder& painter, Gfx::IntPoint location, Array<Gfx::IntPoint, 3> coordinates, Color color)
{
Gfx::Path path;
path.move_to((coordinates[0] + location).to_type<float>());
path.line_to((coordinates[1] + location).to_type<float>());
path.line_to((coordinates[2] + location).to_type<float>());
path.close();
painter.fill_path({
.path = path,
.paint_style_or_color = color,
.winding_rule = Gfx::WindingRule::EvenOdd,
});
}
void MediaPaintable::paint_media_controls(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, DevicePixelRect media_rect, Optional<DevicePixelPoint> const& mouse_position) const
{
auto components = compute_control_bar_components(context, media_element, media_rect);
context.display_list_recorder().fill_rect(components.control_box_rect.to_type<int>(), CONTROL_BOX_COLOR.with_alpha(0xd0));
paint_control_bar_playback_button(context, media_element, components, mouse_position);
paint_control_bar_timeline(context, media_element, components);
paint_control_bar_timestamp(context, components);
paint_control_bar_speaker(context, media_element, components, mouse_position);
paint_control_bar_volume(context, media_element, components, mouse_position);
}
MediaPaintable::Components MediaPaintable::compute_control_bar_components(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, DevicePixelRect media_rect) const
{
auto maximum_control_box_height = context.rounded_device_pixels(40);
auto component_padding = context.rounded_device_pixels(5);
Components components {};
components.control_box_rect = media_rect;
if (components.control_box_rect.height() > maximum_control_box_height)
components.control_box_rect.take_from_top(components.control_box_rect.height() - maximum_control_box_height);
auto remaining_rect = components.control_box_rect;
remaining_rect.shrink(component_padding * 2, 0);
auto timeline_rect_height = context.rounded_device_pixels(8);
if ((timeline_rect_height * 3) <= components.control_box_rect.height()) {
components.timeline_rect = components.control_box_rect;
components.timeline_rect.set_height(timeline_rect_height);
remaining_rect.take_from_top(timeline_rect_height);
}
auto playback_button_rect_width = min(context.rounded_device_pixels(40), remaining_rect.width());
components.playback_button_rect = remaining_rect;
components.playback_button_rect.set_width(playback_button_rect_width);
remaining_rect.take_from_left(playback_button_rect_width);
components.speaker_button_size = context.rounded_device_pixels(30);
if (components.speaker_button_size <= remaining_rect.width()) {
components.volume_button_size = context.rounded_device_pixels(16);
if ((components.speaker_button_size + components.volume_button_size * 3) <= remaining_rect.width()) {
auto volume_width = min(context.rounded_device_pixels(60), remaining_rect.width() - components.speaker_button_size);
components.volume_rect = remaining_rect;
components.volume_rect.take_from_left(remaining_rect.width() - volume_width);
remaining_rect.take_from_right(volume_width);
components.volume_scrub_rect = components.volume_rect.shrunken(components.volume_button_size, components.volume_rect.height() - components.volume_button_size / 2);
}
components.speaker_button_rect = remaining_rect;
components.speaker_button_rect.take_from_left(remaining_rect.width() - components.speaker_button_size);
remaining_rect.take_from_right(components.speaker_button_size + component_padding);
}
auto display_time = human_readable_digital_time(round(media_element.layout_display_time({})));
auto duration = human_readable_digital_time(isnan(media_element.duration()) ? 0 : round(media_element.duration()));
components.timestamp = Utf16String::formatted("{} / {}", display_time, duration);
components.timestamp_font = layout_node().font(context);
auto timestamp_size = DevicePixels { static_cast<DevicePixels::Type>(ceilf(components.timestamp_font->width(components.timestamp))) };
if (timestamp_size <= remaining_rect.width()) {
components.timestamp_rect = remaining_rect;
components.timestamp_rect.take_from_right(remaining_rect.width() - timestamp_size);
remaining_rect.take_from_left(timestamp_size + component_padding);
}
media_element.cached_layout_boxes({}).control_box_rect = context.scale_to_css_rect(components.control_box_rect);
media_element.cached_layout_boxes({}).playback_button_rect = context.scale_to_css_rect(components.playback_button_rect);
media_element.cached_layout_boxes({}).timeline_rect = context.scale_to_css_rect(components.timeline_rect);
media_element.cached_layout_boxes({}).speaker_button_rect = context.scale_to_css_rect(components.speaker_button_rect);
media_element.cached_layout_boxes({}).volume_rect = context.scale_to_css_rect(components.volume_rect);
media_element.cached_layout_boxes({}).volume_scrub_rect = context.scale_to_css_rect(components.volume_scrub_rect);
return components;
}
void MediaPaintable::paint_control_bar_playback_button(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, Components const& components, Optional<DevicePixelPoint> const& mouse_position)
{
auto playback_button_size = components.playback_button_rect.width() * 4 / 10;
auto playback_button_offset_x = (components.playback_button_rect.width() - playback_button_size) / 2;
auto playback_button_offset_y = (components.playback_button_rect.height() - playback_button_size) / 2;
auto playback_button_location = components.playback_button_rect.top_left().translated(playback_button_offset_x, playback_button_offset_y);
auto playback_button_is_hovered = rect_is_hovered(media_element, components.playback_button_rect, mouse_position);
auto playback_button_color = control_button_color(playback_button_is_hovered);
if (media_element.paused()) {
Array<Gfx::IntPoint, 3> play_button_coordinates { {
{ 0, 0 },
{ static_cast<int>(playback_button_size), static_cast<int>(playback_button_size) / 2 },
{ 0, static_cast<int>(playback_button_size) },
} };
fill_triangle(context.display_list_recorder(), playback_button_location.to_type<int>(), play_button_coordinates, playback_button_color);
} else {
DevicePixelRect pause_button_left_rect {
playback_button_location,
{ playback_button_size / 3, playback_button_size }
};
DevicePixelRect pause_button_right_rect {
playback_button_location.translated(playback_button_size * 2 / 3, 0),
{ playback_button_size / 3, playback_button_size }
};
context.display_list_recorder().fill_rect(pause_button_left_rect.to_type<int>(), playback_button_color);
context.display_list_recorder().fill_rect(pause_button_right_rect.to_type<int>(), playback_button_color);
}
}
void MediaPaintable::paint_control_bar_timeline(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, Components const& components)
{
if (components.timeline_rect.is_empty())
return;
auto playback_percentage = isnan(media_element.duration()) ? 0.0 : media_element.layout_display_time({}) / media_element.duration();
auto playback_position = static_cast<double>(static_cast<int>(components.timeline_rect.width())) * playback_percentage;
auto timeline_button_offset_x = static_cast<DevicePixels>(round(playback_position));
auto timeline_past_rect = components.timeline_rect;
timeline_past_rect.set_width(timeline_button_offset_x);
context.display_list_recorder().fill_rect(timeline_past_rect.to_type<int>(), CONTROL_HIGHLIGHT_COLOR);
auto timeline_future_rect = components.timeline_rect;
timeline_future_rect.take_from_left(timeline_button_offset_x);
context.display_list_recorder().fill_rect(timeline_future_rect.to_type<int>(), Color::Black);
}
void MediaPaintable::paint_control_bar_timestamp(DisplayListRecordingContext& context, Components const& components)
{
if (components.timestamp_rect.is_empty())
return;
context.display_list_recorder().draw_text(components.timestamp_rect.to_type<int>(), components.timestamp, *components.timestamp_font, Gfx::TextAlignment::CenterLeft, Color::White);
}
void MediaPaintable::paint_control_bar_speaker(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, Components const& components, Optional<DevicePixelPoint> const& mouse_position)
{
if (components.speaker_button_rect.is_empty())
return;
auto speaker_button_width = context.rounded_device_pixels(20);
auto speaker_button_height = context.rounded_device_pixels(15);
auto speaker_button_offset_x = (components.speaker_button_rect.width() - speaker_button_width) / 2;
auto speaker_button_offset_y = (components.speaker_button_rect.height() - speaker_button_height) / 2;
auto speaker_button_location = components.speaker_button_rect.top_left().translated(speaker_button_offset_x, speaker_button_offset_y);
auto device_point = [&](double x, double y) {
auto position = context.rounded_device_point({ x, y }) + speaker_button_location;
return position.to_type<DevicePixels::Type>().to_type<float>();
};
auto speaker_button_is_hovered = rect_is_hovered(media_element, components.speaker_button_rect, mouse_position);
auto speaker_button_color = control_button_color(speaker_button_is_hovered);
Gfx::Path path;
path.move_to(device_point(0, 4));
path.line_to(device_point(5, 4));
path.line_to(device_point(11, 0));
path.line_to(device_point(11, 15));
path.line_to(device_point(5, 11));
path.line_to(device_point(0, 11));
path.line_to(device_point(0, 4));
path.close();
context.display_list_recorder().fill_path({ .path = path, .paint_style_or_color = speaker_button_color, .winding_rule = Gfx::WindingRule::EvenOdd });
path.clear();
path.move_to(device_point(13, 3));
path.quadratic_bezier_curve_to(device_point(16, 7.5), device_point(13, 12));
path.move_to(device_point(14, 0));
path.quadratic_bezier_curve_to(device_point(20, 7.5), device_point(14, 15));
context.display_list_recorder().stroke_path({
.cap_style = Gfx::Path::CapStyle::Round,
.join_style = Gfx::Path::JoinStyle::Round,
.miter_limit = 4,
.dash_array = {},
.dash_offset = 0,
.path = path,
.paint_style_or_color = speaker_button_color,
.thickness = 1,
});
if (media_element.muted()) {
context.display_list_recorder().draw_line(device_point(0, 0).to_type<int>(), device_point(20, 15).to_type<int>(), Color::Red, 2);
context.display_list_recorder().draw_line(device_point(0, 15).to_type<int>(), device_point(20, 0).to_type<int>(), Color::Red, 2);
}
}
void MediaPaintable::paint_control_bar_volume(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, Components const& components, Optional<DevicePixelPoint> const& mouse_position)
{
if (components.volume_rect.is_empty())
return;
auto volume_position = static_cast<double>(static_cast<int>(components.volume_scrub_rect.width())) * media_element.volume();
auto volume_button_offset_x = static_cast<DevicePixels>(round(volume_position));
auto volume_lower_rect = components.volume_scrub_rect;
volume_lower_rect.set_width(volume_button_offset_x);
context.display_list_recorder().fill_rect_with_rounded_corners(volume_lower_rect.to_type<int>(), CONTROL_HIGHLIGHT_COLOR, 4);
auto volume_higher_rect = components.volume_scrub_rect;
volume_higher_rect.take_from_left(volume_button_offset_x);
context.display_list_recorder().fill_rect_with_rounded_corners(volume_higher_rect.to_type<int>(), Color::Black, 4);
auto volume_button_rect = components.volume_scrub_rect;
volume_button_rect.shrink(components.volume_scrub_rect.width() - components.volume_button_size, components.volume_scrub_rect.height() - components.volume_button_size);
volume_button_rect.set_x(components.volume_scrub_rect.x() + volume_button_offset_x - components.volume_button_size / 2);
auto volume_is_hovered = rect_is_hovered(media_element, components.volume_rect, mouse_position, HTML::HTMLMediaElement::MediaComponent::Volume);
auto volume_color = control_button_color(volume_is_hovered);
context.display_list_recorder().fill_ellipse(volume_button_rect.to_type<int>(), volume_color);
}
MediaPaintable::DispatchEventOfSameName MediaPaintable::handle_mousedown(Badge<EventHandler>, CSSPixelPoint position, unsigned button, unsigned)
{
if (button != UIEvents::MouseButton::Primary)
return DispatchEventOfSameName::Yes;
auto& media_element = this->media_element();
auto const& cached_layout_boxes = media_element.cached_layout_boxes({});
auto position_adjusted_by_scroll_offset = position;
position_adjusted_by_scroll_offset.translate_by(-cumulative_offset_of_enclosing_scroll_frame());
if (cached_layout_boxes.timeline_rect.has_value() && cached_layout_boxes.timeline_rect->contains(position_adjusted_by_scroll_offset)) {
media_element.set_layout_mouse_tracking_component({}, HTML::HTMLMediaElement::MediaComponent::Timeline);
set_current_time(media_element, *cached_layout_boxes.timeline_rect, position_adjusted_by_scroll_offset, Temporary::Yes);
} else if (cached_layout_boxes.volume_rect.has_value() && cached_layout_boxes.volume_rect->contains(position_adjusted_by_scroll_offset)) {
media_element.set_layout_mouse_tracking_component({}, HTML::HTMLMediaElement::MediaComponent::Volume);
set_volume(media_element, *cached_layout_boxes.volume_scrub_rect, position_adjusted_by_scroll_offset);
}
if (media_element.layout_mouse_tracking_component({}).has_value())
const_cast<HTML::Navigable&>(*navigable()).event_handler().set_mouse_event_tracking_paintable(this);
return DispatchEventOfSameName::Yes;
}
MediaPaintable::DispatchEventOfSameName MediaPaintable::handle_mouseup(Badge<EventHandler>, CSSPixelPoint position, unsigned button, unsigned)
{
auto& media_element = this->media_element();
auto const& cached_layout_boxes = media_element.cached_layout_boxes({});
auto position_adjusted_by_scroll_offset = position;
position_adjusted_by_scroll_offset.translate_by(-cumulative_offset_of_enclosing_scroll_frame());
if (auto const& mouse_tracking_component = media_element.layout_mouse_tracking_component({}); mouse_tracking_component.has_value()) {
switch (*mouse_tracking_component) {
case HTML::HTMLMediaElement::MediaComponent::Timeline:
set_current_time(media_element, *cached_layout_boxes.timeline_rect, position_adjusted_by_scroll_offset, Temporary::No);
break;
case HTML::HTMLMediaElement::MediaComponent::Volume:
document().page().client().page_did_stop_tooltip_override();
break;
default:
VERIFY_NOT_REACHED();
}
const_cast<HTML::Navigable&>(*navigable()).event_handler().set_mouse_event_tracking_paintable(nullptr);
media_element.set_layout_mouse_tracking_component({}, {});
return DispatchEventOfSameName::Yes;
}
if (button != UIEvents::MouseButton::Primary)
return DispatchEventOfSameName::Yes;
if (cached_layout_boxes.control_box_rect.has_value() && cached_layout_boxes.control_box_rect->contains(position_adjusted_by_scroll_offset)) {
if (cached_layout_boxes.playback_button_rect.has_value() && cached_layout_boxes.playback_button_rect->contains(position_adjusted_by_scroll_offset)) {
media_element.toggle_playback();
return DispatchEventOfSameName::Yes;
}
if (cached_layout_boxes.speaker_button_rect.has_value() && cached_layout_boxes.speaker_button_rect->contains(position_adjusted_by_scroll_offset)) {
media_element.set_muted(!media_element.muted());
return DispatchEventOfSameName::Yes;
}
if (cached_layout_boxes.timeline_rect.has_value() && cached_layout_boxes.timeline_rect->contains(position_adjusted_by_scroll_offset))
return DispatchEventOfSameName::No;
}
media_element.toggle_playback();
return DispatchEventOfSameName::Yes;
}
MediaPaintable::DispatchEventOfSameName MediaPaintable::handle_mousemove(Badge<EventHandler>, CSSPixelPoint position, unsigned, unsigned)
{
auto& media_element = this->media_element();
auto const& cached_layout_boxes = media_element.cached_layout_boxes({});
auto position_adjusted_by_scroll_offset = position;
auto scroll_offset = cumulative_offset_of_enclosing_scroll_frame();
position_adjusted_by_scroll_offset.translate_by(-scroll_offset);
if (auto const& mouse_tracking_component = media_element.layout_mouse_tracking_component({}); mouse_tracking_component.has_value()) {
switch (*mouse_tracking_component) {
case HTML::HTMLMediaElement::MediaComponent::Timeline:
if (cached_layout_boxes.timeline_rect.has_value())
set_current_time(media_element, *cached_layout_boxes.timeline_rect, position_adjusted_by_scroll_offset, Temporary::Yes);
break;
case HTML::HTMLMediaElement::MediaComponent::Volume:
if (cached_layout_boxes.volume_rect.has_value()) {
set_volume(media_element, *cached_layout_boxes.volume_scrub_rect, position_adjusted_by_scroll_offset);
auto volume = static_cast<u8>(media_element.volume() * 100.0);
document().page().client().page_did_request_tooltip_override({ position_adjusted_by_scroll_offset.x(), cached_layout_boxes.volume_scrub_rect->y() + scroll_offset.y() }, ByteString::formatted("{}%", volume));
}
break;
default:
VERIFY_NOT_REACHED();
}
}
auto previous_hovered_component = media_element.layout_hovered_component({});
if (cached_layout_boxes.playback_button_rect.has_value() && cached_layout_boxes.playback_button_rect->contains(position_adjusted_by_scroll_offset))
media_element.set_layout_hovered_component({}, HTML::HTMLMediaElement::MediaComponent::PlaybackButton);
else if (cached_layout_boxes.speaker_button_rect.has_value() && cached_layout_boxes.speaker_button_rect->contains(position_adjusted_by_scroll_offset))
media_element.set_layout_hovered_component({}, HTML::HTMLMediaElement::MediaComponent::SpeakerButton);
else if (cached_layout_boxes.volume_rect.has_value() && cached_layout_boxes.volume_rect->contains(position_adjusted_by_scroll_offset))
media_element.set_layout_hovered_component({}, HTML::HTMLMediaElement::MediaComponent::Volume);
else
media_element.set_layout_hovered_component({}, {});
if (previous_hovered_component != media_element.layout_hovered_component({}))
set_needs_display();
if (absolute_rect().contains(position_adjusted_by_scroll_offset)) {
media_element.set_layout_mouse_position({}, position_adjusted_by_scroll_offset);
return DispatchEventOfSameName::Yes;
}
media_element.set_layout_mouse_position({}, {});
return DispatchEventOfSameName::No;
}
void MediaPaintable::set_current_time(HTML::HTMLMediaElement& media_element, CSSPixelRect timeline_rect, CSSPixelPoint mouse_position, Temporary temporarily)
{
VERIFY(timeline_rect.width() > 0);
auto x_offset = mouse_position.x() - timeline_rect.x();
x_offset = max(x_offset, 0);
x_offset = min(x_offset, timeline_rect.width());
auto x_percentage = static_cast<double>(x_offset) / static_cast<double>(timeline_rect.width());
auto duration = media_element.duration();
if (isnan(duration))
return;
auto position = x_percentage * duration;
if (position != media_element.layout_display_time({}))
media_element.set_current_time(position);
switch (temporarily) {
case Temporary::Yes:
media_element.set_layout_display_time({}, position);
break;
case Temporary::No:
media_element.set_layout_display_time({}, {});
break;
}
}
void MediaPaintable::set_volume(HTML::HTMLMediaElement& media_element, CSSPixelRect volume_rect, CSSPixelPoint mouse_position)
{
auto x_offset = mouse_position.x() - volume_rect.x();
x_offset = max(x_offset, 0);
x_offset = min(x_offset, volume_rect.width());
auto volume = static_cast<double>(x_offset) / static_cast<double>(volume_rect.width());
media_element.set_volume(volume).release_value_but_fixme_should_propagate_errors();
}
bool MediaPaintable::rect_is_hovered(HTML::HTMLMediaElement const& media_element, Optional<DevicePixelRect> const& rect, Optional<DevicePixelPoint> const& mouse_position, Optional<HTML::HTMLMediaElement::MediaComponent> const& allowed_mouse_tracking_component)
{
if (auto const& mouse_tracking_component = media_element.layout_mouse_tracking_component({}); mouse_tracking_component.has_value())
return mouse_tracking_component == allowed_mouse_tracking_component;
if (!rect.has_value() || !mouse_position.has_value())
return false;
return rect->contains(*mouse_position);
}
}

View file

@ -1,69 +0,0 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWeb/Forward.h>
#include <LibWeb/HTML/HTMLMediaElement.h>
#include <LibWeb/Painting/PaintableBox.h>
#include <LibWeb/PixelUnits.h>
namespace Web::Painting {
class MediaPaintable : public PaintableBox {
GC_CELL(MediaPaintable, PaintableBox);
protected:
explicit MediaPaintable(Layout::ReplacedBox const&);
static Optional<DevicePixelPoint> mouse_position(DisplayListRecordingContext&, HTML::HTMLMediaElement const&);
static void fill_triangle(DisplayListRecorder& painter, Gfx::IntPoint location, Array<Gfx::IntPoint, 3> coordinates, Color color);
void paint_media_controls(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, DevicePixelRect media_rect, Optional<DevicePixelPoint> const& mouse_position) const;
auto& media_element() { return as<HTML::HTMLMediaElement>(*dom_node()); }
private:
struct Components {
DevicePixelRect control_box_rect;
DevicePixelRect playback_button_rect;
DevicePixelRect timeline_rect;
Utf16String timestamp;
RefPtr<Gfx::Font const> timestamp_font;
DevicePixelRect timestamp_rect;
DevicePixelRect speaker_button_rect;
DevicePixels speaker_button_size;
DevicePixelRect volume_rect;
DevicePixelRect volume_scrub_rect;
DevicePixels volume_button_size;
};
virtual bool wants_mouse_events() const override { return true; }
virtual DispatchEventOfSameName handle_mousedown(Badge<EventHandler>, CSSPixelPoint, unsigned button, unsigned modifiers) override;
virtual DispatchEventOfSameName handle_mouseup(Badge<EventHandler>, CSSPixelPoint, unsigned button, unsigned modifiers) override;
virtual DispatchEventOfSameName handle_mousemove(Badge<EventHandler>, CSSPixelPoint, unsigned buttons, unsigned modifiers) override;
Components compute_control_bar_components(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, DevicePixelRect media_rect) const;
static void paint_control_bar_playback_button(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, Components const&, Optional<DevicePixelPoint> const& mouse_position);
static void paint_control_bar_timeline(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, Components const&);
static void paint_control_bar_timestamp(DisplayListRecordingContext&, Components const&);
static void paint_control_bar_speaker(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, Components const& components, Optional<DevicePixelPoint> const& mouse_position);
static void paint_control_bar_volume(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, Components const&, Optional<DevicePixelPoint> const& mouse_position);
enum class Temporary {
Yes,
No,
};
static void set_current_time(HTML::HTMLMediaElement& media_element, CSSPixelRect timeline_rect, CSSPixelPoint mouse_position, Temporary);
static void set_volume(HTML::HTMLMediaElement& media_element, CSSPixelRect volume_rect, CSSPixelPoint mouse_position);
static bool rect_is_hovered(HTML::HTMLMediaElement const& media_element, Optional<DevicePixelRect> const& rect, Optional<DevicePixelPoint> const& mouse_position, Optional<HTML::HTMLMediaElement::MediaComponent> const& allowed_mouse_tracking_component = {});
};
}

View file

@ -1,10 +1,10 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
* Copyright (c) 2026, Gregory Bertilso <gregory@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Array.h>
#include <LibGfx/Bitmap.h>
#include <LibGfx/ImmutableBitmap.h>
#include <LibMedia/Sinks/DisplayingVideoSink.h>
@ -19,25 +19,15 @@
namespace Web::Painting {
static constexpr auto control_box_color = Gfx::Color::from_bgrx(0x26'26'26);
static constexpr auto control_highlight_color = Gfx::Color::from_bgrx(0x1d'99'f3);
GC_DEFINE_ALLOCATOR(VideoPaintable);
static constexpr Gfx::Color control_button_color(bool is_hovered)
{
if (!is_hovered)
return Color::White;
return control_highlight_color;
}
GC::Ref<VideoPaintable> VideoPaintable::create(Layout::VideoBox const& layout_box)
{
return layout_box.heap().allocate<VideoPaintable>(layout_box);
}
VideoPaintable::VideoPaintable(Layout::VideoBox const& layout_box)
: MediaPaintable(layout_box)
: PaintableBox(layout_box)
{
}
@ -59,7 +49,6 @@ void VideoPaintable::paint(DisplayListRecordingContext& context, PaintPhase phas
ScopedCornerRadiusClip corner_clip { context, video_rect, normalized_border_radii_data(ShrinkRadiiForBorders::Yes) };
auto const& video_element = as<HTML::HTMLVideoElement>(*dom_node());
auto mouse_position = MediaPaintable::mouse_position(context, video_element);
auto const& poster_frame = video_element.poster_frame();
@ -84,76 +73,23 @@ void VideoPaintable::paint(DisplayListRecordingContext& context, PaintPhase phas
context.display_list_recorder().fill_rect(video_rect.to_type<int>(), transparent_black);
};
auto paint_loaded_video_controls = [&]() {
auto is_hovered = document().hovered_node() == &video_element;
auto is_paused = video_element.paused();
if (is_hovered || is_paused)
paint_media_controls(context, video_element, video_rect, mouse_position);
};
auto paint_user_agent_controls = video_element.has_attribute(HTML::AttributeNames::controls) || video_element.is_scripting_disabled();
auto representation = video_element.current_representation();
switch (representation) {
case HTML::HTMLVideoElement::Representation::FirstVideoFrame:
case HTML::HTMLVideoElement::Representation::VideoFrame:
paint_video_frame();
if (paint_user_agent_controls)
paint_loaded_video_controls();
break;
case HTML::HTMLVideoElement::Representation::PosterFrame:
VERIFY(poster_frame);
paint_bitmap(poster_frame);
if (paint_user_agent_controls)
paint_placeholder_video_controls(context, video_rect, mouse_position);
break;
case HTML::HTMLVideoElement::Representation::TransparentBlack:
paint_transparent_black();
if (paint_user_agent_controls)
paint_placeholder_video_controls(context, video_rect, mouse_position);
break;
}
}
void VideoPaintable::paint_placeholder_video_controls(DisplayListRecordingContext& context, DevicePixelRect video_rect, Optional<DevicePixelPoint> const& mouse_position) const
{
auto maximum_control_box_size = context.rounded_device_pixels(100);
auto maximum_playback_button_size = context.rounded_device_pixels(40);
auto center = video_rect.center();
auto control_box_size = min(maximum_control_box_size, min(video_rect.width(), video_rect.height()) * 4 / 5);
auto control_box_offset_x = control_box_size / 2;
auto control_box_offset_y = control_box_size / 2;
auto control_box_location = center.translated(-control_box_offset_x, -control_box_offset_y);
DevicePixelRect control_box_rect { control_box_location, { control_box_size, control_box_size } };
auto playback_button_size = min(maximum_playback_button_size, min(video_rect.width(), video_rect.height()) * 2 / 5);
auto playback_button_offset_x = playback_button_size / 2;
auto playback_button_offset_y = playback_button_size / 2;
// We want to center the play button on its center of mass, which is not the midpoint of its vertices.
// To do so, reduce its desired x offset by a factor of tan(30 degrees) / 2 (about 0.288685).
playback_button_offset_x -= 0.288685f * static_cast<float>(static_cast<DevicePixels::Type>(playback_button_offset_x));
auto playback_button_location = center.translated(-playback_button_offset_x, -playback_button_offset_y);
Array<Gfx::IntPoint, 3> play_button_coordinates { {
{ 0, 0 },
{ static_cast<int>(playback_button_size), static_cast<int>(playback_button_size) / 2 },
{ 0, static_cast<int>(playback_button_size) },
} };
auto playback_button_is_hovered = mouse_position.has_value() && control_box_rect.contains(*mouse_position);
auto playback_button_color = control_button_color(playback_button_is_hovered);
context.display_list_recorder().fill_ellipse(control_box_rect.to_type<int>(), control_box_color);
fill_triangle(context.display_list_recorder(), playback_button_location.to_type<int>(), play_button_coordinates, playback_button_color);
}
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
* Copyright (c) 2026, Gregory Bertilso <gregory@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -7,12 +8,12 @@
#pragma once
#include <LibWeb/Forward.h>
#include <LibWeb/Painting/MediaPaintable.h>
#include <LibWeb/Painting/PaintableBox.h>
namespace Web::Painting {
class VideoPaintable final : public MediaPaintable {
GC_CELL(VideoPaintable, MediaPaintable);
class VideoPaintable final : public PaintableBox {
GC_CELL(VideoPaintable, PaintableBox);
GC_DECLARE_ALLOCATOR(VideoPaintable);
public:
@ -22,8 +23,6 @@ public:
private:
VideoPaintable(Layout::VideoBox const&);
void paint_placeholder_video_controls(DisplayListRecordingContext&, DevicePixelRect video_rect, Optional<DevicePixelPoint> const& mouse_position) const;
};
}

View file

@ -158,6 +158,14 @@ function (generate_css_implementation)
NAMESPACE "Web::CSS"
)
embed_as_string(
"MediaControlsStyleSheetSource.cpp"
"${LIBWEB_INPUT_FOLDER}/HTML/MediaControls.css"
"CSS/MediaControlsStyleSheetSource.cpp"
"media_controls_stylesheet_source"
NAMESPACE "Web::CSS"
)
set(CSS_GENERATED_HEADERS
"CSS/Enums.h"
"CSS/EnvironmentVariable.h"

View file

@ -1,14 +1,57 @@
Viewport <#document> at [0,0] [0+0+0 800 0+0+0] [0+0+0 600 0+0+0] [BFC] children: not-inline
BlockContainer <html> at [0,0] [0+0+0 800 0+0+0] [0+0+0 56 0+0+0] [BFC] children: not-inline
BlockContainer <body> at [8,8] [8+0+0 784 0+0+8] [8+0+0 40 0+0+8] children: inline
frag 0 from AudioBox start: 0, length: 0, rect: [8,8 300x40] baseline: 40
AudioBox <audio> at [8,8] [0+0+0 300 0+0+0] [0+0+0 40 0+0+0] children: not-inline
BlockContainer <html> at [0,0] [0+0+0 800 0+0+0] [0+0+0 59 0+0+0] [BFC] children: not-inline
BlockContainer <body> at [8,8] [8+0+0 784 0+0+8] [8+0+0 43 0+0+8] children: inline
frag 0 from AudioBox start: 0, length: 0, rect: [8,8 300x43] baseline: 43
AudioBox <audio> at [8,8] inline-block [0+0+0 300 0+0+0] [0+0+0 43 0+0+0] children: not-inline
BlockContainer <(anonymous)> at [8,8] inline-block [0+0+0 300 0+0+0] [0+0+0 43 0+0+0] [BFC] children: not-inline
BlockContainer <div.container.audio> at [8,8] [0+0+0 300 0+0+0] [0+0+0 43 0+0+0] [BFC] children: not-inline
Box <div.controls.visible> at [8,8] flex-container(column) [0+0+0 300 0+0+0] [0+0+0 43 0+0+0] [FFC] children: not-inline
BlockContainer <div.timeline> at [8,8] flex-item [0+0+0 300 0+0+0] [-5+0+5 5 5+0+-5] [BFC] children: not-inline
BlockContainer <div.timeline-fill> at [8,8] [0+0+0 0 0+0+300] [0+0+0 5 0+0+0] children: not-inline
Box <div.button-bar> at [16,17] flex-container(row) flex-item [0+0+8 284 8+0+0] [0+0+4 30 4+0+0] [FFC] children: not-inline
Box <button.control-button.play-pause-button> at [16,20] flex-container(row) flex-item [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] [FFC] children: not-inline
SVGSVGBox <svg.icon.play-pause-icon> at [16,20] flex-item [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] [SVG] children: not-inline
SVGGeometryBox <path.play-path> at [22,25] [0+0+0 13 0+0+0] [0+0+0 14 0+0+0] children: not-inline
BlockContainer <span.timestamp> at [48,25] flex-item [0+0+0 80.84375 0+0+0] [0+0+0 14 0+0+0] [BFC] children: inline
frag 0 from TextNode start: 0, length: 13, rect: [48,25 80.84375x14] baseline: 10.59375
"00:00 / 00:00"
TextNode <#text> (not painted)
Box <button.control-button.mute-button.high.muted> at [276,20] flex-container(row) flex-item [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] [FFC] children: not-inline
SVGSVGBox <svg.icon> at [276,20] flex-item [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] [SVG] children: not-inline
SVGGeometryBox <path.muted-line> at [279.5,25] [0+0+0 15.5 0+0+0] [0+0+0 15.5 0+0+0] children: not-inline
SVGGeometryBox <path.volume-high> at [290,24.078125] [0+0+0 6 0+0+0] [0+0+0 15.84375 0+0+0] children: not-inline
SVGClipBox <clipPath#muted-clip> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
SVGGeometryBox <path> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
SVGGeometryBox <path.volume-low> at [290,27.671875] [0+0+0 2.03125 0+0+0] [0+0+0 8.65625 0+0+0] children: not-inline
SVGClipBox <clipPath#muted-clip> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
SVGGeometryBox <path> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
SVGGeometryBox <path.speaker> at [280,24] [0+0+0 8 0+0+0] [0+0+0 16 0+0+0] children: not-inline
SVGClipBox <clipPath#muted-clip> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
SVGGeometryBox <path> at [276,20] [0+0+0 24 0+0+0] [0+0+0 24 0+0+0] children: not-inline
TextNode <#text> (not painted)
ViewportPaintable (Viewport<#document>) [0,0 800x600]
PaintableWithLines (BlockContainer<HTML>) [0,0 800x56]
PaintableWithLines (BlockContainer<BODY>) [8,8 784x40]
AudioPaintable (AudioBox<AUDIO>) [8,8 300x40]
PaintableWithLines (BlockContainer<HTML>) [0,0 800x59]
PaintableWithLines (BlockContainer<BODY>) [8,8 784x43]
PaintableBox (AudioBox<AUDIO>) [8,8 300x43]
PaintableWithLines (BlockContainer(anonymous)) [8,8 300x43]
PaintableWithLines (BlockContainer<DIV>.container.audio) [8,8 300x43]
PaintableBox (Box<DIV>.controls.visible) [8,8 300x43]
PaintableWithLines (BlockContainer<DIV>.timeline) [8,3 300x15]
PaintableWithLines (BlockContainer<DIV>.timeline-fill) [8,8 0x5]
PaintableBox (Box<DIV>.button-bar) [8,13 300x38]
PaintableBox (Box<BUTTON>.control-button.play-pause-button) [16,20 24x24]
SVGSVGPaintable (SVGSVGBox<svg>.icon.play-pause-icon) [16,20 24x24]
SVGPathPaintable (SVGGeometryBox<path>.play-path) [22,25 13x14]
PaintableWithLines (BlockContainer<SPAN>.timestamp) [48,25 80.84375x14]
TextPaintable (TextNode<#text>)
PaintableBox (Box<BUTTON>.control-button.mute-button.high.muted) [276,20 24x24]
SVGSVGPaintable (SVGSVGBox<svg>.icon) [276,20 24x24]
SVGPathPaintable (SVGGeometryBox<path>.muted-line) [279.5,25 15.5x15.5]
SVGPathPaintable (SVGGeometryBox<path>.volume-high) [290,24.078125 6x15.84375]
SVGPathPaintable (SVGGeometryBox<path>.volume-low) [290,27.671875 2.03125x8.65625]
SVGPathPaintable (SVGGeometryBox<path>.speaker) [280,24 8x16]
SC for Viewport<#document> [0,0 800x600] [children: 1] (z-index: auto)
SC for BlockContainer<HTML> [0,0 800x56] [children: 0] (z-index: auto)
SC for BlockContainer<HTML> [0,0 800x59] [children: 1] (z-index: auto)
SC for BlockContainer<DIV>.timeline [8,8 300x5] [children: 0] (z-index: 1)

View file

@ -3,9 +3,9 @@ Viewport <#document> at [0,0] [0+0+0 800 0+0+0] [0+0+0 600 0+0+0] [BFC] children
BlockContainer <body> at [8,8] [8+0+0 784 0+0+8] [8+0+0 720 0+0+8] children: inline
frag 0 from VideoBox start: 0, length: 0, rect: [8,8 320x240] baseline: 240
frag 1 from VideoBox start: 0, length: 0, rect: [8,248 640x480] baseline: 480
VideoBox <video> at [8,8] [0+0+0 320 0+0+0] [0+0+0 240 0+0+0] children: not-inline
VideoBox <video> at [8,8] inline-block [0+0+0 320 0+0+0] [0+0+0 240 0+0+0] children: not-inline
TextNode <#text> (not painted)
VideoBox <video> at [8,248] [0+0+0 640 0+0+0] [0+0+0 480 0+0+0] children: not-inline
VideoBox <video> at [8,248] inline-block [0+0+0 640 0+0+0] [0+0+0 480 0+0+0] children: not-inline
TextNode <#text> (not painted)
ViewportPaintable (Viewport<#document>) [0,0 800x600] overflow: [0,0 800x736]