summaryrefslogtreecommitdiffstats
path: root/dom/media/mediasession
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--dom/media/mediasession/MediaMetadata.cpp154
-rw-r--r--dom/media/mediasession/MediaMetadata.h97
-rw-r--r--dom/media/mediasession/MediaSession.cpp335
-rw-r--r--dom/media/mediasession/MediaSession.h132
-rw-r--r--dom/media/mediasession/MediaSessionIPCUtils.h102
-rw-r--r--dom/media/mediasession/moz.build24
-rw-r--r--dom/media/mediasession/test/MediaSessionTestUtils.js30
-rw-r--r--dom/media/mediasession/test/browser.ini9
-rw-r--r--dom/media/mediasession/test/browser_active_mediasession_among_tabs.js201
-rw-r--r--dom/media/mediasession/test/crashtests/crashtests.list1
-rw-r--r--dom/media/mediasession/test/crashtests/inactive-mediasession.html16
-rw-r--r--dom/media/mediasession/test/file_media_session.html31
-rw-r--r--dom/media/mediasession/test/file_trigger_actionhanlder_frame.html40
-rw-r--r--dom/media/mediasession/test/file_trigger_actionhanlder_window.html107
-rw-r--r--dom/media/mediasession/test/mochitest.ini12
-rw-r--r--dom/media/mediasession/test/test_setactionhandler.html92
-rw-r--r--dom/media/mediasession/test/test_trigger_actionhanlder.html57
17 files changed, 1440 insertions, 0 deletions
diff --git a/dom/media/mediasession/MediaMetadata.cpp b/dom/media/mediasession/MediaMetadata.cpp
new file mode 100644
index 0000000000..8ee2c217ac
--- /dev/null
+++ b/dom/media/mediasession/MediaMetadata.cpp
@@ -0,0 +1,154 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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 http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/MediaMetadata.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "mozilla/dom/ScriptSettings.h"
+#include "mozilla/dom/ToJSValue.h"
+#include "nsNetUtil.h"
+
+namespace mozilla::dom {
+
+// Only needed for refcounted objects.
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MediaMetadata, mParent)
+NS_IMPL_CYCLE_COLLECTING_ADDREF(MediaMetadata)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(MediaMetadata)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MediaMetadata)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+MediaMetadata::MediaMetadata(nsIGlobalObject* aParent, const nsString& aTitle,
+ const nsString& aArtist, const nsString& aAlbum)
+ : MediaMetadataBase(aTitle, aArtist, aAlbum), mParent(aParent) {
+ MOZ_ASSERT(mParent);
+}
+
+nsIGlobalObject* MediaMetadata::GetParentObject() const { return mParent; }
+
+JSObject* MediaMetadata::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return MediaMetadata_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+already_AddRefed<MediaMetadata> MediaMetadata::Constructor(
+ const GlobalObject& aGlobal, const MediaMetadataInit& aInit,
+ ErrorResult& aRv) {
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+ if (!global) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ RefPtr<MediaMetadata> mediaMetadata =
+ new MediaMetadata(global, aInit.mTitle, aInit.mArtist, aInit.mAlbum);
+ mediaMetadata->SetArtworkInternal(aInit.mArtwork, aRv);
+ return aRv.Failed() ? nullptr : mediaMetadata.forget();
+}
+
+void MediaMetadata::GetTitle(nsString& aRetVal) const { aRetVal = mTitle; }
+
+void MediaMetadata::SetTitle(const nsAString& aTitle) { mTitle = aTitle; }
+
+void MediaMetadata::GetArtist(nsString& aRetVal) const { aRetVal = mArtist; }
+
+void MediaMetadata::SetArtist(const nsAString& aArtist) { mArtist = aArtist; }
+
+void MediaMetadata::GetAlbum(nsString& aRetVal) const { aRetVal = mAlbum; }
+
+void MediaMetadata::SetAlbum(const nsAString& aAlbum) { mAlbum = aAlbum; }
+
+void MediaMetadata::GetArtwork(JSContext* aCx, nsTArray<JSObject*>& aRetVal,
+ ErrorResult& aRv) const {
+ // Convert the MediaImages to JS Objects
+ if (!aRetVal.SetCapacity(mArtwork.Length(), fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+
+ for (size_t i = 0; i < mArtwork.Length(); ++i) {
+ JS::Rooted<JS::Value> value(aCx);
+ if (!ToJSValue(aCx, mArtwork[i], &value)) {
+ aRv.NoteJSContextException(aCx);
+ return;
+ }
+
+ JS::Rooted<JSObject*> object(aCx, &value.toObject());
+ if (!JS_FreezeObject(aCx, object)) {
+ aRv.NoteJSContextException(aCx);
+ return;
+ }
+
+ aRetVal.AppendElement(object);
+ }
+}
+
+void MediaMetadata::SetArtwork(JSContext* aCx,
+ const Sequence<JSObject*>& aArtwork,
+ ErrorResult& aRv) {
+ // Convert the JS Objects to MediaImages
+ Sequence<MediaImage> artwork;
+ if (!artwork.SetCapacity(aArtwork.Length(), fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+
+ for (JSObject* object : aArtwork) {
+ JS::Rooted<JS::Value> value(aCx, JS::ObjectValue(*object));
+
+ MediaImage* image = artwork.AppendElement(fallible);
+ MOZ_ASSERT(image, "The capacity is preallocated");
+ if (!image->Init(aCx, value)) {
+ aRv.NoteJSContextException(aCx);
+ return;
+ }
+ }
+
+ SetArtworkInternal(artwork, aRv);
+};
+
+static nsIURI* GetEntryBaseURL() {
+ nsCOMPtr<Document> doc = GetEntryDocument();
+ return doc ? doc->GetDocBaseURI() : nullptr;
+}
+
+// `aURL` is an inout parameter.
+static nsresult ResolveURL(nsString& aURL, nsIURI* aBaseURI) {
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL,
+ /* UTF-8 for charset */ nullptr, aBaseURI);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ nsAutoCString spec;
+ rv = uri->GetSpec(spec);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ CopyUTF8toUTF16(spec, aURL);
+ return NS_OK;
+}
+
+void MediaMetadata::SetArtworkInternal(const Sequence<MediaImage>& aArtwork,
+ ErrorResult& aRv) {
+ nsTArray<MediaImage> artwork;
+ artwork.Assign(aArtwork);
+
+ nsCOMPtr<nsIURI> baseURI = GetEntryBaseURL();
+ for (MediaImage& image : artwork) {
+ nsresult rv = ResolveURL(image.mSrc, baseURI);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ aRv.ThrowTypeError<MSG_INVALID_URL>(NS_ConvertUTF16toUTF8(image.mSrc));
+ return;
+ }
+ }
+ mArtwork = std::move(artwork);
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediasession/MediaMetadata.h b/dom/media/mediasession/MediaMetadata.h
new file mode 100644
index 0000000000..6c552e6465
--- /dev/null
+++ b/dom/media/mediasession/MediaMetadata.h
@@ -0,0 +1,97 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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 http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_MediaMetadata_h
+#define mozilla_dom_MediaMetadata_h
+
+#include "js/TypeDecls.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsWrapperCache.h"
+
+class nsIGlobalObject;
+
+namespace mozilla {
+class ErrorResult;
+
+namespace dom {
+
+class MediaMetadataBase {
+ public:
+ MediaMetadataBase() = default;
+ MediaMetadataBase(const nsString& aTitle, const nsString& aArtist,
+ const nsString& aAlbum)
+ : mTitle(aTitle), mArtist(aArtist), mAlbum(aAlbum) {}
+
+ static MediaMetadataBase EmptyData() { return MediaMetadataBase(); }
+
+ nsString mTitle;
+ nsString mArtist;
+ nsString mAlbum;
+ CopyableTArray<MediaImage> mArtwork;
+};
+
+class MediaMetadata final : public nsISupports,
+ public nsWrapperCache,
+ private MediaMetadataBase {
+ public:
+ // Ref counting and cycle collection
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MediaMetadata)
+
+ // WebIDL methods
+ nsIGlobalObject* GetParentObject() const;
+
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ static already_AddRefed<MediaMetadata> Constructor(
+ const GlobalObject& aGlobal, const MediaMetadataInit& aInit,
+ ErrorResult& aRv);
+
+ void GetTitle(nsString& aRetVal) const;
+
+ void SetTitle(const nsAString& aTitle);
+
+ void GetArtist(nsString& aRetVal) const;
+
+ void SetArtist(const nsAString& aArtist);
+
+ void GetAlbum(nsString& aRetVal) const;
+
+ void SetAlbum(const nsAString& aAlbum);
+
+ void GetArtwork(JSContext* aCx, nsTArray<JSObject*>& aRetVal,
+ ErrorResult& aRv) const;
+
+ void SetArtwork(JSContext* aCx, const Sequence<JSObject*>& aArtwork,
+ ErrorResult& aRv);
+
+ // This would expose MediaMetadataBase's members as public, so use this method
+ // carefully. Now we only use this when we want to update the metadata to the
+ // media session controller in the chrome process.
+ MediaMetadataBase* AsMetadataBase() { return this; }
+
+ private:
+ MediaMetadata(nsIGlobalObject* aParent, const nsString& aTitle,
+ const nsString& aArtist, const nsString& aAlbum);
+
+ ~MediaMetadata() = default;
+
+ // Perform `convert artwork algorithm`. Set `mArtwork` to a converted
+ // `aArtwork` if the conversion works, otherwise throw a type error in `aRv`.
+ void SetArtworkInternal(const Sequence<MediaImage>& aArtwork,
+ ErrorResult& aRv);
+
+ nsCOMPtr<nsIGlobalObject> mParent;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_MediaMetadata_h
diff --git a/dom/media/mediasession/MediaSession.cpp b/dom/media/mediasession/MediaSession.cpp
new file mode 100644
index 0000000000..e55fa28d96
--- /dev/null
+++ b/dom/media/mediasession/MediaSession.cpp
@@ -0,0 +1,335 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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 http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/BrowsingContext.h"
+#include "mozilla/dom/ContentMediaController.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/dom/MediaControlUtils.h"
+#include "mozilla/dom/WindowContext.h"
+#include "mozilla/EnumeratedArrayCycleCollection.h"
+
+// avoid redefined macro in unified build
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaSession=%p, " msg, this, ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+// We don't use NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE because we need to
+// unregister MediaSession from document's activity listeners.
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MediaSession)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(MediaSession)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mMediaMetadata)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mActionHandlers)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDoc)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(MediaSession)
+ tmp->Shutdown();
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mMediaMetadata)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mActionHandlers)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mDoc)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+NS_IMPL_CYCLE_COLLECTING_ADDREF(MediaSession)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(MediaSession)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MediaSession)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+ NS_INTERFACE_MAP_ENTRY(nsIDocumentActivity)
+NS_INTERFACE_MAP_END
+
+MediaSession::MediaSession(nsPIDOMWindowInner* aParent)
+ : mParent(aParent), mDoc(mParent->GetExtantDoc()) {
+ MOZ_ASSERT(mParent);
+ MOZ_ASSERT(mDoc);
+ mDoc->RegisterActivityObserver(this);
+ if (mDoc->IsCurrentActiveDocument()) {
+ SetMediaSessionDocStatus(SessionDocStatus::eActive);
+ }
+}
+
+void MediaSession::Shutdown() {
+ if (mDoc) {
+ mDoc->UnregisterActivityObserver(this);
+ }
+ if (mParent) {
+ SetMediaSessionDocStatus(SessionDocStatus::eInactive);
+ }
+}
+
+void MediaSession::NotifyOwnerDocumentActivityChanged() {
+ const bool isDocActive = mDoc->IsCurrentActiveDocument();
+ LOG("Document activity changed, isActive=%d", isDocActive);
+ if (isDocActive) {
+ SetMediaSessionDocStatus(SessionDocStatus::eActive);
+ } else {
+ SetMediaSessionDocStatus(SessionDocStatus::eInactive);
+ }
+}
+
+void MediaSession::SetMediaSessionDocStatus(SessionDocStatus aState) {
+ if (mSessionDocState == aState) {
+ return;
+ }
+ mSessionDocState = aState;
+ NotifyMediaSessionDocStatus(mSessionDocState);
+}
+
+nsPIDOMWindowInner* MediaSession::GetParentObject() const { return mParent; }
+
+JSObject* MediaSession::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return MediaSession_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+MediaMetadata* MediaSession::GetMetadata() const { return mMediaMetadata; }
+
+void MediaSession::SetMetadata(MediaMetadata* aMetadata) {
+ mMediaMetadata = aMetadata;
+ NotifyMetadataUpdated();
+}
+
+void MediaSession::SetPlaybackState(
+ const MediaSessionPlaybackState& aPlaybackState) {
+ if (mDeclaredPlaybackState == aPlaybackState) {
+ return;
+ }
+ mDeclaredPlaybackState = aPlaybackState;
+ NotifyPlaybackStateUpdated();
+}
+
+MediaSessionPlaybackState MediaSession::PlaybackState() const {
+ return mDeclaredPlaybackState;
+}
+
+void MediaSession::SetActionHandler(MediaSessionAction aAction,
+ MediaSessionActionHandler* aHandler) {
+ MOZ_ASSERT(size_t(aAction) < ArrayLength(mActionHandlers));
+ // If the media session changes its supported action, then we would propagate
+ // this information to the chrome process in order to run the media session
+ // actions update algorithm.
+ // https://w3c.github.io/mediasession/#supported-media-session-actions
+ RefPtr<MediaSessionActionHandler>& hanlder = mActionHandlers[aAction];
+ if (!hanlder && aHandler) {
+ NotifyEnableSupportedAction(aAction);
+ } else if (hanlder && !aHandler) {
+ NotifyDisableSupportedAction(aAction);
+ }
+ mActionHandlers[aAction] = aHandler;
+}
+
+MediaSessionActionHandler* MediaSession::GetActionHandler(
+ MediaSessionAction aAction) const {
+ MOZ_ASSERT(size_t(aAction) < ArrayLength(mActionHandlers));
+ return mActionHandlers[aAction];
+}
+
+void MediaSession::SetPositionState(const MediaPositionState& aState,
+ ErrorResult& aRv) {
+ // https://w3c.github.io/mediasession/#dom-mediasession-setpositionstate
+ // If the state is an empty dictionary then clear the position state.
+ if (!aState.IsAnyMemberPresent()) {
+ mPositionState.reset();
+ return;
+ }
+
+ // If the duration is not present, throw a TypeError.
+ if (!aState.mDuration.WasPassed()) {
+ return aRv.ThrowTypeError("Duration is not present");
+ }
+
+ // If the duration is negative, throw a TypeError.
+ if (aState.mDuration.WasPassed() && aState.mDuration.Value() < 0.0) {
+ return aRv.ThrowTypeError(nsPrintfCString(
+ "Invalid duration %f, it can't be negative", aState.mDuration.Value()));
+ }
+
+ // If the position is negative or greater than duration, throw a TypeError.
+ if (aState.mPosition.WasPassed() &&
+ (aState.mPosition.Value() < 0.0 ||
+ aState.mPosition.Value() > aState.mDuration.Value())) {
+ return aRv.ThrowTypeError(nsPrintfCString(
+ "Invalid position %f, it can't be negative or greater than duration",
+ aState.mPosition.Value()));
+ }
+
+ // If the playbackRate is zero, throw a TypeError.
+ if (aState.mPlaybackRate.WasPassed() && aState.mPlaybackRate.Value() == 0.0) {
+ return aRv.ThrowTypeError("The playbackRate is zero");
+ }
+
+ // If the position is not present, set it to zero.
+ double position = aState.mPosition.WasPassed() ? aState.mPosition.Value() : 0;
+
+ // If the playbackRate is not present, set it to 1.0.
+ double playbackRate =
+ aState.mPlaybackRate.WasPassed() ? aState.mPlaybackRate.Value() : 1.0;
+
+ // Update the position state and last position updated time.
+ MOZ_ASSERT(aState.mDuration.WasPassed());
+ mPositionState =
+ Some(PositionState(aState.mDuration.Value(), playbackRate, position));
+ NotifyPositionStateChanged();
+}
+
+void MediaSession::NotifyHandler(const MediaSessionActionDetails& aDetails) {
+ DispatchNotifyHandler(aDetails);
+}
+
+void MediaSession::DispatchNotifyHandler(
+ const MediaSessionActionDetails& aDetails) {
+ class Runnable final : public mozilla::Runnable {
+ public:
+ Runnable(const MediaSession* aSession,
+ const MediaSessionActionDetails& aDetails)
+ : mozilla::Runnable("MediaSession::DispatchNotifyHandler"),
+ mSession(aSession),
+ mDetails(aDetails) {}
+
+ MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHOD Run() override {
+ if (RefPtr<MediaSessionActionHandler> handler =
+ mSession->GetActionHandler(mDetails.mAction)) {
+ handler->Call(mDetails);
+ }
+ return NS_OK;
+ }
+
+ private:
+ RefPtr<const MediaSession> mSession;
+ MediaSessionActionDetails mDetails;
+ };
+
+ RefPtr<nsIRunnable> runnable = new Runnable(this, aDetails);
+ NS_DispatchToMainThread(runnable);
+}
+
+bool MediaSession::IsSupportedAction(MediaSessionAction aAction) const {
+ MOZ_ASSERT(size_t(aAction) < ArrayLength(mActionHandlers));
+ return mActionHandlers[aAction] != nullptr;
+}
+
+bool MediaSession::IsActive() const {
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC);
+ RefPtr<WindowContext> wc = currentBC->GetTopWindowContext();
+ if (!wc) {
+ return false;
+ }
+ Maybe<uint64_t> activeSessionContextId = wc->GetActiveMediaSessionContextId();
+ if (!activeSessionContextId) {
+ return false;
+ }
+ LOG("session context Id=%" PRIu64 ", active session context Id=%" PRIu64,
+ currentBC->Id(), *activeSessionContextId);
+ return *activeSessionContextId == currentBC->Id();
+}
+
+void MediaSession::NotifyMediaSessionDocStatus(SessionDocStatus aState) {
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC, "Update session status after context destroyed!");
+
+ RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC);
+ if (!updater) {
+ return;
+ }
+ if (aState == SessionDocStatus::eActive) {
+ updater->NotifySessionCreated(currentBC->Id());
+ // If media session set its attributes before its document becomes active,
+ // then we would notify those attributes which hasn't been notified as well
+ // because attributes update would only happen if its document is already
+ // active.
+ NotifyMediaSessionAttributes();
+ } else {
+ updater->NotifySessionDestroyed(currentBC->Id());
+ }
+}
+
+void MediaSession::NotifyMediaSessionAttributes() {
+ MOZ_ASSERT(mSessionDocState == SessionDocStatus::eActive);
+ if (mDeclaredPlaybackState != MediaSessionPlaybackState::None) {
+ NotifyPlaybackStateUpdated();
+ }
+ if (mMediaMetadata) {
+ NotifyMetadataUpdated();
+ }
+ for (size_t idx = 0; idx < ArrayLength(mActionHandlers); idx++) {
+ MediaSessionAction action = static_cast<MediaSessionAction>(idx);
+ if (mActionHandlers[action]) {
+ NotifyEnableSupportedAction(action);
+ }
+ }
+ if (mPositionState) {
+ NotifyPositionStateChanged();
+ }
+}
+
+void MediaSession::NotifyPlaybackStateUpdated() {
+ if (mSessionDocState != SessionDocStatus::eActive) {
+ return;
+ }
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC,
+ "Update session playback state after context destroyed!");
+ if (RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC)) {
+ updater->SetDeclaredPlaybackState(currentBC->Id(), mDeclaredPlaybackState);
+ }
+}
+
+void MediaSession::NotifyMetadataUpdated() {
+ if (mSessionDocState != SessionDocStatus::eActive) {
+ return;
+ }
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC, "Update session metadata after context destroyed!");
+
+ Maybe<MediaMetadataBase> metadata;
+ if (GetMetadata()) {
+ metadata.emplace(*(GetMetadata()->AsMetadataBase()));
+ }
+ if (RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC)) {
+ updater->UpdateMetadata(currentBC->Id(), metadata);
+ }
+}
+
+void MediaSession::NotifyEnableSupportedAction(MediaSessionAction aAction) {
+ if (mSessionDocState != SessionDocStatus::eActive) {
+ return;
+ }
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC, "Update action after context destroyed!");
+ if (RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC)) {
+ updater->EnableAction(currentBC->Id(), aAction);
+ }
+}
+
+void MediaSession::NotifyDisableSupportedAction(MediaSessionAction aAction) {
+ if (mSessionDocState != SessionDocStatus::eActive) {
+ return;
+ }
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC, "Update action after context destroyed!");
+ if (RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC)) {
+ updater->DisableAction(currentBC->Id(), aAction);
+ }
+}
+
+void MediaSession::NotifyPositionStateChanged() {
+ if (mSessionDocState != SessionDocStatus::eActive) {
+ return;
+ }
+ RefPtr<BrowsingContext> currentBC = GetParentObject()->GetBrowsingContext();
+ MOZ_ASSERT(currentBC, "Update action after context destroyed!");
+ if (RefPtr<IMediaInfoUpdater> updater = ContentMediaAgent::Get(currentBC)) {
+ updater->UpdatePositionState(currentBC->Id(), *mPositionState);
+ }
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediasession/MediaSession.h b/dom/media/mediasession/MediaSession.h
new file mode 100644
index 0000000000..db6864c842
--- /dev/null
+++ b/dom/media/mediasession/MediaSession.h
@@ -0,0 +1,132 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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 http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_MediaSession_h
+#define mozilla_dom_MediaSession_h
+
+#include "js/TypeDecls.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "mozilla/EnumeratedArray.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsIDocumentActivity.h"
+#include "nsWrapperCache.h"
+
+class nsPIDOMWindowInner;
+
+namespace mozilla {
+class ErrorResult;
+
+namespace dom {
+
+class Document;
+class MediaMetadata;
+
+// https://w3c.github.io/mediasession/#position-state
+struct PositionState {
+ PositionState() = default;
+ PositionState(double aDuration, double aPlaybackRate,
+ double aLastReportedTime)
+ : mDuration(aDuration),
+ mPlaybackRate(aPlaybackRate),
+ mLastReportedPlaybackPosition(aLastReportedTime) {}
+ double mDuration;
+ double mPlaybackRate;
+ double mLastReportedPlaybackPosition;
+};
+
+class MediaSession final : public nsIDocumentActivity, public nsWrapperCache {
+ public:
+ // Ref counting and cycle collection
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MediaSession)
+ NS_DECL_NSIDOCUMENTACTIVITY
+
+ explicit MediaSession(nsPIDOMWindowInner* aParent);
+
+ // WebIDL methods
+ nsPIDOMWindowInner* GetParentObject() const;
+
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ MediaMetadata* GetMetadata() const;
+
+ void SetMetadata(MediaMetadata* aMetadata);
+
+ void SetPlaybackState(const MediaSessionPlaybackState& aPlaybackState);
+
+ MediaSessionPlaybackState PlaybackState() const;
+
+ void SetActionHandler(MediaSessionAction aAction,
+ MediaSessionActionHandler* aHandler);
+
+ void SetPositionState(const MediaPositionState& aState, ErrorResult& aRv);
+
+ bool IsSupportedAction(MediaSessionAction aAction) const;
+
+ // Use this method to trigger media session action handler asynchronously.
+ void NotifyHandler(const MediaSessionActionDetails& aDetails);
+
+ void Shutdown();
+
+ // `MediaStatusManager` would determine which media session is an active media
+ // session and update it from the chrome process. This active session is not
+ // 100% equal to the active media session in the spec, which is a globally
+ // active media session *among all tabs*. The active session here is *among
+ // different windows but in same tab*, so each tab can have at most one
+ // active media session.
+ bool IsActive() const;
+
+ private:
+ // When the document which media session belongs to is going to be destroyed,
+ // or is in the bfcache, then the session would be inactive. Otherwise, it's
+ // active all the time.
+ enum class SessionDocStatus : bool {
+ eInactive = false,
+ eActive = true,
+ };
+ void SetMediaSessionDocStatus(SessionDocStatus aState);
+
+ // These methods are used to propagate media session's status to the chrome
+ // process.
+ void NotifyMediaSessionDocStatus(SessionDocStatus aState);
+ void NotifyMediaSessionAttributes();
+ void NotifyPlaybackStateUpdated();
+ void NotifyMetadataUpdated();
+ void NotifyEnableSupportedAction(MediaSessionAction aAction);
+ void NotifyDisableSupportedAction(MediaSessionAction aAction);
+ void NotifyPositionStateChanged();
+
+ void DispatchNotifyHandler(const MediaSessionActionDetails& aDetails);
+
+ MediaSessionActionHandler* GetActionHandler(MediaSessionAction aAction) const;
+
+ ~MediaSession() = default;
+
+ nsCOMPtr<nsPIDOMWindowInner> mParent;
+
+ RefPtr<MediaMetadata> mMediaMetadata;
+
+ EnumeratedArray<MediaSessionAction, MediaSessionAction::EndGuard_,
+ RefPtr<MediaSessionActionHandler>>
+ mActionHandlers;
+
+ // This is used as is a hint for the user agent to determine whether the
+ // browsing context is playing or paused.
+ // https://w3c.github.io/mediasession/#declared-playback-state
+ MediaSessionPlaybackState mDeclaredPlaybackState =
+ MediaSessionPlaybackState::None;
+
+ Maybe<PositionState> mPositionState;
+ RefPtr<Document> mDoc;
+ SessionDocStatus mSessionDocState = SessionDocStatus::eInactive;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_MediaSession_h
diff --git a/dom/media/mediasession/MediaSessionIPCUtils.h b/dom/media/mediasession/MediaSessionIPCUtils.h
new file mode 100644
index 0000000000..c44f4b4553
--- /dev/null
+++ b/dom/media/mediasession/MediaSessionIPCUtils.h
@@ -0,0 +1,102 @@
+/* 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 http://mozilla.org/MPL/2.0/. */
+
+#ifndef DOM_MEDIA_MEDIASESSION_MEDIASESSIONIPCUTILS_H_
+#define DOM_MEDIA_MEDIASESSION_MEDIASESSIONIPCUTILS_H_
+
+#include "ipc/EnumSerializer.h"
+#include "MediaMetadata.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "mozilla/Maybe.h"
+
+namespace mozilla {
+namespace dom {
+
+typedef Maybe<MediaMetadataBase> MaybeMediaMetadataBase;
+
+} // namespace dom
+} // namespace mozilla
+
+namespace IPC {
+
+template <>
+struct ParamTraits<mozilla::dom::MediaImage> {
+ typedef mozilla::dom::MediaImage paramType;
+
+ static void Write(MessageWriter* aWriter, const paramType& aParam) {
+ WriteParam(aWriter, aParam.mSizes);
+ WriteParam(aWriter, aParam.mSrc);
+ WriteParam(aWriter, aParam.mType);
+ }
+
+ static bool Read(MessageReader* aReader, paramType* aResult) {
+ if (!ReadParam(aReader, &(aResult->mSizes)) ||
+ !ReadParam(aReader, &(aResult->mSrc)) ||
+ !ReadParam(aReader, &(aResult->mType))) {
+ return false;
+ }
+ return true;
+ }
+};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaMetadataBase> {
+ typedef mozilla::dom::MediaMetadataBase paramType;
+
+ static void Write(MessageWriter* aWriter, const paramType& aParam) {
+ WriteParam(aWriter, aParam.mTitle);
+ WriteParam(aWriter, aParam.mArtist);
+ WriteParam(aWriter, aParam.mAlbum);
+ WriteParam(aWriter, aParam.mArtwork);
+ }
+
+ static bool Read(MessageReader* aReader, paramType* aResult) {
+ if (!ReadParam(aReader, &(aResult->mTitle)) ||
+ !ReadParam(aReader, &(aResult->mArtist)) ||
+ !ReadParam(aReader, &(aResult->mAlbum)) ||
+ !ReadParam(aReader, &(aResult->mArtwork))) {
+ return false;
+ }
+ return true;
+ }
+};
+
+template <>
+struct ParamTraits<mozilla::dom::PositionState> {
+ typedef mozilla::dom::PositionState paramType;
+
+ static void Write(MessageWriter* aWriter, const paramType& aParam) {
+ WriteParam(aWriter, aParam.mDuration);
+ WriteParam(aWriter, aParam.mPlaybackRate);
+ WriteParam(aWriter, aParam.mLastReportedPlaybackPosition);
+ }
+
+ static bool Read(MessageReader* aReader, paramType* aResult) {
+ if (!ReadParam(aReader, &(aResult->mDuration)) ||
+ !ReadParam(aReader, &(aResult->mPlaybackRate)) ||
+ !ReadParam(aReader, &(aResult->mLastReportedPlaybackPosition))) {
+ return false;
+ }
+ return true;
+ }
+};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaSessionPlaybackState>
+ : public ContiguousEnumSerializer<
+ mozilla::dom::MediaSessionPlaybackState,
+ mozilla::dom::MediaSessionPlaybackState::None,
+ mozilla::dom::MediaSessionPlaybackState::EndGuard_> {};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaSessionAction>
+ : public ContiguousEnumSerializer<
+ mozilla::dom::MediaSessionAction,
+ mozilla::dom::MediaSessionAction::Play,
+ mozilla::dom::MediaSessionAction::EndGuard_> {};
+
+} // namespace IPC
+
+#endif // DOM_MEDIA_MEDIASESSION_MEDIASESSIONIPCUTILS_H_
diff --git a/dom/media/mediasession/moz.build b/dom/media/mediasession/moz.build
new file mode 100644
index 0000000000..afd208b0ca
--- /dev/null
+++ b/dom/media/mediasession/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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 http://mozilla.org/MPL/2.0/.
+
+MOCHITEST_MANIFESTS += ["test/mochitest.ini"]
+
+EXPORTS.mozilla.dom += [
+ "MediaMetadata.h",
+ "MediaSession.h",
+ "MediaSessionIPCUtils.h",
+]
+
+UNIFIED_SOURCES += [
+ "MediaMetadata.cpp",
+ "MediaSession.cpp",
+]
+
+CRASHTEST_MANIFESTS += ["test/crashtests/crashtests.list"]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+FINAL_LIBRARY = "xul"
diff --git a/dom/media/mediasession/test/MediaSessionTestUtils.js b/dom/media/mediasession/test/MediaSessionTestUtils.js
new file mode 100644
index 0000000000..1ab0e1fe9b
--- /dev/null
+++ b/dom/media/mediasession/test/MediaSessionTestUtils.js
@@ -0,0 +1,30 @@
+const gMediaSessionActions = [
+ "play",
+ "pause",
+ "seekbackward",
+ "seekforward",
+ "previoustrack",
+ "nexttrack",
+ "skipad",
+ "seekto",
+ "stop",
+];
+
+// gCommands and gResults are used in `test_active_mediasession_within_page.html`
+const gCommands = {
+ createMainFrameSession: "create-main-frame-session",
+ createChildFrameSession: "create-child-frame-session",
+ destroyChildFrameSessions: "destroy-child-frame-sessions",
+ destroyActiveChildFrameSession: "destroy-active-child-frame-session",
+ destroyInactiveChildFrameSession: "destroy-inactive-child-frame-session",
+};
+
+const gResults = {
+ mainFrameSession: "main-frame-session",
+ childFrameSession: "child-session-unchanged",
+ childFrameSessionUpdated: "child-session-changed",
+};
+
+function nextWindowMessage() {
+ return new Promise(r => (window.onmessage = event => r(event)));
+}
diff --git a/dom/media/mediasession/test/browser.ini b/dom/media/mediasession/test/browser.ini
new file mode 100644
index 0000000000..b3cd8300cc
--- /dev/null
+++ b/dom/media/mediasession/test/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+subsuite = media-bc
+tags = mediacontrol
+support-files =
+ file_media_session.html
+ ../../test/gizmo.mp4
+
+[browser_active_mediasession_among_tabs.js]
+
diff --git a/dom/media/mediasession/test/browser_active_mediasession_among_tabs.js b/dom/media/mediasession/test/browser_active_mediasession_among_tabs.js
new file mode 100644
index 0000000000..a6a1dbee68
--- /dev/null
+++ b/dom/media/mediasession/test/browser_active_mediasession_among_tabs.js
@@ -0,0 +1,201 @@
+/* eslint-disable no-undef */
+"use strict";
+
+const PAGE =
+ "https://example.com/browser/dom/media/mediasession/test/file_media_session.html";
+
+const ACTION = "previoustrack";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.media.mediasession.enabled", true],
+ ["media.mediacontrol.testingevents.enabled", true],
+ ],
+ });
+});
+
+/**
+ * When multiple tabs are all having media session, the latest created one would
+ * become an active session. When the active media session is destroyed via
+ * closing the tab, the previous active session would become current active
+ * session again.
+ */
+add_task(async function testActiveSessionWhenClosingTab() {
+ info(`open tab1 and load media session test page`);
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab1);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab1 should become active session`);
+ await checkIfActionReceived(tab1, ACTION);
+
+ info(`open tab2 and load media session test page`);
+ const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab2);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab2 should become active session`);
+ await checkIfActionReceived(tab2, ACTION);
+ await checkIfActionNotReceived(tab1, ACTION);
+
+ info(`remove tab2`);
+ const controllerChanged = waitUntilMainMediaControllerChanged();
+ BrowserTestUtils.removeTab(tab2);
+ await controllerChanged;
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab1 should become active session again`);
+ await checkIfActionReceived(tab1, ACTION);
+
+ info(`remove tab1`);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/**
+ * This test is similar with `testActiveSessionWhenClosingTab`, the difference
+ * is that the way we use to destroy active session is via naviagation, not
+ * closing tab.
+ */
+add_task(async function testActiveSessionWhenNavigatingTab() {
+ info(`open tab1 and load media session test page`);
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab1);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab1 should become active session`);
+ await checkIfActionReceived(tab1, ACTION);
+
+ info(`open tab2 and load media session test page`);
+ const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab2);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab2 should become active session`);
+ await checkIfActionReceived(tab2, ACTION);
+ await checkIfActionNotReceived(tab1, ACTION);
+
+ info(`navigate tab2 to blank page`);
+ const controllerChanged = waitUntilMainMediaControllerChanged();
+ BrowserTestUtils.loadURIString(tab2.linkedBrowser, "about:blank");
+ await controllerChanged;
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab1 should become active session`);
+ await checkIfActionReceived(tab1, ACTION);
+
+ info(`remove tabs`);
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+/**
+ * If we create a media session in a tab where no any playing media exists, then
+ * that session would not involve in global active media session selection. The
+ * current active media session would remain unchanged.
+ */
+add_task(async function testCreatingSessionWithoutPlayingMedia() {
+ info(`open tab1 and load media session test page`);
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab1);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(`session in tab1 should become active session`);
+ await checkIfActionReceived(tab1, ACTION);
+
+ info(`open tab2 and load media session test page`);
+ const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+
+ info(`pressing '${ACTION}' key`);
+ MediaControlService.generateMediaControlKey(ACTION);
+
+ info(
+ `session in tab1 is still an active session because there is no media playing in tab2`
+ );
+ await checkIfActionReceived(tab1, ACTION);
+ await checkIfActionNotReceived(tab2, ACTION);
+
+ info(`remove tabs`);
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+/**
+ * The following are helper functions
+ */
+async function startMediaPlaybackAndWaitMedisSessionBecomeActiveSession(tab) {
+ await Promise.all([
+ BrowserUtils.promiseObserved("active-media-session-changed"),
+ SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const video = content.document.getElementById("testVideo");
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ video.play();
+ }),
+ ]);
+}
+
+async function checkIfActionReceived(tab, action) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [action], expectedAction => {
+ return new Promise(resolve => {
+ const result = content.document.getElementById("result");
+ if (!result) {
+ ok(false, `can't get the element for showing result!`);
+ }
+
+ function checkAction() {
+ is(
+ result.innerHTML,
+ expectedAction,
+ `received '${expectedAction}' correctly`
+ );
+ // Reset the result after finishing checking result, then we can dispatch
+ // same action again without worrying about previous result.
+ result.innerHTML = "";
+ resolve();
+ }
+
+ if (result.innerHTML == "") {
+ info(`wait until receiving action`);
+ result.addEventListener("actionChanged", () => checkAction(), {
+ once: true,
+ });
+ } else {
+ checkAction();
+ }
+ });
+ });
+}
+
+async function checkIfActionNotReceived(tab, action) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [action], expectedAction => {
+ return new Promise(resolve => {
+ const result = content.document.getElementById("result");
+ if (!result) {
+ ok(false, `can't get the element for showing result!`);
+ }
+ is(result.innerHTML, "", `should not receive any action`);
+ ok(result.innerHTML != expectedAction, `not receive '${expectedAction}'`);
+ resolve();
+ });
+ });
+}
+
+function waitUntilMainMediaControllerChanged() {
+ return BrowserUtils.promiseObserved("main-media-controller-changed");
+}
diff --git a/dom/media/mediasession/test/crashtests/crashtests.list b/dom/media/mediasession/test/crashtests/crashtests.list
new file mode 100644
index 0000000000..9ca4956ab6
--- /dev/null
+++ b/dom/media/mediasession/test/crashtests/crashtests.list
@@ -0,0 +1 @@
+load inactive-mediasession.html
diff --git a/dom/media/mediasession/test/crashtests/inactive-mediasession.html b/dom/media/mediasession/test/crashtests/inactive-mediasession.html
new file mode 100644
index 0000000000..b24fb887ff
--- /dev/null
+++ b/dom/media/mediasession/test/crashtests/inactive-mediasession.html
@@ -0,0 +1,16 @@
+<html>
+<head></head>
+<script>
+const frame = document.createElementNS('http://www.w3.org/1999/xhtml', 'frame');
+document.documentElement.appendChild(frame);
+
+const windowPointer = frame.contentWindow;
+document.documentElement.replaceWith();
+
+// Setting attributes on inactive media session should not cause crash.
+windowPointer.navigator.mediaSession.setActionHandler('nexttrack', null);
+windowPointer.navigator.mediaSession.playbackState = "playing";
+windowPointer.navigator.mediaSession.setPositionState();
+windowPointer.navigator.mediaSession.metadata = null;
+</script>
+</html>
diff --git a/dom/media/mediasession/test/file_media_session.html b/dom/media/mediasession/test/file_media_session.html
new file mode 100644
index 0000000000..b6680fb46b
--- /dev/null
+++ b/dom/media/mediasession/test/file_media_session.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Media Session and non-autoplay media</title>
+</head>
+<body>
+<video id="testVideo" src="gizmo.mp4" loop></video>
+<h1 id="result"></h1>
+<script type="text/javascript">
+
+const MediaSessionActions = [
+ "play",
+ "pause",
+ "seekbackward",
+ "seekforward",
+ "previoustrack",
+ "nexttrack",
+ "stop",
+];
+
+for (const action of MediaSessionActions) {
+ navigator.mediaSession.setActionHandler(action, () => {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("result").innerHTML = action;
+ document.getElementById("result").dispatchEvent(new CustomEvent("actionChanged"));
+ });
+}
+
+</script>
+</body>
+</html>
diff --git a/dom/media/mediasession/test/file_trigger_actionhanlder_frame.html b/dom/media/mediasession/test/file_trigger_actionhanlder_frame.html
new file mode 100644
index 0000000000..4d55db2189
--- /dev/null
+++ b/dom/media/mediasession/test/file_trigger_actionhanlder_frame.html
@@ -0,0 +1,40 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Test frame for triggering media session's action handler</title>
+ <script src="MediaSessionTestUtils.js"></script>
+ </head>
+<body>
+<video id="testVideo" src="gizmo.mp4" loop></video>
+<script>
+
+const video = document.getElementById("testVideo");
+const w = window.opener || window.parent;
+
+window.onmessage = async event => {
+ if (event.data == "play") {
+ await video.play();
+ // As we can't observe `media-displayed-playback-changed` notification,
+ // that can only be observed in the chrome process. Therefore, we use a
+ // workaround instead which is to wait for a while to ensure that the
+ // controller has already been created in the chrome process.
+ let timeupdatecount = 0;
+ await new Promise(r => video.ontimeupdate = () => {
+ if (++timeupdatecount == 3) {
+ video.ontimeupdate = null;
+ r();
+ }
+ });
+ w.postMessage("played", "*");
+ }
+}
+
+// Setup the action handlers which would post the result back to the main window.
+for (const action of gMediaSessionActions) {
+ navigator.mediaSession.setActionHandler(action, () => {
+ w.postMessage(action, "*");
+ });
+}
+</script>
+</body>
+</html>
diff --git a/dom/media/mediasession/test/file_trigger_actionhanlder_window.html b/dom/media/mediasession/test/file_trigger_actionhanlder_window.html
new file mode 100644
index 0000000000..f3316d6dad
--- /dev/null
+++ b/dom/media/mediasession/test/file_trigger_actionhanlder_window.html
@@ -0,0 +1,107 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Test window for triggering media session's action handler</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="MediaSessionTestUtils.js"></script>
+ </head>
+<body>
+<video id="testVideo" src="gizmo.mp4" loop></video>
+<iframe id="childFrame"></iframe>
+<script>
+
+var triggeredActionNums = 0;
+
+nextWindowMessage().then(
+ async (event) => {
+ const testInfo = event.data;
+ await createSession(testInfo);
+ // Media session would only become active if there is any media currently
+ // playing. Non-active media session won't receive any actions. Therefore,
+ // we start media playback before testing media session.
+ await startMediaPlayback(testInfo);
+ for (const action of gMediaSessionActions) {
+ await waitUntilActionHandlerTriggered(action, testInfo);
+ }
+ endTestAndReportResult();
+ });
+
+/**
+ * The following are helper functions
+ */
+async function startMediaPlayback({shouldCreateFrom}) {
+ info(`wait until media starts playing`);
+ if (shouldCreateFrom == "main-frame") {
+ const video = document.getElementById("testVideo");
+ await video.play();
+ // As we can't observe `media-displayed-playback-changed` notification,
+ // that can only be observed in the chrome process. Therefore, we use a
+ // workaround instead which is to wait for a while to ensure that the
+ // controller has already been created in the chrome process.
+ let timeupdatecount = 0;
+ await new Promise(r => video.ontimeupdate = () => {
+ if (++timeupdatecount == 3) {
+ video.ontimeupdate = null;
+ r();
+ }
+ });
+ } else {
+ const iframe = document.getElementById("childFrame");
+ iframe.contentWindow.postMessage("play", "*");
+ await new Promise(r => {
+ window.onmessage = event => {
+ is(event.data, "played", `media started playing in child-frame`);
+ r();
+ };
+ });
+ }
+}
+
+async function createSession({shouldCreateFrom, origin}) {
+ info(`create media session in ${shouldCreateFrom}`);
+ if (shouldCreateFrom == "main-frame") {
+ // Simply referencing media session will create media session.
+ navigator.mediaSession;
+ return;
+ };
+ const frame = document.getElementById("childFrame");
+ const originURL = origin == "same-origin"
+ ? "http://mochi.test:8888" : "http://example.org";
+ frame.src = originURL + "/tests/dom/media/mediasession/test/file_trigger_actionhanlder_frame.html";
+ await new Promise(r => frame.onload = r);
+}
+
+async function waitUntilActionHandlerTriggered(action, {shouldCreateFrom}) {
+ info(`wait until '${action}' handler of media session created in ` +
+ `${shouldCreateFrom} is triggered`);
+ if (shouldCreateFrom == "main-frame") {
+ let promise = new Promise(resolve => {
+ navigator.mediaSession.setActionHandler(action, () => {
+ ok(true, `Triggered ${action} handler`);
+ triggeredActionNums++;
+ resolve();
+ });
+ });
+ SpecialPowers.generateMediaControlKeyTestEvent(action);
+ await promise;
+ return;
+ }
+ SpecialPowers.generateMediaControlKeyTestEvent(action);
+ if ((await nextWindowMessage()).data == action) {
+ info(`Triggered ${action} handler in child-frame`);
+ triggeredActionNums++;
+ }
+}
+
+function endTestAndReportResult() {
+ const w = window.opener || window.parent;
+ if (triggeredActionNums == gMediaSessionActions.length) {
+ w.postMessage("success", "*");
+ } else {
+ w.postMessage("fail", "*");
+ }
+}
+
+</script>
+</body>
+</html>
diff --git a/dom/media/mediasession/test/mochitest.ini b/dom/media/mediasession/test/mochitest.ini
new file mode 100644
index 0000000000..146f1afea8
--- /dev/null
+++ b/dom/media/mediasession/test/mochitest.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+subsuite = media
+tags = mediasession mediacontrol
+
+support-files =
+ ../../test/gizmo.mp4
+ file_trigger_actionhanlder_frame.html
+ file_trigger_actionhanlder_window.html
+ MediaSessionTestUtils.js
+
+[test_setactionhandler.html]
+[test_trigger_actionhanlder.html]
diff --git a/dom/media/mediasession/test/test_setactionhandler.html b/dom/media/mediasession/test/test_setactionhandler.html
new file mode 100644
index 0000000000..e0fba77d80
--- /dev/null
+++ b/dom/media/mediasession/test/test_setactionhandler.html
@@ -0,0 +1,92 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title></title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+
+SimpleTest.waitForExplicitFinish();
+
+const ACTIONS = [
+ "play",
+ "pause",
+ "seekbackward",
+ "seekforward",
+ "previoustrack",
+ "nexttrack",
+ "skipad",
+ "seekto",
+ "stop",
+];
+
+(async function testSetActionHandler() {
+ await setupPreference();
+
+ for (const action of ACTIONS) {
+ info(`Test setActionHandler for '${action}'`);
+ generateAction(action);
+ ok(true, "it's ok to do " + action + " without any handler");
+
+ let expectedDetails = generateActionDetails(action);
+
+ let fired = false;
+ await setHandlerAndTakeAction(action, details => {
+ ok(hasSameValue(details, expectedDetails), "get expected details for " + action);
+ fired = !fired;
+ clearActionHandler(action);
+ });
+
+ generateAction(action);
+ ok(fired, "handler of " + action + " is cleared");
+ }
+
+ SimpleTest.finish();
+})();
+
+function setupPreference() {
+ return SpecialPowers.pushPrefEnv({"set": [
+ ["dom.media.mediasession.enabled", true],
+ ]});
+}
+
+function generateAction(action) {
+ let details = generateActionDetails(action);
+ SpecialPowers.wrap(navigator.mediaSession).notifyHandler(details);
+}
+
+function generateActionDetails(action) {
+ let details = { action };
+ if (action == "seekbackward" || action == "seekforward") {
+ details.seekOffset = 3.14159;
+ } else if (action == "seekto") {
+ details.seekTime = 1.618;
+ }
+ return details;
+}
+
+function setHandlerAndTakeAction(action, handler) {
+ let promise = new Promise(resolve => {
+ navigator.mediaSession.setActionHandler(action, details => {
+ handler(details);
+ resolve();
+ });
+ });
+ generateAction(action);
+ return promise;
+}
+
+function hasSameValue(a, b) {
+ // The order of the object matters when stringify the object.
+ return JSON.stringify(a) == JSON.stringify(b);
+}
+
+function clearActionHandler(action) {
+ navigator.mediaSession.setActionHandler(action, null);
+}
+
+</script>
+</body>
+</html>
diff --git a/dom/media/mediasession/test/test_trigger_actionhanlder.html b/dom/media/mediasession/test/test_trigger_actionhanlder.html
new file mode 100644
index 0000000000..72e8eaf7b5
--- /dev/null
+++ b/dom/media/mediasession/test/test_trigger_actionhanlder.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Test of triggering media session's action handlers</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="MediaSessionTestUtils.js"></script>
+ </head>
+<body>
+<script>
+/**
+ * This test is used to test if pressing media control keys can trigger media
+ * session's corresponding action handler under different situations.
+ */
+const testCases = [
+ {
+ name: "Triggering action handlers for session created in [main-frame]",
+ shouldCreateFrom: "main-frame",
+ },
+ {
+ name: "Triggering action handlers for session created in [same-origin] [child-frame]",
+ shouldCreateFrom: "child-frame",
+ origin: "same-origin",
+ },
+ {
+ name: "Triggering action handlers for session created in [cross-origin] [child-frame]",
+ shouldCreateFrom: "child-frame",
+ origin: "cross-origin",
+ },
+];
+
+SimpleTest.waitForExplicitFinish();
+
+SpecialPowers.pushPrefEnv({"set": [
+ ["dom.media.mediasession.enabled", true],
+ ["media.mediacontrol.testingevents.enabled", true],
+]}, startTest());
+
+async function startTest() {
+ for (const testCase of testCases) {
+ info(`- loading test '${testCase.name}' in a new window -`);
+ const testURL = "file_trigger_actionhanlder_window.html";
+ const testWindow = window.open(testURL, "", "width=500,height=500");
+ await new Promise(r => testWindow.onload = r);
+
+ info("- start running test -");
+ testWindow.postMessage(testCase, window.origin);
+ is((await nextWindowMessage()).data, "success",
+ `- finished test '${testCase.name}' -`);
+ testWindow.close();
+ }
+ SimpleTest.finish();
+}
+
+</script>
+</body>
+</html>