From a66444968177b09a54f556d973f5cd524dcaa1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Jim=C3=A9nez=20Moreno?= Date: Tue, 16 Apr 2019 11:46:55 +0200 Subject: [PATCH] Media UI basic functionality --- components/embedder_traits/resources.rs | 6 +- components/layout/construct.rs | 6 +- components/script/dom/htmlmediaelement.rs | 27 +- components/script/dom/htmlstyleelement.rs | 6 +- components/script/dom/shadowroot.rs | 12 +- components/script/stylesheet_loader.rs | 3 +- ports/glutin/resources.rs | 5 +- ports/libsimpleservo/api/src/lib.rs | 5 +- resources/media_controls.css | 38 +++ resources/media_controls.js | 347 ++++++++++++++++++++-- 10 files changed, 420 insertions(+), 35 deletions(-) create mode 100644 resources/media_controls.css diff --git a/components/embedder_traits/resources.rs b/components/embedder_traits/resources.rs index 95799aef0d5..16f19e489dd 100644 --- a/components/embedder_traits/resources.rs +++ b/components/embedder_traits/resources.rs @@ -60,7 +60,8 @@ pub enum Resource { PresentationalHintsCSS, QuirksModeCSS, RippyPNG, - MediaControls, + MediaControlsCSS, + MediaControlsJS, } pub trait ResourceReaderMethods { @@ -95,7 +96,8 @@ fn resources_for_tests() -> Box { Resource::PresentationalHintsCSS => "presentational-hints.css", Resource::QuirksModeCSS => "quirks-mode.css", Resource::RippyPNG => "rippy.png", - Resource::MediaControls => "media_controls.js", + 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 f56da289dda..f108876d53c 100644 --- a/components/layout/construct.rs +++ b/components/layout/construct.rs @@ -706,7 +706,8 @@ impl<'a, ConcreteThreadSafeLayoutNode: ThreadSafeLayoutNode> let mut abs_descendants = AbsoluteDescendants::new(); let mut legalizer = Legalizer::new(); if !node.is_replaced_content() || - node.type_id() == Some(LayoutNodeType::Element(LayoutElementType::HTMLMediaElement)) { + node.type_id() == Some(LayoutNodeType::Element(LayoutElementType::HTMLMediaElement)) + { for kid in node.children() { if kid.get_pseudo_element_type() != PseudoElementType::Normal { self.process(&kid); @@ -1249,7 +1250,8 @@ impl<'a, ConcreteThreadSafeLayoutNode: ThreadSafeLayoutNode> // Go to a path that concatenates our kids' fragments. self.build_fragments_for_nonreplaced_inline_content(node) } else { - if node.type_id() == Some(LayoutNodeType::Element(LayoutElementType::HTMLMediaElement)) { + if node.type_id() == Some(LayoutNodeType::Element(LayoutElementType::HTMLMediaElement)) + { // Do not treat media elements as leafs. self.build_flow_for_block(node, None) } else { diff --git a/components/script/dom/htmlmediaelement.rs b/components/script/dom/htmlmediaelement.rs index 7be61332189..3e640fee358 100644 --- a/components/script/dom/htmlmediaelement.rs +++ b/components/script/dom/htmlmediaelement.rs @@ -41,6 +41,7 @@ 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; @@ -1737,21 +1738,41 @@ impl HTMLMediaElement { &document, ElementCreator::ScriptCreated, ); - let mut media_controls = resources::read_string(EmbedderResource::MediaControls); + 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 = media_controls.as_mut_str().replace("@@@id@@@", &id); + 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))); + .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); } diff --git a/components/script/dom/htmlstyleelement.rs b/components/script/dom/htmlstyleelement.rs index 8c8864c4626..cf64e316218 100644 --- a/components/script/dom/htmlstyleelement.rs +++ b/components/script/dom/htmlstyleelement.rs @@ -116,7 +116,11 @@ impl HTMLStyleElement { .upcast::() .containing_shadow_root() { if shadow_root.is_user_agent_widget() { - (ServoUrl::parse(&format!("chrome://{:?}", window.get_url().to_string())).unwrap(), Origin::UserAgent) + ( + ServoUrl::parse(&format!("chrome://{:?}", window.get_url().to_string())) + .unwrap(), + Origin::UserAgent, + ) } else { (window.get_url(), Origin::Author) } diff --git a/components/script/dom/shadowroot.rs b/components/script/dom/shadowroot.rs index d655b8468f5..47deb9067e6 100644 --- a/components/script/dom/shadowroot.rs +++ b/components/script/dom/shadowroot.rs @@ -52,7 +52,11 @@ pub struct ShadowRoot { impl ShadowRoot { #[allow(unrooted_must_root)] - fn new_inherited(host: &Element, document: &Document, is_widget: IsUserAgentWidget) -> ShadowRoot { + fn new_inherited( + host: &Element, + document: &Document, + is_widget: IsUserAgentWidget, + ) -> ShadowRoot { let document_fragment = DocumentFragment::new_inherited(document); let node = document_fragment.upcast::(); node.set_flag(NodeFlags::IS_IN_SHADOW_TREE, true); @@ -72,7 +76,11 @@ impl ShadowRoot { } } - pub fn new(host: &Element, document: &Document, is_widget: IsUserAgentWidget) -> DomRoot { + pub fn new( + host: &Element, + document: &Document, + is_widget: IsUserAgentWidget, + ) -> DomRoot { reflect_dom_object( Box::new(ShadowRoot::new_inherited(host, document, is_widget)), document.window(), diff --git a/components/script/stylesheet_loader.rs b/components/script/stylesheet_loader.rs index c2716264bb7..35697fd981f 100644 --- a/components/script/stylesheet_loader.rs +++ b/components/script/stylesheet_loader.rs @@ -143,7 +143,8 @@ impl FetchResponseListener for StylesheetContext { let protocol_encoding_label = metadata.charset.as_ref().map(|s| &**s); let final_url = if let Some(ref shadow_root) = self.shadow_root { if shadow_root.root().is_user_agent_widget() { - ServoUrl::parse(&format!("chrome://{:?}", metadata.final_url.to_string())).unwrap() + ServoUrl::parse(&format!("chrome://{:?}", metadata.final_url.to_string())) + .unwrap() } else { metadata.final_url } diff --git a/ports/glutin/resources.rs b/ports/glutin/resources.rs index 839ebdab1d4..d668f0ac247 100644 --- a/ports/glutin/resources.rs +++ b/ports/glutin/resources.rs @@ -28,8 +28,9 @@ fn filename(file: Resource) -> &'static str { Resource::ServoCSS => "servo.css", Resource::PresentationalHintsCSS => "presentational-hints.css", Resource::QuirksModeCSS => "quirks-mode.css", - Resource::RippyPNG => "rippy.png", - Resource::MediaControls => "media_controls.js", + 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 82e81f1f673..2f10059cbdb 100644 --- a/ports/libsimpleservo/api/src/lib.rs +++ b/ports/libsimpleservo/api/src/lib.rs @@ -702,7 +702,10 @@ impl ResourceReaderMethods for ResourceReaderInstance { Resource::BluetoothBlocklist => { &include_bytes!("../../../../resources/gatt_blocklist.txt")[..] }, - Resource::MediaControls => { + 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..5d9ab8ca6bf --- /dev/null +++ b/resources/media_controls.css @@ -0,0 +1,38 @@ +button { + display: inline-block; + width: 24px; + height: 24px; + border: none; +} + +.controls { + display: block; + position: fixed; + width: 100%; + padding: 0 9px; +} + +.hidden { + display: none; +} + +.playing { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAKklEQVR4AWMYWUABiB2gWAG3OGHQAMT/obgBl/igNGjUoFGDKM8iIwcAAPiWPtn7PkKeAAAAAElFTkSuQmCC") no-repeat; +} + +.paused { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAATElEQVR4AWMYmUCAWgY9AGIHahj0H4rXU+q6/0j4PRDHU24QAu8HYgXKDUK4rp4aBn0A4gZKDTpAvtcQrkigNLA3gKJ/4BMk3BWjAAA8rCr7cYjSfAAAAABJRU5ErkJggg==") no-repeat; +} + +.ended { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAoUlEQVR4AbXQobIBYQCG4Sf455x08mlGEkSNwCW4H8GMoOtWVUSKgksgyuQNxpgxdn5F2gXF89U3fQp+vRUsPkniJ0nUVvZUT7wvM1X10I+lKOroSh01X2Xwb+Pg73kG1F31gVc/re0AgJaKvKELAJCayJtIAYCZvRIAKNmbAwANUa/wWkNOIjMQQDCQGSsIRqKTra2zaCR4qCaxspKo+YobdwwzP00NEq4AAAAASUVORK5CYII=") no-repeat; +} + +.volumeup { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAhElEQVR4AbXRIQxBARQF0EMlGXrvIole0CudrFd6EugNSbKpeo/EH+wpiu3/J5j76glv9/p7LqbqKrMCJ+GsVUUC0Hd30ygnAaDnaVNOAmzVsFNoA8THQZhjKIwzdERXWGTo8EbLDM0wEibkj+8VOt8rWOdlDjzcNLNZjtksANdk4B/yAqWtTB5BB+CFAAAAAElFTkSuQmCC") no-repeat; +} + +.muted { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAvElEQVR4Aa3OAUcDARzG4adUpSAQECYRoEAium8QBH2D1AkcAZxKRkjQ56hhCIQADOwLDIANwGzGf5zDbHcD+70AD14rruHBbBeuLPQopIAmcuHfgbkyIcONwL4XQz2H1WzND+DMwK+ZTiUSrYJtI5UgFS4B+BPKyfCkb8+Wic9qFHKb2q7R1a5DIbPhCB3fdei5YOwaeZs/3iwRHwVb9+XEQu8lUjJQzdhxa7yckQstr8Kd2s4dg3sNK20KQZNRkQgu8yAAAAAASUVORK5CYII=") no-repeat; +} + diff --git a/resources/media_controls.js b/resources/media_controls.js index 216fad4d603..25489fbec22 100644 --- a/resources/media_controls.js +++ b/resources/media_controls.js @@ -1,23 +1,328 @@ -console.log('YO'); -let controls = document.servoGetMediaControls("@@@id@@@"); +/* 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/. */ -let style = document.createElement("style"); -style.textContent = `#controls { - -servo-top-layer: top; - display: block; - position: fixed; - left: 0px; - bottom: 0px; - height: 30px; - width: 100%; - background: blue; -}`; -controls.appendChild(style); +(() => { + "use strict"; -let div = document.createElement("div"); -div.setAttribute("id", "controls"); -let button = document.createElement("button"); -button.textContent = "Click me"; -div.appendChild(button); -controls.appendChild(div); -console.log('INNER', div.innerHTML); + 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); + let hours = Math.floor(time / 3600); + let mins = Math.floor((time % 3600) / 60); + let secs = Math.floor(time % 60); + let timeString; + if (secs < 10) { + secs = "0" + secs; + } + if (hours || showHours) { + if (mins < 10) { + mins = "0" + mins; + } + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + return timeString; + } + + class MediaControls { + constructor() { + // 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; + + // Create root element and load markup. + const root = document.createElement("div"); + root.classList.add("controls"); + root.innerHTML = MARKUP; + this.controls.appendChild(root); + + // Import elements. + this.elements = {}; + [ + "duration", + "play-pause-button", + "position-duration-box", + "volume-switch", + "volume-level" + ].forEach(id => { + this.elements[camelCase(id)] = this.controls.getElementById(id); + }); + + // Init position duration box. + const positionTextNode = Array.prototype.find.call( + this.elements.positionDurationBox.childNodes, + (n) => !!~n.textContent.search("#1") + ); + const durationSpan = this.elements.duration; + const durationFormat = durationSpan.textContent; + const positionFormat = positionTextNode.textContent; + + durationSpan.classList.add("duration"); + durationSpan.setAttribute("role", "none"); + durationSpan.id = "durationSpan"; + + 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: "change" }, + ]; + 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]; + + console.log(`transitioning from ${from} to ${to}`); + if (from == to) { + return; + } + + // Transition to the next state. + this.state = to; + this.onStateChange(from); + }; + } + + // Set initial state. + this.state = PAUSED; + this.onStateChange(null); + } + + // State change handler + onStateChange(from) { + this.render(from); + } + + render(from = this.state) { + // Error + if (this.state == ERRORED) { + //XXX render errored state + return; + } + + console.log(`render from ${from} to ${this.state}`); + if (this.state != from) { + // Play/Pause button. + const playPauseButton = this.elements.playPauseButton; + playPauseButton.classList.remove(from); + playPauseButton.classList.add(this.state); + } + + // Volume. + const volumeSwitchClass = this.media.muted || this.media.volume == 0 ? "muted" : "volumeup"; + if (!this.elements.volumeSwitch.classList.contains(volumeSwitchClass)) { + this.elements.volumeSwitch.classList = ""; + this.elements.volumeSwitch.classList.add(volumeSwitchClass); + } + const volumeLevelValue = Math.round((this.media.muted ? 0 : this.media.volume) * 100); + if (this.elements.volumeLevel.value != volumeLevelValue) { + this.elements.volumeLevel.value = volumeLevelValue; + } + + // Current time and duration. + let currentTime = "0:00"; + let duration = "0:00"; + 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); + } + + 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.muteUnmute(); + break; + } + break; + case "change": + switch (event.currentTarget) { + case this.elements.volumeLevel: + this.render(); + break; + } + break; + break; + throw new Error(`Unknown event ${event.type}`); + } + } + + // HTMLMediaElement event handler + onMediaEvent(event) { + console.log(event.type); + 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": + 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}`); + } + } + + muteUnmute() { + this.media.muted = !this.media.muted; + } + } + + new MediaControls(); +})();