diff --git a/Libraries/LibWeb/CMakeLists.txt b/Libraries/LibWeb/CMakeLists.txt index 534d44f0ab..47021579cb 100644 --- a/Libraries/LibWeb/CMakeLists.txt +++ b/Libraries/LibWeb/CMakeLists.txt @@ -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 diff --git a/Libraries/LibWeb/CSS/Default.css b/Libraries/LibWeb/CSS/Default.css index 0dce2a1f24..8b29713efd 100644 --- a/Libraries/LibWeb/CSS/Default.css +++ b/Libraries/LibWeb/CSS/Default.css @@ -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 */ diff --git a/Libraries/LibWeb/HTML/HTMLMediaElement.cpp b/Libraries/LibWeb/HTML/HTMLMediaElement.cpp index 06927dff30..96cc6df51b 100644 --- a/Libraries/LibWeb/HTML/HTMLMediaElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLMediaElement.cpp @@ -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 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, GC::Ptr 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, 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, Optional 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) const -{ - return m_display_time.value_or(current_time()); + m_controls.clear(); } } diff --git a/Libraries/LibWeb/HTML/HTMLMediaElement.h b/Libraries/LibWeb/HTML/HTMLMediaElement.h index eea7664277..582fb6ad04 100644 --- a/Libraries/LibWeb/HTML/HTMLMediaElement.h +++ b/Libraries/LibWeb/HTML/HTMLMediaElement.h @@ -19,9 +19,9 @@ #include #include #include +#include #include #include -#include #include namespace Web::HTML { @@ -144,36 +144,8 @@ public: GC::Ref add_text_track(Bindings::TextTrackKind kind, String const& label, String const& language); - bool handle_keydown(Badge, UIEvents::KeyCode, u32 modifiers); - - enum class MediaComponent { - PlaybackButton, - SpeakerButton, - Timeline, - Volume, - }; - - void set_layout_mouse_tracking_component(Badge, Optional mouse_tracking_component) { m_mouse_tracking_component = move(mouse_tracking_component); } - Optional const& layout_mouse_tracking_component(Badge) const { return m_mouse_tracking_component; } - - void set_layout_hovered_component(Badge, Optional hovered_component) { m_hovered_component = hovered_component; } - Optional const& layout_hovered_component(Badge) const { return m_hovered_component; } - - void set_layout_mouse_position(Badge, Optional mouse_position) { m_mouse_position = move(mouse_position); } - Optional const& layout_mouse_position(Badge) const { return m_mouse_position; } - - void set_layout_display_time(Badge, Optional display_time); - double layout_display_time(Badge) const; - - struct CachedLayoutBoxes { - Optional control_box_rect; - Optional playback_button_rect; - Optional timeline_rect; - Optional speaker_button_rect; - Optional volume_rect; - Optional volume_scrub_rect; - }; - CachedLayoutBoxes& cached_layout_boxes(Badge) 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 m_mouse_tracking_component; - Optional m_hovered_component; - bool m_tracking_mouse_position_while_playing { false }; - Optional m_mouse_position; - Optional m_display_time; - mutable CachedLayoutBoxes m_layout_boxes; + Optional m_controls; bool m_has_enabled_preferred_audio_track { false }; bool m_has_selected_preferred_video_track { false }; diff --git a/Libraries/LibWeb/HTML/MediaControls.cpp b/Libraries/LibWeb/HTML/MediaControls.cpp new file mode 100644 index 0000000000..e69cca7f18 --- /dev/null +++ b/Libraries/LibWeb/HTML/MediaControls.cpp @@ -0,0 +1,704 @@ +/* + * Copyright (c) 2026, Gregory Bertilson + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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 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(media_element); + + auto shadow_root = realm.create(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 Handler> +GC::Ref 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(); + if (event) { + if (handler(*event)) + event->prevent_default(); + } + return JS::js_undefined(); + }, + 0, Utf16FlyString {}, &realm); + auto callback = realm.heap().allocate(*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 Handler> +GC::Ref MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler) +{ + return add_event_listener(realm, target, event_name, ListenOnce::No, [handler = move(handler)](DOM::Event&) { + return handler(); + }); +} + +template Handler> +GC::Ref MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler) +{ + return add_event_listener(realm, target, event_name, ListenOnce::No, handler); +} + +template Handler> +GC::Ref MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, ListenOnce listen_once, Handler handler) +{ + return add_event_listener(realm, target, event_name, listen_once, handler); +} + +template Handler> +GC::Ref MediaControls::add_event_listener(JS::Realm& realm, DOM::EventTarget& target, FlyString const& event_name, Handler handler) +{ + return add_event_listener(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 { + 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(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(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 { + 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(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(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(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(m_media_element->current_time())); + auto duration = m_media_element->duration(); + auto total = human_readable_digital_time(isnan(duration) ? 0 : round_to(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 s_no_volume_class = {}; + static Vector s_low_volume_class = { "low"_string }; + static Vector 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 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 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(*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 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(); +} + +} diff --git a/Libraries/LibWeb/HTML/MediaControls.css b/Libraries/LibWeb/HTML/MediaControls.css new file mode 100644 index 0000000000..652729e3b4 --- /dev/null +++ b/Libraries/LibWeb/HTML/MediaControls.css @@ -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; +} diff --git a/Libraries/LibWeb/HTML/MediaControls.h b/Libraries/LibWeb/HTML/MediaControls.h new file mode 100644 index 0000000000..4aa8bbe617 --- /dev/null +++ b/Libraries/LibWeb/HTML/MediaControls.h @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Gregory Bertilson + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace Web::HTML { + +class HTMLMediaElement; + +class MediaControls { +public: + explicit MediaControls(HTMLMediaElement&); + ~MediaControls(); + +private: + void create_shadow_tree(); + + enum class ListenOnce : bool { + No, + Yes, + }; + + template Handler> + GC::Ref add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, ListenOnce, Handler); + template Handler> + GC::Ref add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_nam, Handler); + template Handler> + GC::Ref add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, Handler); + template Handler> + GC::Ref add_event_listener(JS::Realm&, DOM::EventTarget&, FlyString const& event_name, ListenOnce, Handler); + template Handler> + GC::Ref 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 m_media_element; + + GC::Weak m_control_bar; + GC::Weak m_timeline_element; + GC::Weak m_timeline_fill; + GC::Weak m_play_button; + GC::Weak m_play_pause_icon; + GC::Weak m_timestamp_element; + GC::Weak m_mute_button; + GC::Weak m_volume_area; + GC::Weak m_volume_element; + GC::Weak m_volume_fill; + GC::Weak m_video_overlay; + GC::Weak m_placeholder_circle; + + struct RegisteredEventListener { + GC::Weak target; + FlyString event_name; + GC::Weak listener; + }; + Vector 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 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 }; +}; + +} diff --git a/Libraries/LibWeb/Layout/AudioBox.cpp b/Libraries/LibWeb/Layout/AudioBox.cpp index 0cb9a5654e..aa26fec379 100644 --- a/Libraries/LibWeb/Layout/AudioBox.cpp +++ b/Libraries/LibWeb/Layout/AudioBox.cpp @@ -6,7 +6,7 @@ #include #include -#include +#include namespace Web::Layout { @@ -27,16 +27,15 @@ HTML::HTMLAudioElement const& AudioBox::dom_node() const return static_cast(*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 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); } } diff --git a/Libraries/LibWeb/Layout/AudioBox.h b/Libraries/LibWeb/Layout/AudioBox.h index 5ef656924f..14193fe642 100644 --- a/Libraries/LibWeb/Layout/AudioBox.h +++ b/Libraries/LibWeb/Layout/AudioBox.h @@ -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 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); }; diff --git a/Libraries/LibWeb/Layout/VideoBox.cpp b/Libraries/LibWeb/Layout/VideoBox.cpp index 07058dbb19..214d3152bd 100644 --- a/Libraries/LibWeb/Layout/VideoBox.cpp +++ b/Libraries/LibWeb/Layout/VideoBox.cpp @@ -38,6 +38,12 @@ HTML::HTMLVideoElement const& VideoBox::dom_node() const return static_cast(*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(); diff --git a/Libraries/LibWeb/Layout/VideoBox.h b/Libraries/LibWeb/Layout/VideoBox.h index afecf9ad64..618d927177 100644 --- a/Libraries/LibWeb/Layout/VideoBox.h +++ b/Libraries/LibWeb/Layout/VideoBox.h @@ -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 create_paintable() const override; private: diff --git a/Libraries/LibWeb/Page/EventHandler.cpp b/Libraries/LibWeb/Page/EventHandler.cpp index 627eb6e78e..bf49a6fc0b 100644 --- a/Libraries/LibWeb/Page/EventHandler.cpp +++ b/Libraries/LibWeb/Page/EventHandler.cpp @@ -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(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) { diff --git a/Libraries/LibWeb/Painting/AudioPaintable.cpp b/Libraries/LibWeb/Painting/AudioPaintable.cpp deleted file mode 100644 index cca5cccc02..0000000000 --- a/Libraries/LibWeb/Painting/AudioPaintable.cpp +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2023, Tim Flynn - * - * SPDX-License-Identifier: BSD-2-Clause - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace Web::Painting { - -GC_DEFINE_ALLOCATOR(AudioPaintable); - -GC::Ref AudioPaintable::create(Layout::AudioBox const& layout_box) -{ - return layout_box.heap().allocate(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(*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()); - - 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); -} - -} diff --git a/Libraries/LibWeb/Painting/AudioPaintable.h b/Libraries/LibWeb/Painting/AudioPaintable.h deleted file mode 100644 index 0ba070055f..0000000000 --- a/Libraries/LibWeb/Painting/AudioPaintable.h +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2023, Tim Flynn - * - * SPDX-License-Identifier: BSD-2-Clause - */ - -#pragma once - -#include -#include - -namespace Web::Painting { - -class AudioPaintable final : public MediaPaintable { - GC_CELL(AudioPaintable, MediaPaintable); - GC_DECLARE_ALLOCATOR(AudioPaintable); - -public: - static GC::Ref create(Layout::AudioBox const&); - - virtual void paint(DisplayListRecordingContext&, PaintPhase) const override; - -private: - explicit AudioPaintable(Layout::AudioBox const&); -}; - -} diff --git a/Libraries/LibWeb/Painting/MediaPaintable.cpp b/Libraries/LibWeb/Painting/MediaPaintable.cpp deleted file mode 100644 index e786e52ad5..0000000000 --- a/Libraries/LibWeb/Painting/MediaPaintable.cpp +++ /dev/null @@ -1,459 +0,0 @@ -/* - * Copyright (c) 2023, Tim Flynn - * - * SPDX-License-Identifier: BSD-2-Clause - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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 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 coordinates, Color color) -{ - Gfx::Path path; - path.move_to((coordinates[0] + location).to_type()); - path.line_to((coordinates[1] + location).to_type()); - path.line_to((coordinates[2] + location).to_type()); - 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 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(), 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(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 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 play_button_coordinates { { - { 0, 0 }, - { static_cast(playback_button_size), static_cast(playback_button_size) / 2 }, - { 0, static_cast(playback_button_size) }, - } }; - - fill_triangle(context.display_list_recorder(), playback_button_location.to_type(), 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(), playback_button_color); - context.display_list_recorder().fill_rect(pause_button_right_rect.to_type(), 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(static_cast(components.timeline_rect.width())) * playback_percentage; - auto timeline_button_offset_x = static_cast(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(), 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(), 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(), 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 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().to_type(); - }; - - 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(), device_point(20, 15).to_type(), Color::Red, 2); - context.display_list_recorder().draw_line(device_point(0, 15).to_type(), device_point(20, 0).to_type(), Color::Red, 2); - } -} - -void MediaPaintable::paint_control_bar_volume(DisplayListRecordingContext& context, HTML::HTMLMediaElement const& media_element, Components const& components, Optional const& mouse_position) -{ - if (components.volume_rect.is_empty()) - return; - - auto volume_position = static_cast(static_cast(components.volume_scrub_rect.width())) * media_element.volume(); - auto volume_button_offset_x = static_cast(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(), 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(), 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(), volume_color); -} - -MediaPaintable::DispatchEventOfSameName MediaPaintable::handle_mousedown(Badge, 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(*navigable()).event_handler().set_mouse_event_tracking_paintable(this); - - return DispatchEventOfSameName::Yes; -} - -MediaPaintable::DispatchEventOfSameName MediaPaintable::handle_mouseup(Badge, 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(*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, 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(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(x_offset) / static_cast(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(x_offset) / static_cast(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 const& rect, Optional const& mouse_position, Optional 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); -} - -} diff --git a/Libraries/LibWeb/Painting/MediaPaintable.h b/Libraries/LibWeb/Painting/MediaPaintable.h deleted file mode 100644 index b6a65e3b27..0000000000 --- a/Libraries/LibWeb/Painting/MediaPaintable.h +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2023, Tim Flynn - * - * SPDX-License-Identifier: BSD-2-Clause - */ - -#pragma once - -#include -#include -#include -#include - -namespace Web::Painting { - -class MediaPaintable : public PaintableBox { - GC_CELL(MediaPaintable, PaintableBox); - -protected: - explicit MediaPaintable(Layout::ReplacedBox const&); - - static Optional mouse_position(DisplayListRecordingContext&, HTML::HTMLMediaElement const&); - static void fill_triangle(DisplayListRecorder& painter, Gfx::IntPoint location, Array coordinates, Color color); - - void paint_media_controls(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, DevicePixelRect media_rect, Optional const& mouse_position) const; - - auto& media_element() { return as(*dom_node()); } - -private: - struct Components { - DevicePixelRect control_box_rect; - DevicePixelRect playback_button_rect; - DevicePixelRect timeline_rect; - - Utf16String timestamp; - RefPtr 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, CSSPixelPoint, unsigned button, unsigned modifiers) override; - virtual DispatchEventOfSameName handle_mouseup(Badge, CSSPixelPoint, unsigned button, unsigned modifiers) override; - virtual DispatchEventOfSameName handle_mousemove(Badge, 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 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 const& mouse_position); - static void paint_control_bar_volume(DisplayListRecordingContext&, HTML::HTMLMediaElement const&, Components const&, Optional 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 const& rect, Optional const& mouse_position, Optional const& allowed_mouse_tracking_component = {}); -}; - -} diff --git a/Libraries/LibWeb/Painting/VideoPaintable.cpp b/Libraries/LibWeb/Painting/VideoPaintable.cpp index 154cf14ae4..85db44977b 100644 --- a/Libraries/LibWeb/Painting/VideoPaintable.cpp +++ b/Libraries/LibWeb/Painting/VideoPaintable.cpp @@ -1,10 +1,10 @@ /* * Copyright (c) 2023, Tim Flynn + * Copyright (c) 2026, Gregory Bertilso * * SPDX-License-Identifier: BSD-2-Clause */ -#include #include #include #include @@ -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::create(Layout::VideoBox const& layout_box) { return layout_box.heap().allocate(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(*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(), 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 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(static_cast(playback_button_offset_x)); - - auto playback_button_location = center.translated(-playback_button_offset_x, -playback_button_offset_y); - - Array play_button_coordinates { { - { 0, 0 }, - { static_cast(playback_button_size), static_cast(playback_button_size) / 2 }, - { 0, static_cast(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(), control_box_color); - fill_triangle(context.display_list_recorder(), playback_button_location.to_type(), play_button_coordinates, playback_button_color); -} - } diff --git a/Libraries/LibWeb/Painting/VideoPaintable.h b/Libraries/LibWeb/Painting/VideoPaintable.h index 856357cec2..b29505e0c2 100644 --- a/Libraries/LibWeb/Painting/VideoPaintable.h +++ b/Libraries/LibWeb/Painting/VideoPaintable.h @@ -1,5 +1,6 @@ /* * Copyright (c) 2023, Tim Flynn + * Copyright (c) 2026, Gregory Bertilso * * SPDX-License-Identifier: BSD-2-Clause */ @@ -7,12 +8,12 @@ #pragma once #include -#include +#include 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 const& mouse_position) const; }; } diff --git a/Meta/CMake/libweb_generators.cmake b/Meta/CMake/libweb_generators.cmake index 52e3037dc0..66f4a08d23 100644 --- a/Meta/CMake/libweb_generators.cmake +++ b/Meta/CMake/libweb_generators.cmake @@ -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" diff --git a/Tests/LibWeb/Layout/expected/hide-audio-based-on-controls.txt b/Tests/LibWeb/Layout/expected/hide-audio-based-on-controls.txt index 988e162ecb..872bd84c20 100644 --- a/Tests/LibWeb/Layout/expected/hide-audio-based-on-controls.txt +++ b/Tests/LibWeb/Layout/expected/hide-audio-based-on-controls.txt @@ -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 at [0,0] [0+0+0 800 0+0+0] [0+0+0 56 0+0+0] [BFC] children: not-inline - BlockContainer 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