diff options
37 files changed, 1112 insertions, 21 deletions
diff --git a/components/compositing/windowing.rs b/components/compositing/windowing.rs index decece701f4..cdc1b7ff883 100644 --- a/components/compositing/windowing.rs +++ b/components/compositing/windowing.rs @@ -10,7 +10,7 @@ use euclid::Scale; use gleam::gl; use keyboard_types::KeyboardEvent; use msg::constellation_msg::{PipelineId, TopLevelBrowsingContextId, TraversalDirection}; -use script_traits::{MouseButton, TouchEventType, TouchId, WheelDelta}; +use script_traits::{MediaSessionActionType, MouseButton, TouchEventType, TouchId, WheelDelta}; use servo_geometry::DeviceIndependentPixel; use servo_media::player::context::{GlApi, GlContext, NativeDisplay}; use servo_url::ServoUrl; @@ -102,6 +102,9 @@ pub enum WindowEvent { CaptureWebRender, /// Toggle sampling profiler with the given sampling rate and max duration. ToggleSamplingProfiler(Duration, Duration), + /// Sent when the user triggers a media action through the UA exposed media UI + /// (play, pause, seek, etc.). + MediaSessionAction(MediaSessionActionType), } impl Debug for WindowEvent { @@ -132,6 +135,7 @@ impl Debug for WindowEvent { WindowEvent::CaptureWebRender => write!(f, "CaptureWebRender"), WindowEvent::ToggleSamplingProfiler(..) => write!(f, "ToggleSamplingProfiler"), WindowEvent::ExitFullScreen(..) => write!(f, "ExitFullScreen"), + WindowEvent::MediaSessionAction(..) => write!(f, "MediaSessionAction"), } } } diff --git a/components/constellation/constellation.rs b/components/constellation/constellation.rs index c24883f99e5..82b99938f3b 100644 --- a/components/constellation/constellation.rs +++ b/components/constellation/constellation.rs @@ -112,6 +112,7 @@ use compositing::SendableFrameTree; use crossbeam_channel::{after, never, unbounded, Receiver, Sender}; use devtools_traits::{ChromeToDevtoolsControlMsg, DevtoolsControlMsg}; use embedder_traits::{Cursor, EmbedderMsg, EmbedderProxy, EventLoopWaker}; +use embedder_traits::{MediaSessionEvent, MediaSessionPlaybackState}; use euclid::{default::Size2D as UntypedSize2D, Size2D}; use gfx::font_cache_thread::FontCacheThread; use gfx_traits::Epoch; @@ -139,7 +140,6 @@ use net_traits::{self, FetchResponseMsg, IpcSend, ResourceThreads}; use profile_traits::mem; use profile_traits::time; use script_traits::CompositorEvent::{MouseButtonEvent, MouseMoveEvent}; -use script_traits::MouseEventType; use script_traits::{webdriver_msg, LogEntry, ScriptToConstellationChan, ServiceWorkerMsg}; use script_traits::{ AnimationState, AnimationTickType, AuxiliaryBrowsingContextLoadInfo, CompositorEvent, @@ -153,6 +153,7 @@ use script_traits::{ IFrameLoadInfo, IFrameLoadInfoWithData, IFrameSandboxState, TimerSchedulerMsg, }; use script_traits::{LayoutMsg as FromLayoutMsg, ScriptMsg as FromScriptMsg, ScriptThreadFactory}; +use script_traits::{MediaSessionActionType, MouseEventType}; use script_traits::{MessagePortMsg, PortMessageTask, StructuredSerializedData}; use script_traits::{SWManagerMsg, ScopeThings, UpdatePipelineIdReason, WebDriverCommandMsg}; use serde::{Deserialize, Serialize}; @@ -474,6 +475,9 @@ pub struct Constellation<Message, LTF, STF> { /// Mechanism to force the compositor to process events. event_loop_waker: Option<Box<dyn EventLoopWaker>>, + + /// Pipeline ID of the active media session. + active_media_session: Option<PipelineId>, } /// State needed to construct a constellation. @@ -843,6 +847,7 @@ where glplayer_threads: state.glplayer_threads, player_context: state.player_context, event_loop_waker: state.event_loop_waker, + active_media_session: None, }; constellation.run(); @@ -1541,6 +1546,9 @@ where FromCompositorMsg::ExitFullScreen(top_level_browsing_context_id) => { self.handle_exit_fullscreen_msg(top_level_browsing_context_id); }, + FromCompositorMsg::MediaSessionAction(action) => { + self.handle_media_session_action_msg(action); + }, } } @@ -1771,6 +1779,31 @@ where new_value, ); }, + FromScriptMsg::MediaSessionEvent(pipeline_id, event) => { + // Unlikely at this point, but we may receive events coming from + // different media sessions, so we set the active media session based + // on Playing events. + // The last media session claiming to be in playing state is set to + // the active media session. + // Events coming from inactive media sessions are discarded. + if self.active_media_session.is_some() { + match event { + MediaSessionEvent::PlaybackStateChange(ref state) => { + match state { + MediaSessionPlaybackState::Playing | + MediaSessionPlaybackState::Paused => (), + _ => return, + }; + }, + _ => (), + }; + } + self.active_media_session = Some(pipeline_id); + self.embedder_proxy.send(( + Some(source_top_ctx_id), + EmbedderMsg::MediaSessionEvent(event), + )); + }, } } @@ -5019,4 +5052,29 @@ where .send(ToCompositorMsg::SetFrameTree(frame_tree)); } } + + fn handle_media_session_action_msg(&mut self, action: MediaSessionActionType) { + if let Some(media_session_pipeline_id) = self.active_media_session { + let result = match self.pipelines.get(&media_session_pipeline_id) { + None => { + return warn!( + "Pipeline {} got media session action request after closure.", + media_session_pipeline_id, + ) + }, + Some(pipeline) => { + let msg = ConstellationControlMsg::MediaSessionAction( + media_session_pipeline_id, + action, + ); + pipeline.event_loop.send(msg) + }, + }; + if let Err(e) = result { + self.handle_send_error(media_session_pipeline_id, e); + } + } else { + error!("Got a media session action but no active media session is registered"); + } + } } diff --git a/components/embedder_traits/lib.rs b/components/embedder_traits/lib.rs index 949b3ba5acb..9d4e56dd297 100644 --- a/components/embedder_traits/lib.rs +++ b/components/embedder_traits/lib.rs @@ -162,6 +162,9 @@ pub enum EmbedderMsg { Shutdown, /// Report a complete sampled profile ReportProfile(Vec<u8>), + /// Notifies the embedder about media session events + /// (i.e. when there is metadata for the active media session, playback state changes...). + MediaSessionEvent(MediaSessionEvent), } impl Debug for EmbedderMsg { @@ -194,6 +197,7 @@ impl Debug for EmbedderMsg { EmbedderMsg::AllowOpeningBrowser(..) => write!(f, "AllowOpeningBrowser"), EmbedderMsg::BrowserCreated(..) => write!(f, "BrowserCreated"), EmbedderMsg::ReportProfile(..) => write!(f, "ReportProfile"), + EmbedderMsg::MediaSessionEvent(..) => write!(f, "MediaSessionEvent"), } } } @@ -202,3 +206,45 @@ impl Debug for EmbedderMsg { /// the `String` content is expected to be extension (e.g, "doc", without the prefixing ".") #[derive(Clone, Debug, Deserialize, Serialize)] pub struct FilterPattern(pub String); + +/// https://w3c.github.io/mediasession/#mediametadata +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MediaMetadata { + /// Title + pub title: String, + /// Artist + pub artist: String, + /// Album + pub album: String, +} + +impl MediaMetadata { + pub fn new(title: String) -> Self { + Self { + title, + artist: "".to_owned(), + album: "".to_owned(), + } + } +} + +/// https://w3c.github.io/mediasession/#enumdef-mediasessionplaybackstate +#[repr(i32)] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum MediaSessionPlaybackState { + /// The browsing context does not specify whether it’s playing or paused. + None_ = 1, + /// The browsing context is currently playing media and it can be paused. + Playing, + /// The browsing context has paused media and it can be resumed. + Paused, +} + +/// Type of events sent from script to the embedder about the media session. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum MediaSessionEvent { + /// Indicates that the media metadata is available. + SetMetadata(MediaMetadata), + /// Indicates that the playback state has changed. + PlaybackStateChange(MediaSessionPlaybackState), +} diff --git a/components/script/dom/bindings/trace.rs b/components/script/dom/bindings/trace.rs index 74e6996b67a..2051a1a19d8 100644 --- a/components/script/dom/bindings/trace.rs +++ b/components/script/dom/bindings/trace.rs @@ -57,7 +57,7 @@ use content_security_policy::CspList; use crossbeam_channel::{Receiver, Sender}; use cssparser::RGBA; use devtools_traits::{CSSError, TimelineMarkerType, WorkerId}; -use embedder_traits::EventLoopWaker; +use embedder_traits::{EventLoopWaker, MediaMetadata}; use encoding_rs::{Decoder, Encoding}; use euclid::default::{Point2D, Rect, Rotation3D, Transform2D, Transform3D}; use euclid::Length as EuclidLength; @@ -94,8 +94,8 @@ use profile_traits::time::ProfilerChan as TimeProfilerChan; use script_layout_interface::rpc::LayoutRPC; use script_layout_interface::OpaqueStyleAndLayoutData; use script_traits::transferable::MessagePortImpl; -use script_traits::DrawAPaintImageResult; -use script_traits::{DocumentActivity, ScriptToConstellationChan, TimerEventId, TimerSource}; +use script_traits::{DocumentActivity, DrawAPaintImageResult}; +use script_traits::{MediaSessionActionType, ScriptToConstellationChan, TimerEventId, TimerSource}; use script_traits::{UntrustedNodeAddress, WindowSizeData, WindowSizeType}; use selectors::matching::ElementSelectorFlags; use serde::{Deserialize, Serialize}; @@ -536,6 +536,8 @@ unsafe_no_jsmanaged_fields!(WindowGLContext); unsafe_no_jsmanaged_fields!(VideoFrame); unsafe_no_jsmanaged_fields!(WebGLContextId); unsafe_no_jsmanaged_fields!(Arc<Mutex<dyn AudioRenderer>>); +unsafe_no_jsmanaged_fields!(MediaSessionActionType); +unsafe_no_jsmanaged_fields!(MediaMetadata); unsafe impl<'a> JSTraceable for &'a str { #[inline] diff --git a/components/script/dom/htmlmediaelement.rs b/components/script/dom/htmlmediaelement.rs index d10801c5779..f8f707a0dd9 100644 --- a/components/script/dom/htmlmediaelement.rs +++ b/components/script/dom/htmlmediaelement.rs @@ -15,8 +15,10 @@ 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::NavigatorBinding::NavigatorBinding::NavigatorMethods; use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeBinding::NodeMethods; use crate::dom::bindings::codegen::Bindings::TextTrackBinding::{TextTrackKind, TextTrackMode}; +use crate::dom::bindings::codegen::Bindings::WindowBinding::WindowBinding::WindowMethods; use crate::dom::bindings::codegen::InheritTypes::{ElementTypeId, HTMLElementTypeId}; use crate::dom::bindings::codegen::InheritTypes::{HTMLMediaElementTypeId, NodeTypeId}; use crate::dom::bindings::codegen::UnionTypes::{ @@ -65,6 +67,7 @@ use crate::script_thread::ScriptThread; use crate::task_source::TaskSource; use dom_struct::dom_struct; use embedder_traits::resources::{self, Resource as EmbedderResource}; +use embedder_traits::{MediaSessionEvent, MediaSessionPlaybackState}; use euclid::default::Size2D; use headers::{ContentLength, ContentRange, HeaderMapExt}; use html5ever::{LocalName, Prefix}; @@ -592,7 +595,6 @@ impl HTMLMediaElement { match (old_ready_state, ready_state) { (ReadyState::HaveNothing, ReadyState::HaveMetadata) => { task_source.queue_simple_event(self.upcast(), atom!("loadedmetadata"), &window); - // No other steps are applicable in this case. return; }, @@ -1725,6 +1727,17 @@ impl HTMLMediaElement { if self.Controls() { self.render_controls(); } + + let global = self.global(); + let window = global.as_window(); + + // Update the media session metadata title with the obtained metadata. + window.Navigator().MediaSession().update_title( + metadata + .title + .clone() + .unwrap_or(window.get_url().into_string()), + ); }, PlayerEvent::NeedData => { // The player needs more data. @@ -1782,13 +1795,33 @@ impl HTMLMediaElement { }; ScriptThread::await_stable_state(Microtask::MediaElement(task)); }, - PlayerEvent::StateChanged(ref state) => match *state { - PlaybackState::Paused => { - if self.ready_state.get() == ReadyState::HaveMetadata { - self.change_ready_state(ReadyState::HaveEnoughData); - } - }, - _ => {}, + PlayerEvent::StateChanged(ref state) => { + let mut media_session_playback_state = MediaSessionPlaybackState::None_; + match *state { + PlaybackState::Paused => { + media_session_playback_state = MediaSessionPlaybackState::Paused; + if self.ready_state.get() == ReadyState::HaveMetadata { + self.change_ready_state(ReadyState::HaveEnoughData); + } + }, + PlaybackState::Playing => { + media_session_playback_state = MediaSessionPlaybackState::Playing; + }, + PlaybackState::Buffering => { + // Do not send the media session playback state change event + // in this case as a None_ state is expected to clean up the + // session. + return; + }, + _ => {}, + }; + debug!( + "Sending media session event playback state changed to {:?}", + media_session_playback_state + ); + self.send_media_session_event(MediaSessionEvent::PlaybackStateChange( + media_session_playback_state, + )); }, } } @@ -1883,6 +1916,15 @@ impl HTMLMediaElement { self.media_element_load_algorithm(); } } + + fn send_media_session_event(&self, event: MediaSessionEvent) { + let global = self.global(); + let media_session = global.as_window().Navigator().MediaSession(); + + media_session.register_media_instance(&self); + + media_session.send_event(event); + } } // XXX Placeholder for [https://github.com/servo/servo/issues/22293] diff --git a/components/script/dom/mediametadata.rs b/components/script/dom/mediametadata.rs new file mode 100644 index 00000000000..f2e94abfaa1 --- /dev/null +++ b/components/script/dom/mediametadata.rs @@ -0,0 +1,97 @@ +/* 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 crate::dom::bindings::cell::DomRefCell; +use crate::dom::bindings::codegen::Bindings::MediaMetadataBinding; +use crate::dom::bindings::codegen::Bindings::MediaMetadataBinding::MediaMetadataInit; +use crate::dom::bindings::codegen::Bindings::MediaMetadataBinding::MediaMetadataMethods; +use crate::dom::bindings::error::Fallible; +use crate::dom::bindings::reflector::{reflect_dom_object, Reflector}; +use crate::dom::bindings::root::{DomRoot, MutNullableDom}; +use crate::dom::bindings::str::DOMString; +use crate::dom::mediasession::MediaSession; +use crate::dom::window::Window; +use dom_struct::dom_struct; + +#[dom_struct] +pub struct MediaMetadata { + reflector_: Reflector, + session: MutNullableDom<MediaSession>, + title: DomRefCell<DOMString>, + artist: DomRefCell<DOMString>, + album: DomRefCell<DOMString>, +} + +impl MediaMetadata { + fn new_inherited(init: &MediaMetadataInit) -> MediaMetadata { + MediaMetadata { + reflector_: Reflector::new(), + session: Default::default(), + title: DomRefCell::new(init.title.clone()), + artist: DomRefCell::new(init.artist.clone()), + album: DomRefCell::new(init.album.clone()), + } + } + + pub fn new(global: &Window, init: &MediaMetadataInit) -> DomRoot<MediaMetadata> { + reflect_dom_object( + Box::new(MediaMetadata::new_inherited(init)), + global, + MediaMetadataBinding::Wrap, + ) + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-mediametadata + pub fn Constructor( + window: &Window, + init: &MediaMetadataInit, + ) -> Fallible<DomRoot<MediaMetadata>> { + Ok(MediaMetadata::new(window, init)) + } + + fn queue_update_metadata_algorithm(&self) { + if self.session.get().is_none() { + return; + } + } + + pub fn set_session(&self, session: &MediaSession) { + self.session.set(Some(&session)); + } +} + +impl MediaMetadataMethods for MediaMetadata { + /// https://w3c.github.io/mediasession/#dom-mediametadata-title + fn Title(&self) -> DOMString { + self.title.borrow().clone() + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-title + fn SetTitle(&self, value: DOMString) { + *self.title.borrow_mut() = value; + self.queue_update_metadata_algorithm(); + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-artist + fn Artist(&self) -> DOMString { + self.artist.borrow().clone() + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-artist + fn SetArtist(&self, value: DOMString) { + *self.artist.borrow_mut() = value; + self.queue_update_metadata_algorithm(); + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-album + fn Album(&self) -> DOMString { + self.album.borrow().clone() + } + + /// https://w3c.github.io/mediasession/#dom-mediametadata-album + fn SetAlbum(&self, value: DOMString) { + *self.album.borrow_mut() = value; + self.queue_update_metadata_algorithm(); + } +} diff --git a/components/script/dom/mediasession.rs b/components/script/dom/mediasession.rs new file mode 100644 index 00000000000..1523e9a0ae6 --- /dev/null +++ b/components/script/dom/mediasession.rs @@ -0,0 +1,213 @@ +/* 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 crate::compartments::{AlreadyInCompartment, InCompartment}; +use crate::dom::bindings::callback::ExceptionHandling; +use crate::dom::bindings::cell::DomRefCell; +use crate::dom::bindings::codegen::Bindings::HTMLMediaElementBinding::HTMLMediaElementMethods; +use crate::dom::bindings::codegen::Bindings::MediaMetadataBinding::MediaMetadataInit; +use crate::dom::bindings::codegen::Bindings::MediaMetadataBinding::MediaMetadataMethods; +use crate::dom::bindings::codegen::Bindings::MediaSessionBinding; +use crate::dom::bindings::codegen::Bindings::MediaSessionBinding::MediaSessionAction; +use crate::dom::bindings::codegen::Bindings::MediaSessionBinding::MediaSessionActionHandler; +use crate::dom::bindings::codegen::Bindings::MediaSessionBinding::MediaSessionMethods; +use crate::dom::bindings::codegen::Bindings::MediaSessionBinding::MediaSessionPlaybackState; +use crate::dom::bindings::reflector::{reflect_dom_object, DomObject, Reflector}; +use crate::dom::bindings::root::{DomRoot, MutNullableDom}; +use crate::dom::bindings::str::DOMString; +use crate::dom::htmlmediaelement::HTMLMediaElement; +use crate::dom::mediametadata::MediaMetadata; +use crate::dom::window::Window; +use dom_struct::dom_struct; +use embedder_traits::MediaMetadata as EmbedderMediaMetadata; +use embedder_traits::MediaSessionEvent; +use script_traits::MediaSessionActionType; +use script_traits::ScriptMsg; +use std::collections::HashMap; +use std::rc::Rc; + +#[dom_struct] +pub struct MediaSession { + reflector_: Reflector, + /// https://w3c.github.io/mediasession/#dom-mediasession-metadata + #[ignore_malloc_size_of = "defined in embedder_traits"] + metadata: DomRefCell<Option<EmbedderMediaMetadata>>, + /// https://w3c.github.io/mediasession/#dom-mediasession-playbackstate + playback_state: DomRefCell<MediaSessionPlaybackState>, + /// https://w3c.github.io/mediasession/#supported-media-session-actions + #[ignore_malloc_size_of = "Rc"] + action_handlers: DomRefCell<HashMap<MediaSessionActionType, Rc<MediaSessionActionHandler>>>, + /// The media instance controlled by this media session. + /// For now only HTMLMediaElements are controlled by media sessions. + media_instance: MutNullableDom<HTMLMediaElement>, +} + +impl MediaSession { + #[allow(unrooted_must_root)] + fn new_inherited() -> MediaSession { + let media_session = MediaSession { + reflector_: Reflector::new(), + metadata: DomRefCell::new(None), + playback_state: DomRefCell::new(MediaSessionPlaybackState::None), + action_handlers: DomRefCell::new(HashMap::new()), + media_instance: Default::default(), + }; + media_session + } + + pub fn new(window: &Window) -> DomRoot<MediaSession> { + reflect_dom_object( + Box::new(MediaSession::new_inherited()), + window, + MediaSessionBinding::Wrap, + ) + } + + pub fn register_media_instance(&self, media_instance: &HTMLMediaElement) { + self.media_instance.set(Some(media_instance)); + } + + pub fn handle_action(&self, action: MediaSessionActionType) { + debug!("Handle media session action {:?}", action); + + if let Some(handler) = self.action_handlers.borrow().get(&action) { + if handler.Call__(ExceptionHandling::Report).is_err() { + warn!("Error calling MediaSessionActionHandler callback"); + } + return; + } + + // Default action. + if let Some(media) = self.media_instance.get() { + match action { + MediaSessionActionType::Play => { + let in_compartment_proof = AlreadyInCompartment::assert(&self.global()); + media.Play(InCompartment::Already(&in_compartment_proof)); + }, + MediaSessionActionType::Pause => { + media.Pause(); + }, + MediaSessionActionType::SeekBackward => {}, + MediaSessionActionType::SeekForward => {}, + MediaSessionActionType::PreviousTrack => {}, + MediaSessionActionType::NextTrack => {}, + MediaSessionActionType::SkipAd => {}, + MediaSessionActionType::Stop => {}, + MediaSessionActionType::SeekTo => {}, + } + } + } + + pub fn send_event(&self, event: MediaSessionEvent) { + let global = self.global(); + let window = global.as_window(); + let pipeline_id = window + .pipeline_id() + .expect("Cannot send media session event outside of a pipeline"); + window.send_to_constellation(ScriptMsg::MediaSessionEvent(pipeline_id, event)); + } + + pub fn update_title(&self, title: String) { + let mut metadata = self.metadata.borrow_mut(); + if let Some(ref mut metadata) = *metadata { + // We only update the title with the data provided by the media + // player and iff the user did not provide a title. + if !metadata.title.is_empty() { + return; + } + metadata.title = title; + } else { + *metadata = Some(EmbedderMediaMetadata::new(title)); + } + self.send_event(MediaSessionEvent::SetMetadata( + metadata.as_ref().unwrap().clone(), + )); + } +} + +impl MediaSessionMethods for MediaSession { + /// https://w3c.github.io/mediasession/#dom-mediasession-metadata + fn GetMetadata(&self) -> Option<DomRoot<MediaMetadata>> { + if let Some(ref metadata) = *self.metadata.borrow() { + let mut init = MediaMetadataInit::empty(); + init.title = DOMString::from_string(metadata.title.clone()); + init.artist = DOMString::from_string(metadata.artist.clone()); + init.album = DOMString::from_string(metadata.album.clone()); + let global = self.global(); + Some(MediaMetadata::new(&global.as_window(), &init)) + } else { + None + } + } + + /// https://w3c.github.io/mediasession/#dom-mediasession-metadata + fn SetMetadata(&self, metadata: Option<&MediaMetadata>) { + if let Some(ref metadata) = metadata { + metadata.set_session(self); + } + + let global = self.global(); + let window = global.as_window(); + let _metadata = match metadata { + Some(m) => { + let title = if m.Title().is_empty() { + window.get_url().into_string() + } else { + m.Title().into() + }; + EmbedderMediaMetadata { + title, + artist: m.Artist().into(), + album: m.Album().into(), + } + }, + None => EmbedderMediaMetadata::new(window.get_url().into_string()), + }; + + *self.metadata.borrow_mut() = Some(_metadata.clone()); + + self.send_event(MediaSessionEvent::SetMetadata(_metadata)); + } + + /// https://w3c.github.io/mediasession/#dom-mediasession-playbackstate + fn PlaybackState(&self) -> MediaSessionPlaybackState { + *self.playback_state.borrow() + } + + /// https://w3c.github.io/mediasession/#dom-mediasession-playbackstate + fn SetPlaybackState(&self, state: MediaSessionPlaybackState) { + *self.playback_state.borrow_mut() = state; + } + + /// https://w3c.github.io/mediasession/#update-action-handler-algorithm + fn SetActionHandler( + &self, + action: MediaSessionAction, + handler: Option<Rc<MediaSessionActionHandler>>, + ) { + match handler { + Some(handler) => self + .action_handlers + .borrow_mut() + .insert(action.into(), handler.clone()), + None => self.action_handlers.borrow_mut().remove(&action.into()), + }; + } +} + +impl From<MediaSessionAction> for MediaSessionActionType { + fn from(action: MediaSessionAction) -> MediaSessionActionType { + match action { + MediaSessionAction::Play => MediaSessionActionType::Play, + MediaSessionAction::Pause => MediaSessionActionType::Pause, + MediaSessionAction::Seekbackward => MediaSessionActionType::SeekBackward, + MediaSessionAction::Seekforward => MediaSessionActionType::SeekForward, + MediaSessionAction::Previoustrack => MediaSessionActionType::PreviousTrack, + MediaSessionAction::Nexttrack => MediaSessionActionType::NextTrack, + MediaSessionAction::Skipad => MediaSessionActionType::SkipAd, + MediaSessionAction::Stop => MediaSessionActionType::Stop, + MediaSessionAction::Seekto => MediaSessionActionType::SeekTo, + } + } +} diff --git a/components/script/dom/mod.rs b/components/script/dom/mod.rs index fbc4d457482..ef562931bee 100644 --- a/components/script/dom/mod.rs +++ b/components/script/dom/mod.rs @@ -398,8 +398,10 @@ pub mod mediaelementaudiosourcenode; pub mod mediaerror; pub mod mediafragmentparser; pub mod medialist; +pub mod mediametadata; pub mod mediaquerylist; pub mod mediaquerylistevent; +pub mod mediasession; pub mod mediastream; pub mod mediastreamtrack; pub mod messagechannel; diff --git a/components/script/dom/navigator.rs b/components/script/dom/navigator.rs index 8a0f6a21d98..127883dd956 100644 --- a/components/script/dom/navigator.rs +++ b/components/script/dom/navigator.rs @@ -12,6 +12,7 @@ use crate::dom::bindings::str::DOMString; use crate::dom::bluetooth::Bluetooth; use crate::dom::gamepadlist::GamepadList; use crate::dom::mediadevices::MediaDevices; +use crate::dom::mediasession::MediaSession; use crate::dom::mimetypearray::MimeTypeArray; use crate::dom::navigatorinfo; use crate::dom::permissions::Permissions; @@ -34,6 +35,7 @@ pub struct Navigator { mediadevices: MutNullableDom<MediaDevices>, gamepads: MutNullableDom<GamepadList>, permissions: MutNullableDom<Permissions>, + mediasession: MutNullableDom<MediaSession>, } impl Navigator { @@ -48,6 +50,7 @@ impl Navigator { mediadevices: Default::default(), gamepads: Default::default(), permissions: Default::default(), + mediasession: Default::default(), } } @@ -186,4 +189,20 @@ impl NavigatorMethods for Navigator { self.mediadevices .or_init(|| MediaDevices::new(&self.global())) } + + /// https://w3c.github.io/mediasession/#dom-navigator-mediasession + fn MediaSession(&self) -> DomRoot<MediaSession> { + self.mediasession.or_init(|| { + // There is a single MediaSession instance per Pipeline + // and only one active MediaSession globally. + // + // MediaSession creation can happen in two cases: + // + // - If content gets `navigator.mediaSession` + // - If a media instance (HTMLMediaElement so far) starts playing media. + let global = self.global(); + let window = global.as_window(); + MediaSession::new(window) + }) + } } diff --git a/components/script/dom/webidls/MediaMetadata.webidl b/components/script/dom/webidls/MediaMetadata.webidl new file mode 100644 index 00000000000..495aeef8e35 --- /dev/null +++ b/components/script/dom/webidls/MediaMetadata.webidl @@ -0,0 +1,30 @@ +/* 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/. */ +/* + * The origin of this IDL file is + * https://w3c.github.io/mediasession/#mediametadata + */ + +dictionary MediaImage { + required USVString src; + DOMString sizes = ""; + DOMString type = ""; +}; + +[Exposed=Window] +interface MediaMetadata { + [Throws] constructor(optional MediaMetadataInit init = {}); + attribute DOMString title; + attribute DOMString artist; + attribute DOMString album; + // TODO: https://github.com/servo/servo/issues/10072 + // attribute FrozenArray<MediaImage> artwork; +}; + +dictionary MediaMetadataInit { + DOMString title = ""; + DOMString artist = ""; + DOMString album = ""; + sequence<MediaImage> artwork = []; +}; diff --git a/components/script/dom/webidls/MediaSession.webidl b/components/script/dom/webidls/MediaSession.webidl new file mode 100644 index 00000000000..12b3fe062ba --- /dev/null +++ b/components/script/dom/webidls/MediaSession.webidl @@ -0,0 +1,57 @@ +/* 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/. */ +/* + * The origin of this IDL file is + * https://w3c.github.io/mediasession/#mediasession + */ + +[Exposed=Window] +partial interface Navigator { + [SameObject] readonly attribute MediaSession mediaSession; +}; + +enum MediaSessionPlaybackState { + "none", + "paused", + "playing" +}; + +enum MediaSessionAction { + "play", + "pause", + "seekbackward", + "seekforward", + "previoustrack", + "nexttrack", + "skipad", + "stop", + "seekto" +}; + +dictionary MediaSessionActionDetails { + required MediaSessionAction action; +}; + +dictionary MediaSessionSeekActionDetails : MediaSessionActionDetails { + double? seekOffset; +}; + +dictionary MediaSessionSeekToActionDetails : MediaSessionActionDetails { + required double seekTime; + boolean? fastSeek; +}; + +callback MediaSessionActionHandler = void(/*MediaSessionActionDetails details*/); + +[Exposed=Window] +interface MediaSession { + attribute MediaMetadata? metadata; + + attribute MediaSessionPlaybackState playbackState; + + void setActionHandler(MediaSessionAction action, MediaSessionActionHandler? handler); + + //void setPositionState(optional MediaPositionState? state); +}; + diff --git a/components/script/script_thread.rs b/components/script/script_thread.rs index dbc4f0fb444..c0155ea63a5 100644 --- a/components/script/script_thread.rs +++ b/components/script/script_thread.rs @@ -138,7 +138,7 @@ use script_traits::{ DiscardBrowsingContext, DocumentActivity, EventResult, HistoryEntryReplacement, }; use script_traits::{InitialScriptState, JsEvalResult, LayoutMsg, LoadData, LoadOrigin}; -use script_traits::{MouseButton, MouseEventType, NewLayoutInfo}; +use script_traits::{MediaSessionActionType, MouseButton, MouseEventType, NewLayoutInfo}; use script_traits::{Painter, ProgressiveWebMetricType, ScriptMsg, ScriptThreadFactory}; use script_traits::{ScriptToConstellationChan, TimerEvent, TimerSchedulerMsg}; use script_traits::{TimerSource, TouchEventType, TouchId, UntrustedNodeAddress, WheelDelta}; @@ -1713,6 +1713,7 @@ impl ScriptThread { WebVREvents(id, ..) => Some(id), PaintMetric(..) => None, ExitFullScreen(id, ..) => Some(id), + MediaSessionAction(..) => None, } }, MixedMessage::FromDevtools(_) => None, @@ -1942,6 +1943,9 @@ impl ScriptThread { ConstellationControlMsg::PaintMetric(pipeline_id, metric_type, metric_value) => { self.handle_paint_metric(pipeline_id, metric_type, metric_value) }, + ConstellationControlMsg::MediaSessionAction(pipeline_id, action) => { + self.handle_media_session_action(pipeline_id, action) + }, msg @ ConstellationControlMsg::AttachLayout(..) | msg @ ConstellationControlMsg::Viewport(..) | msg @ ConstellationControlMsg::SetScrollState(..) | @@ -3925,6 +3929,15 @@ impl ScriptThread { } } + fn handle_media_session_action(&self, pipeline_id: PipelineId, action: MediaSessionActionType) { + if let Some(window) = self.documents.borrow().find_window(pipeline_id) { + let media_session = window.Navigator().MediaSession(); + media_session.handle_action(action); + } else { + warn!("No MediaSession for this pipeline ID"); + }; + } + pub fn enqueue_microtask(job: Microtask) { SCRIPT_THREAD_ROOT.with(|root| { let script_thread = unsafe { &*root.get().unwrap() }; diff --git a/components/script_traits/lib.rs b/components/script_traits/lib.rs index 0212ae021b6..a8a19b5a40a 100644 --- a/components/script_traits/lib.rs +++ b/components/script_traits/lib.rs @@ -388,6 +388,8 @@ pub enum ConstellationControlMsg { WebVREvents(PipelineId, Vec<WebVREvent>), /// Notifies the script thread about a new recorded paint metric. PaintMetric(PipelineId, ProgressiveWebMetricType, u64), + /// Notifies the media session about a user requested media session action. + MediaSessionAction(PipelineId, MediaSessionActionType), } impl fmt::Debug for ConstellationControlMsg { @@ -426,6 +428,7 @@ impl fmt::Debug for ConstellationControlMsg { WebVREvents(..) => "WebVREvents", PaintMetric(..) => "PaintMetric", ExitFullScreen(..) => "ExitFullScreen", + MediaSessionAction(..) => "MediaSessionAction", }; write!(formatter, "ConstellationControlMsg::{}", variant) } @@ -877,6 +880,8 @@ pub enum ConstellationMsg { DisableProfiler, /// Request to exit from fullscreen mode ExitFullScreen(TopLevelBrowsingContextId), + /// Media session action. + MediaSessionAction(MediaSessionActionType), } impl fmt::Debug for ConstellationMsg { @@ -907,6 +912,7 @@ impl fmt::Debug for ConstellationMsg { EnableProfiler(..) => "EnableProfiler", DisableProfiler => "DisableProfiler", ExitFullScreen(..) => "ExitFullScreen", + MediaSessionAction(..) => "MediaSessionAction", }; write!(formatter, "ConstellationMsg::{}", variant) } @@ -1053,3 +1059,49 @@ pub enum MessagePortMsg { /// Handle a new port-message-task. NewTask(MessagePortId, PortMessageTask), } + +/// The type of MediaSession action. +/// https://w3c.github.io/mediasession/#enumdef-mediasessionaction +#[derive(Clone, Debug, Deserialize, Eq, Hash, MallocSizeOf, PartialEq, Serialize)] +pub enum MediaSessionActionType { + /// The action intent is to resume playback. + Play, + /// The action intent is to pause the currently active playback. + Pause, + /// The action intent is to move the playback time backward by a short period (i.e. a few + /// seconds). + SeekBackward, + /// The action intent is to move the playback time forward by a short period (i.e. a few + /// seconds). + SeekForward, + /// The action intent is to either start the current playback from the beginning if the + /// playback has a notion, of beginning, or move to the previous item in the playlist if the + /// playback has a notion of playlist. + PreviousTrack, + /// The action is to move to the playback to the next item in the playlist if the playback has + /// a notion of playlist. + NextTrack, + /// The action intent is to skip the advertisement that is currently playing. + SkipAd, + /// The action intent is to stop the playback and clear the state if appropriate. + Stop, + /// The action intent is to move the playback time to a specific time. + SeekTo, +} + +impl From<i32> for MediaSessionActionType { + fn from(value: i32) -> MediaSessionActionType { + match value { + 1 => MediaSessionActionType::Play, + 2 => MediaSessionActionType::Pause, + 3 => MediaSessionActionType::SeekBackward, + 4 => MediaSessionActionType::SeekForward, + 5 => MediaSessionActionType::PreviousTrack, + 6 => MediaSessionActionType::NextTrack, + 7 => MediaSessionActionType::SkipAd, + 8 => MediaSessionActionType::Stop, + 9 => MediaSessionActionType::SeekTo, + _ => panic!("Unknown MediaSessionActionType"), + } + } +} diff --git a/components/script_traits/script_msg.rs b/components/script_traits/script_msg.rs index a12101380c9..1eccc794398 100644 --- a/components/script_traits/script_msg.rs +++ b/components/script_traits/script_msg.rs @@ -16,7 +16,7 @@ use crate::WorkerGlobalScopeInit; use crate::WorkerScriptLoadOrigin; use canvas_traits::canvas::{CanvasId, CanvasMsg}; use devtools_traits::{ScriptToDevtoolsControlMsg, WorkerId}; -use embedder_traits::EmbedderMsg; +use embedder_traits::{EmbedderMsg, MediaSessionEvent}; use euclid::default::Size2D as UntypedSize2D; use euclid::Size2D; use gfx_traits::Epoch; @@ -254,6 +254,9 @@ pub enum ScriptMsg { GetScreenSize(IpcSender<DeviceIntSize>), /// Get the available screen size (pixel) GetScreenAvailSize(IpcSender<DeviceIntSize>), + /// Notifies the constellation about media session events + /// (i.e. when there is metadata for the active media session, playback state changes...). + MediaSessionEvent(PipelineId, MediaSessionEvent), } impl fmt::Debug for ScriptMsg { @@ -305,6 +308,7 @@ impl fmt::Debug for ScriptMsg { GetClientWindow(..) => "GetClientWindow", GetScreenSize(..) => "GetScreenSize", GetScreenAvailSize(..) => "GetScreenAvailSize", + MediaSessionEvent(..) => "MediaSessionEvent", }; write!(formatter, "ScriptMsg::{}", variant) } diff --git a/components/servo/lib.rs b/components/servo/lib.rs index 3d4b7bf14d3..e32c28ef740 100644 --- a/components/servo/lib.rs +++ b/components/servo/lib.rs @@ -713,6 +713,16 @@ where ); } }, + + WindowEvent::MediaSessionAction(a) => { + let msg = ConstellationMsg::MediaSessionAction(a); + if let Err(e) = self.constellation_chan.send(msg) { + warn!( + "Sending MediaSessionAction message to constellation failed ({:?}).", + e + ); + } + }, } } diff --git a/ports/glutin/browser.rs b/ports/glutin/browser.rs index e3da4d13d61..976026a483b 100644 --- a/ports/glutin/browser.rs +++ b/ports/glutin/browser.rs @@ -449,6 +449,10 @@ where error!("Failed to store profile: {}", e); } }, + EmbedderMsg::MediaSessionEvent(_) => { + debug!("MediaSessionEvent received"); + // TODO(ferjm): MediaSession support for Glutin based browsers. + }, } } } diff --git a/ports/libsimpleservo/api/src/lib.rs b/ports/libsimpleservo/api/src/lib.rs index 0b415ac669c..c7986e05623 100644 --- a/ports/libsimpleservo/api/src/lib.rs +++ b/ports/libsimpleservo/api/src/lib.rs @@ -15,7 +15,7 @@ use servo::compositing::windowing::{ WindowMethods, }; use servo::embedder_traits::resources::{self, Resource, ResourceReaderMethods}; -use servo::embedder_traits::EmbedderMsg; +use servo::embedder_traits::{EmbedderMsg, MediaSessionEvent}; use servo::euclid::{Point2D, Rect, Scale, Size2D, Vector2D}; use servo::keyboard_types::{Key, KeyState, KeyboardEvent}; use servo::msg::constellation_msg::TraversalDirection; @@ -126,10 +126,14 @@ pub trait HostTrait { fn on_shutdown_complete(&self); /// A text input is focused. fn on_ime_state_changed(&self, show: bool); - /// Gets sytem clipboard contents + /// Gets sytem clipboard contents. fn get_clipboard_contents(&self) -> Option<String>; - /// Sets system clipboard contents + /// Sets system clipboard contents. fn set_clipboard_contents(&self, contents: String); + /// Called when we get the media session metadata/ + fn on_media_session_metadata(&self, title: String, artist: String, album: String); + /// Called when the media sessoin playback state changes. + fn on_media_session_playback_state_change(&self, state: i32); } pub struct ServoGlue { @@ -466,6 +470,11 @@ impl ServoGlue { self.process_event(WindowEvent::Keyboard(key_event)) } + pub fn media_session_action(&mut self, action: i32) -> Result<(), &'static str> { + info!("Media session action {:?}", action); + self.process_event(WindowEvent::MediaSessionAction(action.into())) + } + fn process_event(&mut self, event: WindowEvent) -> Result<(), &'static str> { self.events.push(event); if !self.batch_mode { @@ -572,6 +581,21 @@ impl ServoGlue { EmbedderMsg::HideIME => { self.callbacks.host_callbacks.on_ime_state_changed(false); }, + EmbedderMsg::MediaSessionEvent(event) => { + match event { + MediaSessionEvent::SetMetadata(metadata) => { + self.callbacks.host_callbacks.on_media_session_metadata( + metadata.title, + metadata.artist, + metadata.album, + ) + }, + MediaSessionEvent::PlaybackStateChange(state) => self + .callbacks + .host_callbacks + .on_media_session_playback_state_change(state as i32), + }; + }, EmbedderMsg::Status(..) | EmbedderMsg::SelectFiles(..) | EmbedderMsg::MoveTo(..) | diff --git a/ports/libsimpleservo/capi/src/lib.rs b/ports/libsimpleservo/capi/src/lib.rs index c6b27ae6d48..956c7e3a380 100644 --- a/ports/libsimpleservo/capi/src/lib.rs +++ b/ports/libsimpleservo/capi/src/lib.rs @@ -216,6 +216,9 @@ pub struct CHostCallbacks { pub on_ime_state_changed: extern "C" fn(show: bool), pub get_clipboard_contents: extern "C" fn() -> *const c_char, pub set_clipboard_contents: extern "C" fn(contents: *const c_char), + pub on_media_session_metadata: + extern "C" fn(title: *const c_char, album: *const c_char, artist: *const c_char), + pub on_media_session_playback_state_change: extern "C" fn(state: i32), } /// Servo options @@ -708,4 +711,20 @@ impl HostTrait for HostCallbacks { let contents = CString::new(contents).expect("Can't create string"); (self.0.set_clipboard_contents)(contents.as_ptr()); } + + fn on_media_session_metadata(&self, title: String, artist: String, album: String) { + debug!( + "on_media_session_metadata ({:?} {:?} {:?})", + title, artist, album + ); + let title = CString::new(title).expect("Can't create string"); + let artist = CString::new(artist).expect("Can't create string"); + let album = CString::new(album).expect("Can't create string"); + (self.0.on_media_session_metadata)(title.as_ptr(), artist.as_ptr(), album.as_ptr()); + } + + fn on_media_session_playback_state_change(&self, state: i32) { + debug!("on_media_session_playback_state_change {:?}", state); + (self.0.on_media_session_playback_state_change)(state); + } } diff --git a/ports/libsimpleservo/jniapi/src/lib.rs b/ports/libsimpleservo/jniapi/src/lib.rs index 963e01dec6d..1125f3a5680 100644 --- a/ports/libsimpleservo/jniapi/src/lib.rs +++ b/ports/libsimpleservo/jniapi/src/lib.rs @@ -333,6 +333,16 @@ pub fn Java_org_mozilla_servoview_JNIServo_click(env: JNIEnv, _: JClass, x: jint call(&env, |s| s.click(x as f32, y as f32)); } +#[no_mangle] +pub fn Java_org_mozilla_servoview_JNIServo_mediaSessionAction( + env: JNIEnv, + _: JClass, + action: jint, +) { + debug!("mediaSessionAction"); + call(&env, |s| s.media_session_action(action as i32)); +} + pub struct WakeupCallback { callback: GlobalRef, jvm: Arc<JavaVM>, @@ -508,6 +518,48 @@ impl HostTrait for HostCallbacks { } fn set_clipboard_contents(&self, _contents: String) {} + + fn on_media_session_metadata(&self, title: String, artist: String, album: String) { + info!("on_media_session_metadata"); + let env = self.jvm.get_env().unwrap(); + let title = match new_string(&env, &title) { + Ok(s) => s, + Err(_) => return, + }; + let title = JValue::Object(JObject::from(title)); + + let artist = match new_string(&env, &artist) { + Ok(s) => s, + Err(_) => return, + }; + let artist = JValue::Object(JObject::from(artist)); + + let album = match new_string(&env, &album) { + Ok(s) => s, + Err(_) => return, + }; + let album = JValue::Object(JObject::from(album)); + env.call_method( + self.callbacks.as_obj(), + "onMediaSessionMetadata", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + &[title, artist, album], + ) + .unwrap(); + } + + fn on_media_session_playback_state_change(&self, state: i32) { + info!("on_media_session_playback_state_change {:?}", state); + let env = self.jvm.get_env().unwrap(); + let state = JValue::Int(state as jint); + env.call_method( + self.callbacks.as_obj(), + "onMediaSessionPlaybackStateChange", + "(I)V", + &[state], + ) + .unwrap(); + } } fn initialize_android_glue(env: &JNIEnv, activity: JObject) { diff --git a/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MainActivity.java b/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MainActivity.java index e4ef5a51b52..21a6e7f996f 100644 --- a/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MainActivity.java +++ b/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MainActivity.java @@ -23,6 +23,7 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.util.Log; +import org.mozilla.servo.MediaSession; import org.mozilla.servoview.ServoView; import org.mozilla.servoview.Servo; @@ -41,6 +42,7 @@ public class MainActivity extends Activity implements Servo.Client { ProgressBar mProgressBar; TextView mIdleText; boolean mCanGoBack; + MediaSession mMediaSession; @Override protected void onCreate(Bundle savedInstanceState) { @@ -85,6 +87,12 @@ public class MainActivity extends Activity implements Servo.Client { setupUrlField(); } + @Override + protected void onDestroy() { + super.onDestroy(); + mMediaSession.hideMediaSessionControls(); + } + private void setupUrlField() { mUrlField.setOnEditorActionListener((v, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { @@ -203,6 +211,7 @@ public class MainActivity extends Activity implements Servo.Client { mServoView.onPause(); super.onPause(); } + @Override public void onResume() { mServoView.onResume(); @@ -217,4 +226,33 @@ public class MainActivity extends Activity implements Servo.Client { super.onBackPressed(); } } + + @Override + public void onMediaSessionMetadata(String title, String artist, String album) { + if (mMediaSession == null) { + mMediaSession = new MediaSession(mServoView, this, getApplicationContext()); + } + Log.d("onMediaSessionMetadata", title + " " + artist + " " + album); + mMediaSession.updateMetadata(title, artist, album); + } + + @Override + public void onMediaSessionPlaybackStateChange(int state) { + Log.d("onMediaSessionPlaybackStateChange", String.valueOf(state)); + if (mMediaSession == null) { + mMediaSession = new MediaSession(mServoView, this, getApplicationContext()); + } + + mMediaSession.setPlaybackState(state); + + if (state == MediaSession.PLAYBACK_STATE_NONE) { + mMediaSession.hideMediaSessionControls(); + return; + } + if (state == MediaSession.PLAYBACK_STATE_PLAYING || + state == MediaSession.PLAYBACK_STATE_PAUSED) { + mMediaSession.showMediaSessionControls(); + return; + } + } } diff --git a/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MediaSession.java b/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MediaSession.java new file mode 100644 index 00000000000..5b1852d5d3f --- /dev/null +++ b/support/android/apk/servoapp/src/main/java/org/mozilla/servo/MediaSession.java @@ -0,0 +1,195 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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/. */ + +package org.mozilla.servo; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.util.Log; + +import org.mozilla.servoview.ServoView; + +public class MediaSession { + private class NotificationID { + private int lastID = 0; + public int getNext() { + lastID++; + return lastID; + } + + public int get() { + return lastID; + } + } + + // https://w3c.github.io/mediasession/#enumdef-mediasessionplaybackstate + public static final int PLAYBACK_STATE_NONE = 1; + public static final int PLAYBACK_STATE_PLAYING = 2; + public static final int PLAYBACK_STATE_PAUSED = 3; + + // https://w3c.github.io/mediasession/#enumdef-mediasessionaction + private static final int ACTION_PLAY = 1; + private static final int ACTION_PAUSE = 2; + private static final int ACTON_SEEK_BACKWARD = 3; + private static final int ACTION_SEEK_FORWARD = 4; + private static final int ACTION_PREVIOUS_TRACK = 5; + private static final int ACTION_NEXT_TRACK = 6; + private static final int ACTION_SKIP_AD = 7; + private static final int ACTION_STOP = 8; + private static final int ACTION_SEEK_TO = 9; + + private static final String MEDIA_CHANNEL_ID = "MediaNotificationChannel"; + private static final String KEY_MEDIA_PLAY = "org.mozilla.servoview.MainActivity.play"; + private static final String KEY_MEDIA_PAUSE = "org.mozilla.servoview.MainActivity.pause"; + private static final String KEY_MEDIA_PREV = "org.mozilla.servoview.MainActivity.prev"; + private static final String KEY_MEDIA_NEXT = "org.mozilla.servoview.MainActivity.next"; + private static final String KEY_MEDIA_STOP = "org.mozilla.servoview.MainActivity.stop"; + + ServoView mView; + MainActivity mActivity; + Context mContext; + + NotificationID mNotificationID; + BroadcastReceiver mMediaSessionActionReceiver; + + int mPlaybackState = PLAYBACK_STATE_PAUSED; + + String mTitle; + String mArtist; + String mAlbum; + + public MediaSession(ServoView view, MainActivity activity, Context context) { + mView = view; + mActivity = activity; + mContext = context; + mNotificationID = new NotificationID(); + createMediaNotificationChannel(); + } + + private void createMediaNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = + mContext.getResources().getString(R.string.media_channel_name); + String description = + mContext.getResources().getString(R.string.media_channel_description); + int importance = NotificationManager.IMPORTANCE_LOW; + NotificationChannel channel = + new NotificationChannel(MEDIA_CHANNEL_ID, name, importance); + channel.setDescription(description); + NotificationManager notificationManager = + mContext.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + public void showMediaSessionControls() { + Log.d("MediaSession", "showMediaSessionControls " + mPlaybackState); + IntentFilter filter = new IntentFilter(); + if (mPlaybackState == PLAYBACK_STATE_PAUSED) { + filter.addAction(KEY_MEDIA_PLAY); + } + if (mPlaybackState == PLAYBACK_STATE_PLAYING) { + filter.addAction(KEY_MEDIA_PAUSE); + } + + int id; + if (mMediaSessionActionReceiver == null) { + id = mNotificationID.getNext(); + + mMediaSessionActionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(KEY_MEDIA_PAUSE)) { + mView.mediaSessionAction(ACTION_PAUSE); + Log.d("MediaSession", "PAUSE action"); + } else if (intent.getAction().equals(KEY_MEDIA_PLAY)) { + mView.mediaSessionAction(ACTION_PLAY); + Log.d("MediaSession", "PLAY action"); + } + } + }; + } else { + id = mNotificationID.get(); + } + + mContext.registerReceiver(mMediaSessionActionReceiver, filter); + + Notification.Builder builder = new Notification.Builder(mContext, this.MEDIA_CHANNEL_ID); + builder + .setSmallIcon(R.drawable.media_session_icon) + .setContentTitle(mTitle) + .setVisibility(Notification.VISIBILITY_PUBLIC); + + String contentText = new String(); + if (mArtist != null && !mArtist.isEmpty()) { + contentText = mArtist; + } + if (mAlbum != null && !mAlbum.isEmpty()) { + if (!contentText.isEmpty()) { + contentText += " - " + mAlbum; + } else { + contentText = mAlbum; + } + } + + if (!contentText.isEmpty()) { + builder.setContentText(contentText); + } + + if (mPlaybackState == PLAYBACK_STATE_PAUSED) { + Intent playIntent = new Intent(KEY_MEDIA_PLAY); + Notification.Action playAction = + new Notification.Action(R.drawable.media_session_play, "Play", + PendingIntent.getBroadcast(mContext, 0, playIntent, 0)); + builder.addAction(playAction); + } + + if (mPlaybackState == PLAYBACK_STATE_PLAYING) { + Intent pauseIntent = new Intent(KEY_MEDIA_PAUSE); + Notification.Action pauseAction = + new Notification.Action(R.drawable.media_session_pause, "Pause", + PendingIntent.getBroadcast(mContext, 0, pauseIntent, 0)); + builder.addAction(pauseAction); + } + + builder.setStyle(new Notification.MediaStyle() + .setShowActionsInCompactView(0)); + + NotificationManager notificationManager = + mContext.getSystemService(NotificationManager.class); + notificationManager.notify(id, builder.build()); + } + + public void hideMediaSessionControls() { + Log.d("MediaSession", "hideMediaSessionControls"); + NotificationManager notificationManager = + mContext.getSystemService(NotificationManager.class); + notificationManager.cancel(mNotificationID.get()); + mContext.unregisterReceiver(mMediaSessionActionReceiver); + mMediaSessionActionReceiver = null; + } + + public void setPlaybackState(int state) { + mPlaybackState = state; + } + + public void updateMetadata(String title, String artist, String album) { + mTitle = title; + mArtist = artist; + mAlbum = album; + + if (mMediaSessionActionReceiver != null) { + showMediaSessionControls(); + } + } +} diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_icon.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_icon.png Binary files differnew file mode 100755 index 00000000000..aafd2bbad02 --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_icon.png diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_next.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_next.png Binary files differnew file mode 100755 index 00000000000..b4208472c5d --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_next.png diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_pause.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_pause.png Binary files differnew file mode 100755 index 00000000000..4a33fe9b17a --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_pause.png diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_play.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_play.png Binary files differnew file mode 100755 index 00000000000..65b46201718 --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_play.png diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_prev.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_prev.png Binary files differnew file mode 100755 index 00000000000..8bd9feef195 --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_prev.png diff --git a/support/android/apk/servoapp/src/main/res/drawable/media_session_stop.png b/support/android/apk/servoapp/src/main/res/drawable/media_session_stop.png Binary files differnew file mode 100755 index 00000000000..b9321def65d --- /dev/null +++ b/support/android/apk/servoapp/src/main/res/drawable/media_session_stop.png diff --git a/support/android/apk/servoapp/src/main/res/values/strings.xml b/support/android/apk/servoapp/src/main/res/values/strings.xml index ed1fb279213..4f3d30a0a84 100644 --- a/support/android/apk/servoapp/src/main/res/values/strings.xml +++ b/support/android/apk/servoapp/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ <resources> - <string name="app_name">Servo</string> + <string name="app_name">Servo</string> + <string name="media_channel_name">ServoMedia</string> + <string name="media_channel_description">Notication channel for multimedia activity</string> </resources> diff --git a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/JNIServo.java b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/JNIServo.java index 7bfcf233d8d..6181a298f0a 100644 --- a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/JNIServo.java +++ b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/JNIServo.java @@ -66,6 +66,8 @@ public class JNIServo { public native void click(float x, float y); + public native void mediaSessionAction(int action); + public static class ServoOptions { public String args; public String url; @@ -109,6 +111,10 @@ public class JNIServo { void onHistoryChanged(boolean canGoBack, boolean canGoForward); void onShutdownComplete(); + + void onMediaSessionMetadata(String title, String artist, String album); + + void onMediaSessionPlaybackStateChange(int state); } } diff --git a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/Servo.java b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/Servo.java index dac970d1acf..f45f55e79f5 100644 --- a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/Servo.java +++ b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/Servo.java @@ -168,6 +168,10 @@ public class Servo { mSuspended = suspended; } + public void mediaSessionAction(int action) { + mRunCallback.inGLThread(() -> mJNI.mediaSessionAction(action)); + } + public interface Client { void onAlert(String message); @@ -184,6 +188,10 @@ public class Servo { void onHistoryChanged(boolean canGoBack, boolean canGoForward); void onRedrawing(boolean redrawing); + + void onMediaSessionMetadata(String title, String artist, String album); + + void onMediaSessionPlaybackStateChange(int state); } public interface RunCallback { @@ -269,5 +277,13 @@ public class Servo { public void onRedrawing(boolean redrawing) { mRunCallback.inUIThread(() -> mClient.onRedrawing(redrawing)); } + + public void onMediaSessionMetadata(String title, String artist, String album) { + mRunCallback.inUIThread(() -> mClient.onMediaSessionMetadata(title, artist, album)); + } + + public void onMediaSessionPlaybackStateChange(int state) { + mRunCallback.inUIThread(() -> mClient.onMediaSessionPlaybackStateChange(state)); + } } } diff --git a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/ServoView.java b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/ServoView.java index 33431afa19a..3784bbf58cb 100644 --- a/support/android/apk/servoview/src/main/java/org/mozilla/servoview/ServoView.java +++ b/support/android/apk/servoview/src/main/java/org/mozilla/servoview/ServoView.java @@ -134,8 +134,12 @@ public class ServoView extends GLSurfaceView } } + public void mediaSessionAction(int action) { + mServo.mediaSessionAction(action); + } + public void flushGLBuffers() { - requestRender(); + requestRender(); } // Scroll and click diff --git a/tests/wpt/include.ini b/tests/wpt/include.ini index 6fded483450..2f662095640 100644 --- a/tests/wpt/include.ini +++ b/tests/wpt/include.ini @@ -119,6 +119,8 @@ skip: true skip: true [js] skip: false +[mediasession] + skip: false [navigation-timing] skip: false [offscreen-canvas] diff --git a/tests/wpt/metadata/mediasession/idlharness.window.js.ini b/tests/wpt/metadata/mediasession/idlharness.window.js.ini new file mode 100644 index 00000000000..5e65e535b96 --- /dev/null +++ b/tests/wpt/metadata/mediasession/idlharness.window.js.ini @@ -0,0 +1,16 @@ +[idlharness.window.html] + [MediaSession interface: calling setPositionState(MediaPositionState) on navigator.mediaSession with too few arguments must throw TypeError] + expected: FAIL + + [MediaSession interface: navigator.mediaSession must inherit property "setPositionState(MediaPositionState)" with the proper type] + expected: FAIL + + [MediaSession interface: operation setPositionState(MediaPositionState)] + expected: FAIL + + [MediaMetadata interface: attribute artwork] + expected: FAIL + + [MediaMetadata interface: new MediaMetadata() must inherit property "artwork" with the proper type] + expected: FAIL + diff --git a/tests/wpt/metadata/mediasession/mediametadata.html.ini b/tests/wpt/metadata/mediasession/mediametadata.html.ini new file mode 100644 index 00000000000..2a2b3e73c85 --- /dev/null +++ b/tests/wpt/metadata/mediasession/mediametadata.html.ini @@ -0,0 +1,43 @@ +[mediametadata.html] + [Test that MediaMetadata.artwork is Frozen] + expected: FAIL + + [Test that MediaMetadat.artwork can't be modified] + expected: FAIL + + [Test that resetting metadata to null is reflected] + expected: FAIL + + [Test the default values for MediaMetadata with empty init dictionary] + expected: FAIL + + [Test MediaImage default values] + expected: FAIL + + [Test that mediaSession.metadata is properly set] + expected: FAIL + + [Test that changes to metadata propagate properly] + expected: FAIL + + [Test that MediaMetadata.artwork returns parsed urls] + expected: FAIL + + [Test the different values allowed in MediaMetadata init dictionary] + expected: FAIL + + [Test the default values for MediaMetadata with no init dictionary] + expected: FAIL + + [Test that MediaImage.src is required] + expected: FAIL + + [Test that MediaMetadata throws when setting an invalid url] + expected: FAIL + + [Test that MediaMetadata.artwork will not expose unknown properties] + expected: FAIL + + [Test that the base URL of MediaImage is the base URL of entry setting object] + expected: FAIL + diff --git a/tests/wpt/metadata/mediasession/positionstate.html.ini b/tests/wpt/metadata/mediasession/positionstate.html.ini new file mode 100644 index 00000000000..ca73e31fb84 --- /dev/null +++ b/tests/wpt/metadata/mediasession/positionstate.html.ini @@ -0,0 +1,19 @@ +[positionstate.html] + [Test setPositionState with a null value] + expected: FAIL + + [Test setPositionState with zero duration] + expected: FAIL + + [Test setPositionState with a valid value for forward playback] + expected: FAIL + + [Test setPositionState with optional position] + expected: FAIL + + [Test setPositionState with only duration] + expected: FAIL + + [Test setPositionState with optional playback rate] + expected: FAIL + diff --git a/tests/wpt/mozilla/meta/MANIFEST.json b/tests/wpt/mozilla/meta/MANIFEST.json index e675b665aa6..409d33b4c58 100644 --- a/tests/wpt/mozilla/meta/MANIFEST.json +++ b/tests/wpt/mozilla/meta/MANIFEST.json @@ -19023,7 +19023,7 @@ "testharness" ], "mozilla/interfaces.html": [ - "4006cae2d79ba4ca21c229084fcb528b8a4156f1", + "f1d58732adafef4afc9f9b7f16d6961e4b74a5e9", "testharness" ], "mozilla/interfaces.js": [ diff --git a/tests/wpt/mozilla/tests/mozilla/interfaces.html b/tests/wpt/mozilla/tests/mozilla/interfaces.html index 4006cae2d79..f1d58732ada 100644 --- a/tests/wpt/mozilla/tests/mozilla/interfaces.html +++ b/tests/wpt/mozilla/tests/mozilla/interfaces.html @@ -166,8 +166,10 @@ test_interfaces([ "MediaElementAudioSourceNode", "MediaError", "MediaList", + "MediaMetadata", "MediaQueryList", "MediaQueryListEvent", + "MediaSession", "MessageChannel", "MessageEvent", "MessagePort", |