mirror of
https://github.com/servo/servo.git
synced 2025-07-24 15:50:21 +01:00
Auto merge of #23208 - ferjm:media.ui, r=emilio,jdm
Media controls <strike>This is still highly WIP. It depends on #22743 and so it is based on top of it. The basic controls functionality is there, but the layout is highly broken. There is a hack to at least make the controls render on top of the video, but it is not correctly positioned. The controls' div container ends up as sibbling of the media element in the flow tree while IIUC it should end up as a child.</strike> - [X] `./mach build -d` does not report any errors - [x] `./mach test-tidy` does not report any errors - [X] These changes fix #22721 and fix #22720 There is at least an extra dependency to improve the functionality and visual aspect: #22728. <!-- Reviewable:start --> --- This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/servo/servo/23208) <!-- Reviewable:end -->
This commit is contained in:
commit
42b6b18f76
16 changed files with 678 additions and 71 deletions
|
@ -60,6 +60,8 @@ pub enum Resource {
|
|||
PresentationalHintsCSS,
|
||||
QuirksModeCSS,
|
||||
RippyPNG,
|
||||
MediaControlsCSS,
|
||||
MediaControlsJS,
|
||||
}
|
||||
|
||||
pub trait ResourceReaderMethods {
|
||||
|
@ -94,6 +96,8 @@ fn resources_for_tests() -> Box<dyn ResourceReaderMethods + Sync + Send> {
|
|||
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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<HashSet<Dom<ShadowRoot>>>,
|
||||
/// Whether any of the shadow roots need the stylesheets flushed.
|
||||
shadow_roots_styles_changed: Cell<bool>,
|
||||
/// 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<HashMap<String, Dom<ShadowRoot>>>,
|
||||
}
|
||||
|
||||
#[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<Promise> {
|
||||
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<DomRoot<ShadowRoot>> {
|
||||
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<u64>) {
|
||||
|
|
|
@ -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::<Node>()
|
||||
.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::<Node>().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)]
|
||||
|
|
|
@ -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<Option<HTMLMediaElementFetchContext>>,
|
||||
/// Player Id reported the player thread
|
||||
id: Cell<u64>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
/// <https://html.spec.whatwg.org/multipage/#dom-media-networkstate>
|
||||
|
@ -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::<Element>();
|
||||
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::<Node>()
|
||||
.SetTextContent(Some(DOMString::from(media_controls_script)));
|
||||
if let Err(e) = shadow_root
|
||||
.upcast::<Node>()
|
||||
.AppendChild(&*script.upcast::<Node>())
|
||||
{
|
||||
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::<Node>()
|
||||
.SetTextContent(Some(DOMString::from(media_controls_style)));
|
||||
|
||||
if let Err(e) = shadow_root
|
||||
.upcast::<Node>()
|
||||
.AppendChild(&*style.upcast::<Node>())
|
||||
{
|
||||
warn!("Could not render media controls {:?}", e);
|
||||
}
|
||||
|
||||
self.upcast::<Node>().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();
|
||||
}
|
||||
},
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 <form>.
|
||||
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 <form>.
|
||||
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>,
|
||||
|
|
|
@ -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<Document>,
|
||||
host: Dom<Element>,
|
||||
host: MutNullableDom<Element>,
|
||||
/// List of author styles associated with nodes in this shadow tree.
|
||||
author_styles: DomRefCell<AuthorStyles<StyleSheetInDocument>>,
|
||||
stylesheet_list: MutNullableDom<StyleSheetList>,
|
||||
|
@ -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>();
|
||||
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<DomRoot<Element>> {
|
||||
//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::<Node>()
|
||||
.dirty(NodeDamage::NodeStyleDamaged);
|
||||
if let Some(host) = self.host.get() {
|
||||
host.upcast::<Node>().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<Element> {
|
||||
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<ShadowRoot> {
|
|||
#[inline]
|
||||
#[allow(unsafe_code)]
|
||||
unsafe fn get_host_for_layout(&self) -> LayoutDom<Element> {
|
||||
(*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]
|
||||
|
|
|
@ -211,3 +211,9 @@ partial interface Document {
|
|||
};
|
||||
|
||||
Document implements DocumentOrShadowRoot;
|
||||
|
||||
// Servo internal API.
|
||||
partial interface Document {
|
||||
[Throws]
|
||||
ShadowRoot servoGetMediaControls(DOMString id);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -490,4 +490,6 @@ pub trait ThreadSafeLayoutElement:
|
|||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_shadow_host(&self) -> bool;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")[..]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
54
resources/media-controls.css
Normal file
54
resources/media-controls.css
Normal file
|
@ -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;
|
||||
}
|
||||
|
381
resources/media-controls.js
Normal file
381
resources/media-controls.js
Normal file
|
@ -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 = `
|
||||
<div class="controls">
|
||||
<button id="play-pause-button"></button>
|
||||
<input id="progress" type="range" value="0" min="0" max="100" step="1"></input>
|
||||
<span id="position-duration-box" class="hidden">
|
||||
<span id="position-text">#1</span>
|
||||
<span id="duration"> / #2</span>
|
||||
</span>
|
||||
<button id="volume-switch"></button>
|
||||
<input id="volume-level" type="range" value="100" min="0" max="100" step="1"></input>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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();
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue