diff --git a/components/embedder_traits/resources.rs b/components/embedder_traits/resources.rs index a29208fc67c..bd4a19c6d60 100644 --- a/components/embedder_traits/resources.rs +++ b/components/embedder_traits/resources.rs @@ -60,6 +60,8 @@ pub enum Resource { PresentationalHintsCSS, QuirksModeCSS, RippyPNG, + MediaControlsCSS, + MediaControlsJS, } pub trait ResourceReaderMethods { @@ -94,6 +96,8 @@ fn resources_for_tests() -> Box { Resource::PresentationalHintsCSS => "presentational-hints.css", Resource::QuirksModeCSS => "quirks-mode.css", Resource::RippyPNG => "rippy.png", + Resource::MediaControlsCSS => "media-controls.css", + Resource::MediaControlsJS => "media-controls.js", }; let mut path = env::current_exe().unwrap(); path = path.canonicalize().unwrap(); diff --git a/components/layout/construct.rs b/components/layout/construct.rs index 21e3611f446..072413995b2 100644 --- a/components/layout/construct.rs +++ b/components/layout/construct.rs @@ -705,9 +705,16 @@ impl<'a, ConcreteThreadSafeLayoutNode: ThreadSafeLayoutNode> // List of absolute descendants, in tree order. let mut abs_descendants = AbsoluteDescendants::new(); let mut legalizer = Legalizer::new(); - if !node.is_replaced_content() { + let is_media_element_with_widget = node.type_id() == + Some(LayoutNodeType::Element(LayoutElementType::HTMLMediaElement)) && + node.as_element().unwrap().is_shadow_host(); + if !node.is_replaced_content() || is_media_element_with_widget { for kid in node.children() { if kid.get_pseudo_element_type() != PseudoElementType::Normal { + if node.is_replaced_content() { + // Replaced elements don't have pseudo-elements per spec. + continue; + } self.process(&kid); } diff --git a/components/layout_thread/dom_wrapper.rs b/components/layout_thread/dom_wrapper.rs index 35463f69715..5d815517532 100644 --- a/components/layout_thread/dom_wrapper.rs +++ b/components/layout_thread/dom_wrapper.rs @@ -1397,6 +1397,10 @@ impl<'le> ThreadSafeLayoutElement for ServoThreadSafeLayoutElement<'le> { .expect("Unstyled layout node?") .borrow() } + + fn is_shadow_host(&self) -> bool { + self.element.shadow_root().is_some() + } } /// This implementation of `::selectors::Element` is used for implementing lazy diff --git a/components/script/dom/document.rs b/components/script/dom/document.rs index 1122fa52552..5b17c8ea004 100644 --- a/components/script/dom/document.rs +++ b/components/script/dom/document.rs @@ -20,6 +20,7 @@ use crate::dom::bindings::codegen::Bindings::HTMLIFrameElementBinding::HTMLIFram use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods; use crate::dom::bindings::codegen::Bindings::NodeFilterBinding::NodeFilter; use crate::dom::bindings::codegen::Bindings::PerformanceBinding::PerformanceMethods; +use crate::dom::bindings::codegen::Bindings::ShadowRootBinding::ShadowRootMethods; use crate::dom::bindings::codegen::Bindings::TouchBinding::TouchMethods; use crate::dom::bindings::codegen::Bindings::WindowBinding::{ FrameRequestCallback, ScrollBehavior, WindowMethods, @@ -163,6 +164,7 @@ use style::stylesheet_set::DocumentStylesheetSet; use style::stylesheets::{Origin, OriginSet, Stylesheet}; use url::percent_encoding::percent_decode; use url::Host; +use uuid::Uuid; /// The number of times we are allowed to see spurious `requestAnimationFrame()` calls before /// falling back to fake ones. @@ -385,6 +387,12 @@ pub struct Document { shadow_roots: DomRefCell>>, /// Whether any of the shadow roots need the stylesheets flushed. shadow_roots_styles_changed: Cell, + /// List of registered media controls. + /// We need to keep this list to allow the media controls to + /// access the "privileged" document.servoGetMediaControls(id) API, + /// where `id` needs to match any of the registered ShadowRoots + /// hosting the media controls UI. + media_controls: DomRefCell>>, } #[derive(JSTraceable, MallocSizeOf)] @@ -2457,6 +2465,23 @@ impl Document { self.responsive_images.borrow_mut().remove(i); } } + + pub fn register_media_controls(&self, controls: &ShadowRoot) -> String { + let id = Uuid::new_v4().to_string(); + self.media_controls + .borrow_mut() + .insert(id.clone(), Dom::from_ref(controls)); + id + } + + pub fn unregister_media_controls(&self, id: &str) { + if let Some(ref media_controls) = self.media_controls.borrow_mut().remove(id) { + let media_controls = DomRoot::from_ref(&**media_controls); + media_controls.Host().detach_shadow(); + } else { + debug_assert!(false, "Trying to unregister unknown media controls"); + } + } } #[derive(MallocSizeOf, PartialEq)] @@ -2750,6 +2775,7 @@ impl Document { delayed_tasks: Default::default(), shadow_roots: DomRefCell::new(HashSet::new()), shadow_roots_styles_changed: Cell::new(false), + media_controls: DomRefCell::new(HashMap::new()), } } @@ -4551,6 +4577,16 @@ impl DocumentMethods for Document { fn ExitFullscreen(&self) -> Rc { self.exit_fullscreen() } + + // check-tidy: no specs after this line + // Servo only API to get an instance of the controls of a specific + // media element matching the given id. + fn ServoGetMediaControls(&self, id: DOMString) -> Fallible> { + match self.media_controls.borrow().get(&*id) { + Some(m) => Ok(DomRoot::from_ref(&*m)), + None => Err(Error::InvalidAccess), + } + } } fn update_with_current_time_ms(marker: &Cell) { diff --git a/components/script/dom/element.rs b/components/script/dom/element.rs index 1145cb0fd7e..91d7a8aa450 100644 --- a/components/script/dom/element.rs +++ b/components/script/dom/element.rs @@ -78,7 +78,7 @@ use crate::dom::nodelist::NodeList; use crate::dom::promise::Promise; use crate::dom::raredata::ElementRareData; use crate::dom::servoparser::ServoParser; -use crate::dom::shadowroot::ShadowRoot; +use crate::dom::shadowroot::{IsUserAgentWidget, ShadowRoot}; use crate::dom::text::Text; use crate::dom::validation::Validatable; use crate::dom::virtualmethods::{vtable_for, VirtualMethods}; @@ -231,13 +231,6 @@ impl FromStr for AdjacentPosition { } } -/// Whether a shadow root hosts an User Agent widget. -#[derive(PartialEq)] -pub enum IsUserAgentWidget { - No, - Yes, -} - // // Element methods // @@ -498,14 +491,25 @@ impl Element { self.ensure_rare_data().shadow_root = Some(Dom::from_ref(&*shadow_root)); shadow_root .upcast::() - .set_containing_shadow_root(&shadow_root); + .set_containing_shadow_root(Some(&shadow_root)); if self.is_connected() { self.node.owner_doc().register_shadow_root(&*shadow_root); } + self.upcast::().dirty(NodeDamage::OtherNodeDamage); + Ok(shadow_root) } + + pub fn detach_shadow(&self) { + if let Some(ref shadow_root) = self.shadow_root() { + shadow_root.detach(); + self.ensure_rare_data().shadow_root = None; + } else { + debug_assert!(false, "Trying to detach a non-attached shadow root"); + } + } } #[allow(unsafe_code)] diff --git a/components/script/dom/htmlmediaelement.rs b/components/script/dom/htmlmediaelement.rs index 9ba07fe0c09..1b6f8ab1e3c 100644 --- a/components/script/dom/htmlmediaelement.rs +++ b/components/script/dom/htmlmediaelement.rs @@ -15,6 +15,7 @@ use crate::dom::bindings::codegen::Bindings::HTMLMediaElementBinding::HTMLMediaE use crate::dom::bindings::codegen::Bindings::HTMLSourceElementBinding::HTMLSourceElementMethods; use crate::dom::bindings::codegen::Bindings::MediaErrorBinding::MediaErrorConstants::*; use crate::dom::bindings::codegen::Bindings::MediaErrorBinding::MediaErrorMethods; +use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeBinding::NodeMethods; use crate::dom::bindings::codegen::Bindings::TextTrackBinding::{TextTrackKind, TextTrackMode}; use crate::dom::bindings::codegen::InheritTypes::{ElementTypeId, HTMLElementTypeId}; use crate::dom::bindings::codegen::InheritTypes::{HTMLMediaElementTypeId, NodeTypeId}; @@ -33,18 +34,21 @@ use crate::dom::document::Document; use crate::dom::element::{ cors_setting_for_element, reflect_cross_origin_attribute, set_cross_origin_attribute, }; -use crate::dom::element::{AttributeMutation, Element}; +use crate::dom::element::{AttributeMutation, Element, ElementCreator}; use crate::dom::event::Event; use crate::dom::eventtarget::EventTarget; use crate::dom::globalscope::GlobalScope; use crate::dom::htmlelement::HTMLElement; +use crate::dom::htmlscriptelement::HTMLScriptElement; use crate::dom::htmlsourceelement::HTMLSourceElement; +use crate::dom::htmlstyleelement::HTMLStyleElement; use crate::dom::htmlvideoelement::HTMLVideoElement; use crate::dom::mediaerror::MediaError; use crate::dom::mediastream::MediaStream; use crate::dom::node::{document_from_node, window_from_node, Node, NodeDamage, UnbindContext}; use crate::dom::performanceresourcetiming::InitiatorType; use crate::dom::promise::Promise; +use crate::dom::shadowroot::IsUserAgentWidget; use crate::dom::texttrack::TextTrack; use crate::dom::texttracklist::TextTrackList; use crate::dom::timeranges::{TimeRanges, TimeRangesContainer}; @@ -59,6 +63,7 @@ use crate::network_listener::{self, NetworkListener, PreInvoke, ResourceTimingLi use crate::script_thread::ScriptThread; use crate::task_source::TaskSource; use dom_struct::dom_struct; +use embedder_traits::resources::{self, Resource as EmbedderResource}; use euclid::Size2D; use headers::{ContentLength, ContentRange, HeaderMapExt}; use html5ever::{LocalName, Prefix}; @@ -335,6 +340,11 @@ pub struct HTMLMediaElement { current_fetch_context: DomRefCell>, /// Player Id reported the player thread id: Cell, + /// Media controls id. + /// In order to workaround the lack of privileged JS context, we secure the + /// the access to the "privileged" document.servoGetMediaControls(id) API by + /// keeping a whitelist of media controls identifiers. + media_controls_id: DomRefCell>, } /// @@ -397,6 +407,7 @@ impl HTMLMediaElement { next_timeupdate_event: Cell::new(time::get_time() + Duration::milliseconds(250)), current_fetch_context: DomRefCell::new(None), id: Cell::new(0), + media_controls_id: DomRefCell::new(None), } } @@ -1637,6 +1648,12 @@ impl HTMLMediaElement { // https://github.com/servo/media/issues/156 // Step 12 & 13 are already handled by the earlier media track processing. + + // We wait until we have metadata to render the controls, so we render them + // with the appropriate size. + if self.Controls() { + self.render_controls(); + } }, PlayerEvent::NeedData => { // The player needs more data. @@ -1712,6 +1729,68 @@ impl HTMLMediaElement { .start(0) .unwrap_or_else(|_| self.playback_position.get()) } + + fn render_controls(&self) { + let element = self.htmlelement.upcast::(); + if self.ready_state.get() < ReadyState::HaveMetadata || element.is_shadow_host() { + // Bail out if we have no metadata yet or + // if we are already showing the controls. + return; + } + let shadow_root = element.attach_shadow(IsUserAgentWidget::Yes).unwrap(); + let document = document_from_node(self); + let script = HTMLScriptElement::new( + local_name!("script"), + None, + &document, + ElementCreator::ScriptCreated, + ); + let mut media_controls_script = resources::read_string(EmbedderResource::MediaControlsJS); + // This is our hacky way to temporarily workaround the lack of a privileged + // JS context. + // The media controls UI accesses the document.servoGetMediaControls(id) API + // to get an instance to the media controls ShadowRoot. + // `id` needs to match the internally generated UUID assigned to a media element. + let id = document.register_media_controls(&shadow_root); + let media_controls_script = media_controls_script.as_mut_str().replace("@@@id@@@", &id); + *self.media_controls_id.borrow_mut() = Some(id); + script + .upcast::() + .SetTextContent(Some(DOMString::from(media_controls_script))); + if let Err(e) = shadow_root + .upcast::() + .AppendChild(&*script.upcast::()) + { + warn!("Could not render media controls {:?}", e); + return; + } + + let media_controls_style = resources::read_string(EmbedderResource::MediaControlsCSS); + let style = HTMLStyleElement::new( + local_name!("script"), + None, + &document, + ElementCreator::ScriptCreated, + ); + style + .upcast::() + .SetTextContent(Some(DOMString::from(media_controls_style))); + + if let Err(e) = shadow_root + .upcast::() + .AppendChild(&*style.upcast::()) + { + warn!("Could not render media controls {:?}", e); + } + + self.upcast::().dirty(NodeDamage::OtherNodeDamage); + } + + fn remove_controls(&self) { + if let Some(id) = self.media_controls_id.borrow_mut().take() { + document_from_node(self).unregister_media_controls(&id); + } + } } // XXX Placeholder for [https://github.com/servo/servo/issues/22293] @@ -1754,6 +1833,7 @@ impl Drop for HTMLMediaElement { .unwrap() .shutdown_player(&client_context_id, player.clone()); } + self.remove_controls(); } } @@ -1783,6 +1863,11 @@ impl HTMLMediaElementMethods for HTMLMediaElement { // https://html.spec.whatwg.org/multipage/#dom-media-defaultmuted make_bool_setter!(SetDefaultMuted, "muted"); + // https://html.spec.whatwg.org/multipage/#dom-media-controls + make_bool_getter!(Controls, "controls"); + // https://html.spec.whatwg.org/multipage/#dom-media-controls + make_bool_setter!(SetControls, "controls"); + // https://html.spec.whatwg.org/multipage/#dom-media-src make_url_getter!(Src, "src"); @@ -2177,19 +2262,23 @@ impl VirtualMethods for HTMLMediaElement { fn attribute_mutated(&self, attr: &Attr, mutation: AttributeMutation) { self.super_type().unwrap().attribute_mutated(attr, mutation); - if &local_name!("muted") == attr.local_name() { - self.SetMuted(mutation.new_value(attr).is_some()); - return; - } - - if mutation.new_value(attr).is_none() { - return; - } - match attr.local_name() { + &local_name!("muted") => { + self.SetMuted(mutation.new_value(attr).is_some()); + }, &local_name!("src") => { + if mutation.new_value(attr).is_none() { + return; + } self.media_element_load_algorithm(); }, + &local_name!("controls") => { + if mutation.new_value(attr).is_some() { + self.render_controls(); + } else { + self.remove_controls(); + } + }, _ => (), }; } diff --git a/components/script/dom/node.rs b/components/script/dom/node.rs index fe7f9ec29c2..8165d0c4eef 100644 --- a/components/script/dom/node.rs +++ b/components/script/dom/node.rs @@ -283,7 +283,7 @@ impl Node { for node in new_child.traverse_preorder(ShadowIncluding::No) { if parent_in_shadow_tree { if let Some(shadow_root) = self.containing_shadow_root() { - node.set_containing_shadow_root(&*shadow_root); + node.set_containing_shadow_root(Some(&*shadow_root)); } debug_assert!(node.containing_shadow_root().is_some()); } @@ -299,6 +299,36 @@ impl Node { } } + /// Clean up flags and unbind from tree. + pub fn complete_remove_subtree(root: &Node, context: &UnbindContext) { + for node in root.traverse_preorder(ShadowIncluding::Yes) { + // Out-of-document elements never have the descendants flag set. + node.set_flag( + NodeFlags::IS_IN_DOC | + NodeFlags::IS_CONNECTED | + NodeFlags::HAS_DIRTY_DESCENDANTS | + NodeFlags::HAS_SNAPSHOT | + NodeFlags::HANDLED_SNAPSHOT, + false, + ); + } + for node in root.traverse_preorder(ShadowIncluding::Yes) { + // This needs to be in its own loop, because unbind_from_tree may + // rely on the state of IS_IN_DOC of the context node's descendants, + // e.g. when removing a
. + vtable_for(&&*node).unbind_from_tree(&context); + node.style_and_layout_data.get().map(|d| node.dispose(d)); + // https://dom.spec.whatwg.org/#concept-node-remove step 14 + if let Some(element) = node.as_custom_element() { + ScriptThread::enqueue_callback_reaction( + &*element, + CallbackReaction::Disconnected, + None, + ); + } + } + } + /// Removes the given child from this node's list of children. /// /// Fails unless `child` is a child of this node. @@ -339,32 +369,7 @@ impl Node { child.parent_node.set(None); self.children_count.set(self.children_count.get() - 1); - for node in child.traverse_preorder(ShadowIncluding::Yes) { - // Out-of-document elements never have the descendants flag set. - node.set_flag( - NodeFlags::IS_IN_DOC | - NodeFlags::IS_CONNECTED | - NodeFlags::HAS_DIRTY_DESCENDANTS | - NodeFlags::HAS_SNAPSHOT | - NodeFlags::HANDLED_SNAPSHOT, - false, - ); - } - for node in child.traverse_preorder(ShadowIncluding::Yes) { - // This needs to be in its own loop, because unbind_from_tree may - // rely on the state of IS_IN_DOC of the context node's descendants, - // e.g. when removing a . - vtable_for(&&*node).unbind_from_tree(&context); - node.style_and_layout_data.get().map(|d| node.dispose(d)); - // https://dom.spec.whatwg.org/#concept-node-remove step 14 - if let Some(element) = node.as_custom_element() { - ScriptThread::enqueue_callback_reaction( - &*element, - CallbackReaction::Disconnected, - None, - ); - } - } + Self::complete_remove_subtree(child, &context); } pub fn to_untrusted_node_address(&self) -> UntrustedNodeAddress { @@ -961,8 +966,8 @@ impl Node { .map(|sr| DomRoot::from_ref(&**sr)) } - pub fn set_containing_shadow_root(&self, shadow_root: &ShadowRoot) { - self.ensure_rare_data().containing_shadow_root = Some(Dom::from_ref(shadow_root)); + pub fn set_containing_shadow_root(&self, shadow_root: Option<&ShadowRoot>) { + self.ensure_rare_data().containing_shadow_root = shadow_root.map(Dom::from_ref); } pub fn is_in_html_doc(&self) -> bool { @@ -3082,7 +3087,7 @@ pub struct UnbindContext<'a> { impl<'a> UnbindContext<'a> { /// Create a new `UnbindContext` value. - fn new( + pub fn new( parent: &'a Node, prev_sibling: Option<&'a Node>, next_sibling: Option<&'a Node>, diff --git a/components/script/dom/shadowroot.rs b/components/script/dom/shadowroot.rs index ae108f8109c..d6df87bded8 100644 --- a/components/script/dom/shadowroot.rs +++ b/components/script/dom/shadowroot.rs @@ -14,7 +14,7 @@ use crate::dom::document::Document; use crate::dom::documentfragment::DocumentFragment; use crate::dom::documentorshadowroot::{DocumentOrShadowRoot, StyleSheetInDocument}; use crate::dom::element::Element; -use crate::dom::node::{Node, NodeDamage, NodeFlags, ShadowIncluding}; +use crate::dom::node::{Node, NodeDamage, NodeFlags, ShadowIncluding, UnbindContext}; use crate::dom::stylesheetlist::{StyleSheetList, StyleSheetListOwner}; use crate::dom::window::Window; use crate::stylesheet_set::StylesheetSetRef; @@ -28,13 +28,20 @@ use style::media_queries::Device; use style::shared_lock::SharedRwLockReadGuard; use style::stylesheets::Stylesheet; +/// Whether a shadow root hosts an User Agent widget. +#[derive(JSTraceable, MallocSizeOf, PartialEq)] +pub enum IsUserAgentWidget { + No, + Yes, +} + // https://dom.spec.whatwg.org/#interface-shadowroot #[dom_struct] pub struct ShadowRoot { document_fragment: DocumentFragment, document_or_shadow_root: DocumentOrShadowRoot, document: Dom, - host: Dom, + host: MutNullableDom, /// List of author styles associated with nodes in this shadow tree. author_styles: DomRefCell>, stylesheet_list: MutNullableDom, @@ -55,7 +62,7 @@ impl ShadowRoot { document_fragment, document_or_shadow_root: DocumentOrShadowRoot::new(document.window()), document: Dom::from_ref(document), - host: Dom::from_ref(host), + host: MutNullableDom::new(Some(host)), author_styles: DomRefCell::new(AuthorStyles::new()), stylesheet_list: MutNullableDom::new(None), window: Dom::from_ref(document.window()), @@ -70,6 +77,14 @@ impl ShadowRoot { ) } + pub fn detach(&self) { + self.document.unregister_shadow_root(&self); + let node = self.upcast::(); + node.set_containing_shadow_root(None); + Node::complete_remove_subtree(&node, &UnbindContext::new(node, None, None, None)); + self.host.set(None); + } + pub fn get_focused_element(&self) -> Option> { //XXX get retargeted focused element None @@ -123,9 +138,9 @@ impl ShadowRoot { self.document.invalidate_shadow_roots_stylesheets(); self.author_styles.borrow_mut().stylesheets.force_dirty(); // Mark the host element dirty so a reflow will be performed. - self.host - .upcast::() - .dirty(NodeDamage::NodeStyleDamaged); + if let Some(host) = self.host.get() { + host.upcast::().dirty(NodeDamage::NodeStyleDamaged); + } } /// Remove any existing association between the provided id and any elements @@ -209,7 +224,8 @@ impl ShadowRootMethods for ShadowRoot { /// https://dom.spec.whatwg.org/#dom-shadowroot-host fn Host(&self) -> DomRoot { - DomRoot::from_ref(&self.host) + let host = self.host.get(); + host.expect("Trying to get host from a detached shadow root") } // https://drafts.csswg.org/cssom/#dom-document-stylesheets @@ -241,7 +257,10 @@ impl LayoutShadowRootHelpers for LayoutDom { #[inline] #[allow(unsafe_code)] unsafe fn get_host_for_layout(&self) -> LayoutDom { - (*self.unsafe_get()).host.to_layout() + (*self.unsafe_get()) + .host + .get_inner_as_layout() + .expect("We should never do layout on a detached shadow root") } #[inline] diff --git a/components/script/dom/webidls/Document.webidl b/components/script/dom/webidls/Document.webidl index 0127f8ecf98..ebcdea32166 100644 --- a/components/script/dom/webidls/Document.webidl +++ b/components/script/dom/webidls/Document.webidl @@ -211,3 +211,9 @@ partial interface Document { }; Document implements DocumentOrShadowRoot; + +// Servo internal API. +partial interface Document { + [Throws] + ShadowRoot servoGetMediaControls(DOMString id); +}; diff --git a/components/script/dom/webidls/HTMLMediaElement.webidl b/components/script/dom/webidls/HTMLMediaElement.webidl index 21ac720fd06..cdf32c98312 100644 --- a/components/script/dom/webidls/HTMLMediaElement.webidl +++ b/components/script/dom/webidls/HTMLMediaElement.webidl @@ -53,7 +53,7 @@ interface HTMLMediaElement : HTMLElement { void pause(); // controls - // [CEReactions] attribute boolean controls; + [CEReactions] attribute boolean controls; [Throws] attribute double volume; attribute boolean muted; [CEReactions] attribute boolean defaultMuted; diff --git a/components/script_layout_interface/wrapper_traits.rs b/components/script_layout_interface/wrapper_traits.rs index ae559474d06..8ce22e9e202 100644 --- a/components/script_layout_interface/wrapper_traits.rs +++ b/components/script_layout_interface/wrapper_traits.rs @@ -490,4 +490,6 @@ pub trait ThreadSafeLayoutElement: .clone(), } } + + fn is_shadow_host(&self) -> bool; } diff --git a/ports/glutin/resources.rs b/ports/glutin/resources.rs index 5a48037f524..64a1cdc44ff 100644 --- a/ports/glutin/resources.rs +++ b/ports/glutin/resources.rs @@ -29,6 +29,8 @@ fn filename(file: Resource) -> &'static str { Resource::PresentationalHintsCSS => "presentational-hints.css", Resource::QuirksModeCSS => "quirks-mode.css", Resource::RippyPNG => "rippy.png", + Resource::MediaControlsCSS => "media-controls.css", + Resource::MediaControlsJS => "media-controls.js", } } diff --git a/ports/libsimpleservo/api/src/lib.rs b/ports/libsimpleservo/api/src/lib.rs index 9dac35f1aa4..cbd7ee16fd7 100644 --- a/ports/libsimpleservo/api/src/lib.rs +++ b/ports/libsimpleservo/api/src/lib.rs @@ -702,6 +702,12 @@ impl ResourceReaderMethods for ResourceReaderInstance { Resource::BluetoothBlocklist => { &include_bytes!("../../../../resources/gatt_blocklist.txt")[..] }, + Resource::MediaControlsCSS => { + &include_bytes!("../../../../resources/media-controls.css")[..] + }, + Resource::MediaControlsJS => { + &include_bytes!("../../../../resources/media-controls.js")[..] + }, }) } diff --git a/resources/media-controls.css b/resources/media-controls.css new file mode 100644 index 00000000000..cbea462157c --- /dev/null +++ b/resources/media-controls.css @@ -0,0 +1,54 @@ +button { + display: inline-block; + width: 24px; + height: 24px; + min-width: var(--button-size); + min-height: var(--button-size); + padding: 6px; + border: 0; + margin: 0; + background-color: transparent; + background-repeat: no-repeat; + background-position: center; +} + +.root { + display: block; + position: relative; + min-height: 40px; + min-width: 200px; +} + +.controls { + position: absolute; + bottom: 0; + width: 100%; + height: 40px; + background-color: rgba(26,26,26,.8); + color: #ffffff; +} + +.hidden { + display: none; +} + +.playing { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAALElEQVR4AWMYQeD///8KQOwAxQq4xQkb1PAfARpwiQ9Kg0YNGjWI8iwycgAA8BVdyMCA4SwAAAAASUVORK5CYII=") no-repeat; +} + +.paused { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAATklEQVR4AWMYgeD///8C1DLoARA7UG4QAqynyHX/UcF7II6n3CAE2A/ECpQbhHBdPTUM+gDEDZQadIB8ryFckUBpYG8ARf/AJ0i4K0YBAOu3zFnxHnpDAAAAAElFTkSuQmCC") no-repeat; +} + +.ended { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAArElEQVR4AbWRIQ7CQBBFR7QBhcYRFKISVwQcgfsgmiDq8S22BgkGAxwBJBp0BSEkhOaxYs2wW6jhjZn8eeqPfEJbfkHItoECDRRgQq9eSrBQsWLgl1rsAJgyo+TG6IsmBrocudKp1ew25MVcNG5PHDjr05i+oy946qCkcKSCUgdrLgQqCUyy0VIMJE5rsWjIqUgJbfcpFUvfUzLgzsnMA8gIxQcROXszOZH8hTfry4rnukG7bgAAAABJRU5ErkJggg==") no-repeat; +} + +.volumeup { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAi0lEQVR4AWOgNfh/4n/Yfybc0g1gcs////8P/RfCoeT/fyjL9v+H/1f+c2NTAgRwnun/v/+nYCoBAzB7yX9GILni/8//IjBpFAAVyQKSLkA6BJ+i3UBSDEiX4lO0C6qoDJ+iTCDpCqRDCTl8JdDhooSDYDL+wLT7/xEYmDz4omU3ZrRgKj0NjmCqAwANy7W0uf6FTQAAAABJRU5ErkJggg==") no-repeat; +} + +.muted { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAy0lEQVR4AayRtUICUBRA7a7Jye5RF7u+wM4vsF2s0e7O37BzkZqZ+AF2WGk4XLqZeO/dPq/TUtuoZSki7mIgFloFlv3+icgOoKE8GlsH1sVOgOgy9jGjpzIuRjqv/rgNI3/hQCuD0t+8WK7Eywx6NfSGIAX+5t90CwOF5ODgNj4EO2TzxbDkdXwlgmCdLOolr+UlEbTn37QAK0fRBz8BAImvEYwMnmiJfdRzHxTAYoAQJjqPWWxJMf+3vHEAzCf+8A4avXYR9xSpm5QAaOvUDhZuhCsAAAAASUVORK5CYII=") no-repeat; +} + diff --git a/resources/media-controls.js b/resources/media-controls.js new file mode 100644 index 00000000000..383b025ee0e --- /dev/null +++ b/resources/media-controls.js @@ -0,0 +1,381 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +(() => { + "use strict"; + + const MARKUP = ` +
+ + + + + +
+ `; + + // States. + const BUFFERING = "buffering"; + const ENDED = "ended"; + const ERRORED = "errored"; + const PAUSED = "paused"; + const PLAYING = "playing"; + + // State transitions. + const TRANSITIONS = { + buffer: { + paused: BUFFERING + }, + end: { + playing: ENDED, + paused: ENDED + }, + error: { + buffering: ERRORED, + playing: ERRORED, + paused: ERRORED + }, + pause: { + buffering: PAUSED, + playing: PAUSED + }, + play: { + buffering: PLAYING, + ended: PLAYING, + paused: PLAYING + } + }; + + function camelCase(str) { + const rdashes = /-(.)/g; + return str.replace(rdashes, (str, p1) => { + return p1.toUpperCase(); + }); + } + + function formatTime(time, showHours = false) { + // Format the duration as "h:mm:ss" or "m:ss" + time = Math.round(time / 1000); + + const hours = Math.floor(time / 3600); + const mins = Math.floor((time % 3600) / 60); + const secs = Math.floor(time % 60); + + const formattedHours = + hours || showHours ? `${hours.toString().padStart(2, "0")}:` : ""; + + return `${formattedHours}${mins + .toString() + .padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + + class MediaControls { + constructor() { + this.nonce = Date.now(); + // Get the instance of the shadow root where these controls live. + this.controls = document.servoGetMediaControls("@@@id@@@"); + // Get the instance of the host of these controls. + this.media = this.controls.host; + + this.mutationObserver = new MutationObserver(() => { + // We can only get here if the `controls` attribute is removed. + this.cleanup(); + }); + this.mutationObserver.observe(this.media, { + attributeFilter: ["controls"] + }); + + // Create root element and load markup. + this.root = document.createElement("div"); + this.root.classList.add("root"); + this.root.innerHTML = MARKUP; + this.controls.appendChild(this.root); + + // Import elements. + this.elements = {}; + [ + "duration", + "play-pause-button", + "position-duration-box", + "position-text", + "progress", + "volume-switch", + "volume-level" + ].forEach(id => { + this.elements[camelCase(id)] = this.controls.getElementById(id); + }); + + // Init position duration box. + const positionTextNode = this.elements.positionText; + const durationSpan = this.elements.duration; + const durationFormat = durationSpan.textContent; + const positionFormat = positionTextNode.textContent; + + durationSpan.classList.add("duration"); + durationSpan.setAttribute("role", "none"); + + Object.defineProperties(this.elements.positionDurationBox, { + durationSpan: { + value: durationSpan + }, + position: { + get: () => { + return positionTextNode.textContent; + }, + set: v => { + positionTextNode.textContent = positionFormat.replace("#1", v); + } + }, + duration: { + get: () => { + return durationSpan.textContent; + }, + set: v => { + durationSpan.textContent = v ? durationFormat.replace("#2", v) : ""; + } + }, + show: { + value: (currentTime, duration) => { + const self = this.elements.positionDurationBox; + if (self.position != currentTime) { + self.position = currentTime; + } + if (self.duration != duration) { + self.duration = duration; + } + self.classList.remove("hidden"); + } + } + }); + + // Add event listeners. + this.mediaEvents = [ + "play", + "pause", + "ended", + "volumechange", + "loadeddata", + "loadstart", + "timeupdate", + "progress", + "playing", + "waiting", + "canplay", + "canplaythrough", + "seeking", + "seeked", + "emptied", + "loadedmetadata", + "error", + "suspend" + ]; + this.mediaEvents.forEach(event => { + this.media.addEventListener(event, this); + }); + + this.controlEvents = [ + { el: this.elements.playPauseButton, type: "click" }, + { el: this.elements.volumeSwitch, type: "click" }, + { el: this.elements.volumeLevel, type: "input" } + ]; + this.controlEvents.forEach(({ el, type }) => { + el.addEventListener(type, this); + }); + + // Create state transitions. + // + // It exposes one method per transition. i.e. this.pause(), this.play(), etc. + // For each transition, we check that the transition is possible and call + // the `onStateChange` handler. + for (let name in TRANSITIONS) { + if (!TRANSITIONS.hasOwnProperty(name)) { + continue; + } + this[name] = () => { + const from = this.state; + + // Checks if the transition is valid in the current state. + if (!TRANSITIONS[name][from]) { + const error = `Transition "${name}" invalid for the current state "${from}"`; + console.error(error); + throw new Error(error); + } + + const to = TRANSITIONS[name][from]; + + if (from == to) { + return; + } + + // Transition to the next state. + this.state = to; + this.onStateChange(from); + }; + } + + // Set initial state. + this.state = this.media.paused ? PAUSED : PLAYING; + this.onStateChange(null); + } + + cleanup() { + this.mutationObserver.disconnect(); + this.mediaEvents.forEach(event => { + this.media.removeEventListener(event, this); + }); + this.controlEvents.forEach(({ el, type }) => { + el.removeEventListener(type, this); + }); + } + + // State change handler + onStateChange(from) { + this.render(from); + } + + render(from = this.state) { + const isAudioOnly = this.media.localName == "audio"; + if (!isAudioOnly) { + // XXX This should ideally use clientHeight/clientWidth, + // but for some reason I couldn't figure out yet, + // using it breaks layout. + this.root.style.height = this.media.videoHeight; + this.root.style.width = this.media.videoWidth; + } + + // Error + if (this.state == ERRORED) { + //XXX render errored state + return; + } + + if (this.state != from) { + // Play/Pause button. + const playPauseButton = this.elements.playPauseButton; + playPauseButton.classList.remove(from); + playPauseButton.classList.add(this.state); + } + + // Progress. + const positionPercent = + (this.media.currentTime / this.media.duration) * 100; + if (Number.isFinite(positionPercent)) { + this.elements.progress.value = positionPercent; + } else { + this.elements.progress.value = 0; + } + + // Current time and duration. + let currentTime = formatTime(0); + let duration = formatTime(0); + if (!isNaN(this.media.currentTime) && !isNaN(this.media.duration)) { + currentTime = formatTime(Math.round(this.media.currentTime * 1000)); + duration = formatTime(Math.round(this.media.duration * 1000)); + } + this.elements.positionDurationBox.show(currentTime, duration); + + // Volume. + this.elements.volumeSwitch.className = + this.media.muted || !this.media.volume ? "muted" : "volumeup"; + const volumeLevelValue = this.media.muted + ? 0 + : Math.round(this.media.volume * 100); + if (this.elements.volumeLevel.value != volumeLevelValue) { + this.elements.volumeLevel.value = volumeLevelValue; + } + } + + handleEvent(event) { + if (!event.isTrusted) { + console.warn(`Drop untrusted event ${event.type}`); + return; + } + + if (this.mediaEvents.includes(event.type)) { + this.onMediaEvent(event); + } else { + this.onControlEvent(event); + } + } + + onControlEvent(event) { + switch (event.type) { + case "click": + switch (event.currentTarget) { + case this.elements.playPauseButton: + this.playOrPause(); + break; + case this.elements.volumeSwitch: + this.toggleMuted(); + break; + } + break; + case "input": + switch (event.currentTarget) { + case this.elements.volumeLevel: + this.changeVolume(); + break; + } + break; + default: + throw new Error(`Unknown event ${event.type}`); + } + } + + // HTMLMediaElement event handler + onMediaEvent(event) { + switch (event.type) { + case "ended": + this.end(); + break; + case "play": + case "pause": + // Transition to PLAYING or PAUSED state. + this[event.type](); + break; + case "volumechange": + case "timeupdate": + case "resize": + this.render(); + break; + case "loadedmetadata": + break; + } + } + + /* Media actions */ + + playOrPause() { + switch (this.state) { + case PLAYING: + this.media.pause(); + break; + case BUFFERING: + case ENDED: + case PAUSED: + this.media.play(); + break; + default: + throw new Error(`Invalid state ${this.state}`); + } + } + + toggleMuted() { + this.media.muted = !this.media.muted; + } + + changeVolume() { + const volume = parseInt(this.elements.volumeLevel.value); + if (!isNaN(volume)) { + this.media.volume = volume / 100; + } + } + } + + new MediaControls(); +})(); + diff --git a/tests/wpt/metadata/html/dom/interfaces.https.html.ini b/tests/wpt/metadata/html/dom/interfaces.https.html.ini index 1dd351142d6..3dca62fda1f 100644 --- a/tests/wpt/metadata/html/dom/interfaces.https.html.ini +++ b/tests/wpt/metadata/html/dom/interfaces.https.html.ini @@ -6510,9 +6510,6 @@ [HTMLMediaElement interface: document.createElement("video") must inherit property "seekable" with the proper type] expected: FAIL - [HTMLMediaElement interface: document.createElement("video") must inherit property "controls" with the proper type] - expected: FAIL - [HTMLMediaElement interface: document.createElement("audio") must inherit property "srcObject" with the proper type] expected: FAIL @@ -6522,9 +6519,6 @@ [HTMLMediaElement interface: document.createElement("audio") must inherit property "seekable" with the proper type] expected: FAIL - [HTMLMediaElement interface: document.createElement("audio") must inherit property "controls" with the proper type] - expected: FAIL - [HTMLMediaElement interface: new Audio() must inherit property "srcObject" with the proper type] expected: FAIL @@ -6534,9 +6528,6 @@ [HTMLMediaElement interface: new Audio() must inherit property "seekable" with the proper type] expected: FAIL - [HTMLMediaElement interface: new Audio() must inherit property "controls" with the proper type] - expected: FAIL - [HTMLMediaElement interface: operation getStartDate()] expected: FAIL @@ -6546,9 +6537,6 @@ [HTMLMediaElement interface: operation play()] expected: FAIL - [HTMLMediaElement interface: attribute controls] - expected: FAIL - [HTMLMapElement interface: attribute name] expected: FAIL