summaryrefslogtreecommitdiffstats
path: root/dom/media/mediacontrol
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /dom/media/mediacontrol
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/media/mediacontrol')
-rw-r--r--dom/media/mediacontrol/AudioFocusManager.cpp134
-rw-r--r--dom/media/mediacontrol/AudioFocusManager.h54
-rw-r--r--dom/media/mediacontrol/ContentMediaController.cpp376
-rw-r--r--dom/media/mediacontrol/ContentMediaController.h109
-rw-r--r--dom/media/mediacontrol/ContentPlaybackController.cpp210
-rw-r--r--dom/media/mediacontrol/ContentPlaybackController.h73
-rw-r--r--dom/media/mediacontrol/FetchImageHelper.cpp164
-rw-r--r--dom/media/mediacontrol/FetchImageHelper.h82
-rw-r--r--dom/media/mediacontrol/MediaControlIPC.h75
-rw-r--r--dom/media/mediacontrol/MediaControlKeyManager.cpp228
-rw-r--r--dom/media/mediacontrol/MediaControlKeyManager.h73
-rw-r--r--dom/media/mediacontrol/MediaControlKeySource.cpp122
-rw-r--r--dom/media/mediacontrol/MediaControlKeySource.h122
-rw-r--r--dom/media/mediacontrol/MediaControlService.cpp540
-rw-r--r--dom/media/mediacontrol/MediaControlService.h181
-rw-r--r--dom/media/mediacontrol/MediaControlUtils.cpp26
-rw-r--r--dom/media/mediacontrol/MediaControlUtils.h216
-rw-r--r--dom/media/mediacontrol/MediaController.cpp560
-rw-r--r--dom/media/mediacontrol/MediaController.h214
-rw-r--r--dom/media/mediacontrol/MediaPlaybackStatus.cpp142
-rw-r--r--dom/media/mediacontrol/MediaPlaybackStatus.h139
-rw-r--r--dom/media/mediacontrol/MediaStatusManager.cpp482
-rw-r--r--dom/media/mediacontrol/MediaStatusManager.h276
-rw-r--r--dom/media/mediacontrol/PositionStateEvent.h60
-rw-r--r--dom/media/mediacontrol/moz.build43
-rw-r--r--dom/media/mediacontrol/tests/browser/browser.ini53
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js179
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js94
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_default_action_handler.js422
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js358
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js206
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js45
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js62
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js342
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js416
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js205
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js116
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js150
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js89
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js80
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js130
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js103
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js76
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js108
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js189
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js59
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js108
-rw-r--r--dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js131
-rw-r--r--dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html10
-rw-r--r--dom/media/mediacontrol/tests/browser/file_autoplay.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/file_empty_title.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/file_error_media.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/file_iframe_media.html94
-rw-r--r--dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html11
-rw-r--r--dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html11
-rw-r--r--dom/media/mediacontrol/tests/browser/file_muted_autoplay.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/file_no_src_media.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/file_non_autoplay.html11
-rw-r--r--dom/media/mediacontrol/tests/browser/file_non_eligible_media.html14
-rw-r--r--dom/media/mediacontrol/tests/browser/file_non_looping_media.html9
-rw-r--r--dom/media/mediacontrol/tests/browser/head.js401
-rw-r--r--dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h39
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp163
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp64
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestMediaController.cpp204
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp49
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm136
-rw-r--r--dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm164
-rw-r--r--dom/media/mediacontrol/tests/gtest/moz.build24
69 files changed, 9871 insertions, 0 deletions
diff --git a/dom/media/mediacontrol/AudioFocusManager.cpp b/dom/media/mediacontrol/AudioFocusManager.cpp
new file mode 100644
index 0000000000..24ac7374d4
--- /dev/null
+++ b/dom/media/mediacontrol/AudioFocusManager.cpp
@@ -0,0 +1,134 @@
+/* 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 "AudioFocusManager.h"
+
+#include "MediaController.h"
+#include "MediaControlUtils.h"
+#include "MediaControlService.h"
+#include "mozilla/dom/CanonicalBrowsingContext.h"
+#include "mozilla/Logging.h"
+#include "mozilla/StaticPrefs_media.h"
+#include "mozilla/Telemetry.h"
+#include "nsThreadUtils.h"
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("AudioFocusManager=%p, " msg, this, ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+void AudioFocusManager::RequestAudioFocus(IMediaController* aController) {
+ MOZ_ASSERT(aController);
+ if (mOwningFocusControllers.Contains(aController)) {
+ return;
+ }
+ const bool hasManagedAudioFocus = ClearFocusControllersIfNeeded();
+ LOG("Controller %" PRId64 " grants audio focus", aController->Id());
+ mOwningFocusControllers.AppendElement(aController);
+ if (hasManagedAudioFocus) {
+ AccumulateCategorical(
+ mozilla::Telemetry::LABELS_TABS_AUDIO_COMPETITION::ManagedFocusByGecko);
+ } else if (GetAudioFocusNums() == 1) {
+ // Only one audible tab is playing within gecko.
+ AccumulateCategorical(
+ mozilla::Telemetry::LABELS_TABS_AUDIO_COMPETITION::None);
+ } else {
+ // Multiple audible tabs are playing at the same time within gecko.
+ CreateTimerForUpdatingTelemetry();
+ }
+}
+
+void AudioFocusManager::RevokeAudioFocus(IMediaController* aController) {
+ MOZ_ASSERT(aController);
+ if (!mOwningFocusControllers.Contains(aController)) {
+ return;
+ }
+ LOG("Controller %" PRId64 " loses audio focus", aController->Id());
+ mOwningFocusControllers.RemoveElement(aController);
+}
+
+bool AudioFocusManager::ClearFocusControllersIfNeeded() {
+ // Enable audio focus management will start the audio competition which is
+ // only allowing one controller playing at a time.
+ if (!StaticPrefs::media_audioFocus_management()) {
+ return false;
+ }
+
+ bool hasStoppedAnyController = false;
+ for (auto& controller : mOwningFocusControllers) {
+ LOG("Controller %" PRId64 " loses audio focus in audio competitition",
+ controller->Id());
+ hasStoppedAnyController = true;
+ controller->Stop();
+ }
+ mOwningFocusControllers.Clear();
+ return hasStoppedAnyController;
+}
+
+uint32_t AudioFocusManager::GetAudioFocusNums() const {
+ return mOwningFocusControllers.Length();
+}
+
+void AudioFocusManager::CreateTimerForUpdatingTelemetry() {
+ MOZ_ASSERT(NS_IsMainThread());
+ // Already create the timer.
+ if (mTelemetryTimer) {
+ return;
+ }
+
+ const uint32_t focusNum = GetAudioFocusNums();
+ MOZ_ASSERT(focusNum > 1);
+
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service);
+ const uint32_t activeControllerNum = service->GetActiveControllersNum();
+
+ // It takes time if users want to manually manage the audio competition by
+ // pausing one of playing tabs. So we will check the status after a short
+ // while to see if users handle the audio competition, or simply ignore it.
+ nsCOMPtr<nsIRunnable> task = NS_NewRunnableFunction(
+ "AudioFocusManager::RequestAudioFocus",
+ [focusNum, activeControllerNum]() {
+ if (RefPtr<MediaControlService> service =
+ MediaControlService::GetService()) {
+ service->GetAudioFocusManager().UpdateTelemetryDataFromTimer(
+ focusNum, activeControllerNum);
+ }
+ });
+ mTelemetryTimer =
+ SimpleTimer::Create(task, 4000, GetMainThreadSerialEventTarget());
+}
+
+void AudioFocusManager::UpdateTelemetryDataFromTimer(
+ uint32_t aPrevFocusNum, uint64_t aPrevActiveControllerNum) {
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(mTelemetryTimer);
+ // Users pause or mute tabs which decreases the amount of audible playing
+ // tabs, which should not affect the total controller amount.
+ if (GetAudioFocusNums() < aPrevFocusNum) {
+ // If active controller amount is not equal, that means controllers got
+ // deactivated by other reasons, such as reaching to the end, which are not
+ // the situation we would like to accumulate for telemetry.
+ if (MediaControlService::GetService()->GetActiveControllersNum() ==
+ aPrevActiveControllerNum) {
+ AccumulateCategorical(mozilla::Telemetry::LABELS_TABS_AUDIO_COMPETITION::
+ ManagedFocusByUser);
+ }
+ } else {
+ AccumulateCategorical(
+ mozilla::Telemetry::LABELS_TABS_AUDIO_COMPETITION::Ignored);
+ }
+ mTelemetryTimer = nullptr;
+}
+
+AudioFocusManager::~AudioFocusManager() {
+ if (mTelemetryTimer) {
+ mTelemetryTimer->Cancel();
+ mTelemetryTimer = nullptr;
+ }
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/AudioFocusManager.h b/dom/media/mediacontrol/AudioFocusManager.h
new file mode 100644
index 0000000000..b6047444bb
--- /dev/null
+++ b/dom/media/mediacontrol/AudioFocusManager.h
@@ -0,0 +1,54 @@
+/* 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_MEDIACONTROL_AUDIOFOCUSMANAGER_H_
+#define DOM_MEDIA_MEDIACONTROL_AUDIOFOCUSMANAGER_H_
+
+#include "base/basictypes.h"
+#include "nsTArray.h"
+#include "VideoUtils.h"
+
+namespace mozilla::dom {
+
+class IMediaController;
+class MediaControlService;
+
+/**
+ * AudioFocusManager is used to assign the audio focus to different requester
+ * and decide which requester can own audio focus when audio competing happens.
+ * When the audio competing happens, the last request would be a winner who can
+ * still own the audio focus, and all the other requesters would lose the audio
+ * focus. Now MediaController is the onlt requester, it would request the audio
+ * focus when it becomes audible and revoke the audio focus when the controller
+ * is no longer active.
+ */
+class AudioFocusManager {
+ public:
+ void RequestAudioFocus(IMediaController* aController);
+ void RevokeAudioFocus(IMediaController* aController);
+
+ explicit AudioFocusManager() = default;
+ ~AudioFocusManager();
+
+ uint32_t GetAudioFocusNums() const;
+
+ private:
+ friend class MediaControlService;
+ // Return true if we manage audio focus by clearing other controllers owning
+ // audio focus before assigning audio focus to the new controller.
+ bool ClearFocusControllersIfNeeded();
+
+ void CreateTimerForUpdatingTelemetry();
+ // This would check if user has managed audio focus by themselves and update
+ // the result to telemetry. This method should only be called from timer.
+ void UpdateTelemetryDataFromTimer(uint32_t aPrevFocusNum,
+ uint64_t aPrevActiveControllerNum);
+
+ nsTArray<RefPtr<IMediaController>> mOwningFocusControllers;
+ RefPtr<SimpleTimer> mTelemetryTimer;
+};
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_AUDIOFOCUSMANAGER_H_
diff --git a/dom/media/mediacontrol/ContentMediaController.cpp b/dom/media/mediacontrol/ContentMediaController.cpp
new file mode 100644
index 0000000000..a5d19a57c4
--- /dev/null
+++ b/dom/media/mediacontrol/ContentMediaController.cpp
@@ -0,0 +1,376 @@
+/* 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 "ContentMediaController.h"
+
+#include "MediaControlUtils.h"
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/dom/BrowsingContext.h"
+#include "mozilla/dom/CanonicalBrowsingContext.h"
+#include "mozilla/dom/ContentChild.h"
+#include "mozilla/StaticPtr.h"
+#include "mozilla/Telemetry.h"
+#include "nsGlobalWindowOuter.h"
+
+namespace mozilla::dom {
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("ContentMediaController=%p, " msg, this, ##__VA_ARGS__))
+
+static Maybe<bool> sXPCOMShutdown;
+
+static void InitXPCOMShutdownMonitor() {
+ if (sXPCOMShutdown) {
+ return;
+ }
+ sXPCOMShutdown.emplace(false);
+ RunOnShutdown([&] { sXPCOMShutdown = Some(true); });
+}
+
+static ContentMediaController* GetContentMediaControllerFromBrowsingContext(
+ BrowsingContext* aBrowsingContext) {
+ MOZ_ASSERT(NS_IsMainThread());
+ InitXPCOMShutdownMonitor();
+ if (!aBrowsingContext || aBrowsingContext->IsDiscarded()) {
+ return nullptr;
+ }
+
+ nsPIDOMWindowOuter* outer = aBrowsingContext->GetDOMWindow();
+ if (!outer) {
+ return nullptr;
+ }
+
+ nsGlobalWindowInner* inner =
+ nsGlobalWindowInner::Cast(outer->GetCurrentInnerWindow());
+ return inner ? inner->GetContentMediaController() : nullptr;
+}
+
+static already_AddRefed<BrowsingContext> GetBrowsingContextForAgent(
+ uint64_t aBrowsingContextId) {
+ // If XPCOM has been shutdown, then we're not able to access browsing context.
+ if (sXPCOMShutdown && *sXPCOMShutdown) {
+ return nullptr;
+ }
+ return BrowsingContext::Get(aBrowsingContextId);
+}
+
+/* static */
+ContentMediaControlKeyReceiver* ContentMediaControlKeyReceiver::Get(
+ BrowsingContext* aBC) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return GetContentMediaControllerFromBrowsingContext(aBC);
+}
+
+/* static */
+ContentMediaAgent* ContentMediaAgent::Get(BrowsingContext* aBC) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return GetContentMediaControllerFromBrowsingContext(aBC);
+}
+
+void ContentMediaAgent::NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) {
+ MOZ_ASSERT(NS_IsMainThread());
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media %s in BC %" PRId64, ToMediaPlaybackStateStr(aState),
+ bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaPlaybackChanged(bc, aState);
+ } else {
+ // Currently this only happen when we disable e10s, otherwise all controlled
+ // media would be run in the content process.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->NotifyMediaPlaybackChanged(bc->Id(), aState);
+ }
+ }
+}
+
+void ContentMediaAgent::NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) {
+ MOZ_ASSERT(NS_IsMainThread());
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media became %s in BC %" PRId64,
+ aState == MediaAudibleState::eAudible ? "audible" : "inaudible",
+ bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaAudibleChanged(bc, aState);
+ } else {
+ // Currently this only happen when we disable e10s, otherwise all controlled
+ // media would be run in the content process.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->NotifyMediaAudibleChanged(bc->Id(), aState);
+ }
+ }
+}
+
+void ContentMediaAgent::SetIsInPictureInPictureMode(
+ uint64_t aBrowsingContextId, bool aIsInPictureInPictureMode) {
+ MOZ_ASSERT(NS_IsMainThread());
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media Picture-in-Picture mode '%s' in BC %" PRId64,
+ aIsInPictureInPictureMode ? "enabled" : "disabled", bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyPictureInPictureModeChanged(
+ bc, aIsInPictureInPictureMode);
+ } else {
+ // Currently this only happen when we disable e10s, otherwise all controlled
+ // media would be run in the content process.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->SetIsInPictureInPictureMode(bc->Id(), aIsInPictureInPictureMode);
+ }
+ }
+}
+
+void ContentMediaAgent::SetDeclaredPlaybackState(
+ uint64_t aBrowsingContextId, MediaSessionPlaybackState aState) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify declared playback state '%s' in BC %" PRId64,
+ ToMediaSessionPlaybackStateStr(aState), bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaSessionPlaybackStateChanged(bc,
+ aState);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->SetDeclaredPlaybackState(bc->Id(), aState);
+ }
+}
+
+void ContentMediaAgent::NotifySessionCreated(uint64_t aBrowsingContextId) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media session being created in BC %" PRId64, bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaSessionUpdated(bc, true);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->NotifySessionCreated(bc->Id());
+ }
+}
+
+void ContentMediaAgent::NotifySessionDestroyed(uint64_t aBrowsingContextId) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media session being destroyed in BC %" PRId64, bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaSessionUpdated(bc, false);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->NotifySessionDestroyed(bc->Id());
+ }
+}
+
+void ContentMediaAgent::UpdateMetadata(
+ uint64_t aBrowsingContextId, const Maybe<MediaMetadataBase>& aMetadata) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify media session metadata change in BC %" PRId64, bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyUpdateMediaMetadata(bc, aMetadata);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->UpdateMetadata(bc->Id(), aMetadata);
+ }
+}
+
+void ContentMediaAgent::EnableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify to enable action '%s' in BC %" PRId64,
+ ToMediaSessionActionStr(aAction), bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaSessionSupportedActionChanged(
+ bc, aAction, true);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->EnableAction(bc->Id(), aAction);
+ }
+}
+
+void ContentMediaAgent::DisableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify to disable action '%s' in BC %" PRId64,
+ ToMediaSessionActionStr(aAction), bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaSessionSupportedActionChanged(
+ bc, aAction, false);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->DisableAction(bc->Id(), aAction);
+ }
+}
+
+void ContentMediaAgent::NotifyMediaFullScreenState(uint64_t aBrowsingContextId,
+ bool aIsInFullScreen) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+
+ LOG("Notify %s fullscreen in BC %" PRId64,
+ aIsInFullScreen ? "entered" : "left", bc->Id());
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyMediaFullScreenState(bc, aIsInFullScreen);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->NotifyMediaFullScreenState(bc->Id(), aIsInFullScreen);
+ }
+}
+
+void ContentMediaAgent::UpdatePositionState(uint64_t aBrowsingContextId,
+ const PositionState& aState) {
+ RefPtr<BrowsingContext> bc = GetBrowsingContextForAgent(aBrowsingContextId);
+ if (!bc || bc->IsDiscarded()) {
+ return;
+ }
+ if (XRE_IsContentProcess()) {
+ ContentChild* contentChild = ContentChild::GetSingleton();
+ Unused << contentChild->SendNotifyPositionStateChanged(bc, aState);
+ return;
+ }
+ // This would only happen when we disable e10s.
+ if (RefPtr<IMediaInfoUpdater> updater =
+ bc->Canonical()->GetMediaController()) {
+ updater->UpdatePositionState(bc->Id(), aState);
+ }
+}
+
+ContentMediaController::ContentMediaController(uint64_t aId) {
+ LOG("Create content media controller for BC %" PRId64, aId);
+}
+
+void ContentMediaController::AddReceiver(
+ ContentMediaControlKeyReceiver* aListener) {
+ MOZ_ASSERT(NS_IsMainThread());
+ mReceivers.AppendElement(aListener);
+}
+
+void ContentMediaController::RemoveReceiver(
+ ContentMediaControlKeyReceiver* aListener) {
+ MOZ_ASSERT(NS_IsMainThread());
+ mReceivers.RemoveElement(aListener);
+}
+
+void ContentMediaController::HandleMediaKey(MediaControlKey aKey) {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (mReceivers.IsEmpty()) {
+ return;
+ }
+ LOG("Handle '%s' event, receiver num=%zu", ToMediaControlKeyStr(aKey),
+ mReceivers.Length());
+ // We have default handlers for play, pause and stop.
+ // https://w3c.github.io/mediasession/#ref-for-dom-mediasessionaction-play%E2%91%A3
+ switch (aKey) {
+ case MediaControlKey::Pause:
+ PauseOrStopMedia();
+ return;
+ case MediaControlKey::Play:
+ [[fallthrough]];
+ case MediaControlKey::Stop:
+ // When receiving `Stop`, the amount of receiver would vary during the
+ // iteration, so we use the backward iteration to avoid accessing the
+ // index which is over the array length.
+ for (auto& receiver : Reversed(mReceivers)) {
+ receiver->HandleMediaKey(aKey);
+ }
+ return;
+ default:
+ MOZ_ASSERT_UNREACHABLE("Not supported media key for default handler");
+ }
+}
+
+void ContentMediaController::PauseOrStopMedia() {
+ // When receiving `pause`, if a page contains playing media and paused media
+ // at that moment, that means a user intends to pause those playing
+ // media, not the already paused ones. Then, we're going to stop those already
+ // paused media and keep those latest paused media in `mReceivers`.
+ // The reason for doing that is, when resuming paused media, we only want to
+ // resume latest paused media, not all media, in order to get a better user
+ // experience, which matches Chrome's behavior.
+ bool isAnyMediaPlaying = false;
+ for (const auto& receiver : mReceivers) {
+ if (receiver->IsPlaying()) {
+ isAnyMediaPlaying = true;
+ break;
+ }
+ }
+
+ for (auto& receiver : Reversed(mReceivers)) {
+ if (isAnyMediaPlaying && !receiver->IsPlaying()) {
+ receiver->HandleMediaKey(MediaControlKey::Stop);
+ } else {
+ receiver->HandleMediaKey(MediaControlKey::Pause);
+ }
+ }
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/ContentMediaController.h b/dom/media/mediacontrol/ContentMediaController.h
new file mode 100644
index 0000000000..9e162dbb27
--- /dev/null
+++ b/dom/media/mediacontrol/ContentMediaController.h
@@ -0,0 +1,109 @@
+/* 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_MEDIACONTROL_CONTENTMEDIACONTROLLER_H_
+#define DOM_MEDIA_MEDIACONTROL_CONTENTMEDIACONTROLLER_H_
+
+#include "MediaControlKeySource.h"
+#include "MediaStatusManager.h"
+
+namespace mozilla::dom {
+
+class BrowsingContext;
+
+/**
+ * ContentMediaControlKeyReceiver is an interface which is used to receive media
+ * control key sent from the chrome process.
+ */
+class ContentMediaControlKeyReceiver {
+ public:
+ NS_INLINE_DECL_PURE_VIRTUAL_REFCOUNTING
+
+ // Return nullptr if the top level browsing context is no longer alive.
+ static ContentMediaControlKeyReceiver* Get(BrowsingContext* aBC);
+
+ // Use this method to handle the event from `ContentMediaAgent`.
+ virtual void HandleMediaKey(MediaControlKey aKey) = 0;
+
+ virtual bool IsPlaying() const = 0;
+};
+
+/**
+ * ContentMediaAgent is an interface which we use to (1) propoagate media
+ * related information from the content process to the chrome process (2) act an
+ * event source to dispatch media control key to its listeners.
+ *
+ * If the media would like to know the media control key, then media MUST
+ * inherit from ContentMediaControlKeyReceiver, and register themselves to
+ * ContentMediaAgent. Whenever media control key delivers, ContentMediaAgent
+ * would notify all its receivers. In addition, whenever controlled media
+ * changes its playback status or audible state, they should update their status
+ * update via ContentMediaAgent.
+ */
+class ContentMediaAgent : public IMediaInfoUpdater {
+ public:
+ // Return nullptr if the top level browsing context is no longer alive.
+ static ContentMediaAgent* Get(BrowsingContext* aBC);
+
+ // IMediaInfoUpdater Methods
+ void NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) override;
+ void NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) override;
+ void SetIsInPictureInPictureMode(uint64_t aBrowsingContextId,
+ bool aIsInPictureInPictureMode) override;
+ void SetDeclaredPlaybackState(uint64_t aBrowsingContextId,
+ MediaSessionPlaybackState aState) override;
+ void NotifySessionCreated(uint64_t aBrowsingContextId) override;
+ void NotifySessionDestroyed(uint64_t aBrowsingContextId) override;
+ void UpdateMetadata(uint64_t aBrowsingContextId,
+ const Maybe<MediaMetadataBase>& aMetadata) override;
+ void EnableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) override;
+ void DisableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) override;
+ void NotifyMediaFullScreenState(uint64_t aBrowsingContextId,
+ bool aIsInFullScreen) override;
+ void UpdatePositionState(uint64_t aBrowsingContextId,
+ const PositionState& aState) override;
+
+ // Use these methods to register/unregister `ContentMediaControlKeyReceiver`
+ // in order to listen to media control key events.
+ virtual void AddReceiver(ContentMediaControlKeyReceiver* aReceiver) = 0;
+ virtual void RemoveReceiver(ContentMediaControlKeyReceiver* aReceiver) = 0;
+};
+
+/**
+ * ContentMediaController exists in per inner window, which has a responsibility
+ * to update the content media state to MediaController (ContentMediaAgent) and
+ * delivers MediaControlKey to its receiver in order to control media in the
+ * content page (ContentMediaControlKeyReceiver).
+ */
+class ContentMediaController final : public ContentMediaAgent,
+ public ContentMediaControlKeyReceiver {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(ContentMediaController, override)
+
+ explicit ContentMediaController(uint64_t aId);
+ // ContentMediaAgent methods
+ void AddReceiver(ContentMediaControlKeyReceiver* aListener) override;
+ void RemoveReceiver(ContentMediaControlKeyReceiver* aListener) override;
+
+ // ContentMediaControlKeyReceiver method
+ void HandleMediaKey(MediaControlKey aKey) override;
+
+ private:
+ ~ContentMediaController() = default;
+
+ // We don't need this method, so make it as private and simply return false.
+ virtual bool IsPlaying() const override { return false; }
+
+ void PauseOrStopMedia();
+
+ nsTArray<RefPtr<ContentMediaControlKeyReceiver>> mReceivers;
+};
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_CONTENTMEDIACONTROLLER_H_
diff --git a/dom/media/mediacontrol/ContentPlaybackController.cpp b/dom/media/mediacontrol/ContentPlaybackController.cpp
new file mode 100644
index 0000000000..ba48c0a5ce
--- /dev/null
+++ b/dom/media/mediacontrol/ContentPlaybackController.cpp
@@ -0,0 +1,210 @@
+/* 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 "ContentPlaybackController.h"
+
+#include "MediaControlUtils.h"
+#include "mozilla/dom/ContentMediaController.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/dom/Navigator.h"
+#include "mozilla/dom/WindowContext.h"
+#include "mozilla/Telemetry.h"
+#include "nsFocusManager.h"
+
+// avoid redefined macro in unified build
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("ContentPlaybackController=%p, " msg, this, ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+ContentPlaybackController::ContentPlaybackController(
+ BrowsingContext* aContext) {
+ MOZ_ASSERT(aContext);
+ mBC = aContext;
+}
+
+MediaSession* ContentPlaybackController::GetMediaSession() const {
+ RefPtr<nsPIDOMWindowOuter> window = mBC->GetDOMWindow();
+ if (!window) {
+ return nullptr;
+ }
+
+ RefPtr<Navigator> navigator = window->GetNavigator();
+ if (!navigator) {
+ return nullptr;
+ }
+
+ return navigator->HasCreatedMediaSession() ? navigator->MediaSession()
+ : nullptr;
+}
+
+void ContentPlaybackController::NotifyContentMediaControlKeyReceiver(
+ MediaControlKey aKey) {
+ if (RefPtr<ContentMediaControlKeyReceiver> receiver =
+ ContentMediaControlKeyReceiver::Get(mBC)) {
+ LOG("Handle '%s' in default behavior for BC %" PRIu64,
+ ToMediaControlKeyStr(aKey), mBC->Id());
+ receiver->HandleMediaKey(aKey);
+ }
+}
+
+void ContentPlaybackController::NotifyMediaSession(MediaSessionAction aAction) {
+ MediaSessionActionDetails details;
+ details.mAction = aAction;
+ NotifyMediaSession(details);
+}
+
+void ContentPlaybackController::NotifyMediaSession(
+ const MediaSessionActionDetails& aDetails) {
+ if (RefPtr<MediaSession> session = GetMediaSession()) {
+ LOG("Handle '%s' in media session behavior for BC %" PRIu64,
+ ToMediaSessionActionStr(aDetails.mAction), mBC->Id());
+ MOZ_ASSERT(session->IsActive(), "Notify inactive media session!");
+ session->NotifyHandler(aDetails);
+ }
+}
+
+void ContentPlaybackController::NotifyMediaSessionWhenActionIsSupported(
+ MediaSessionAction aAction) {
+ if (IsMediaSessionActionSupported(aAction)) {
+ NotifyMediaSession(aAction);
+ }
+}
+
+bool ContentPlaybackController::IsMediaSessionActionSupported(
+ MediaSessionAction aAction) const {
+ RefPtr<MediaSession> session = GetMediaSession();
+ return session ? session->IsActive() && session->IsSupportedAction(aAction)
+ : false;
+}
+
+Maybe<uint64_t> ContentPlaybackController::GetActiveMediaSessionId() const {
+ RefPtr<WindowContext> wc = mBC->GetTopWindowContext();
+ return wc ? wc->GetActiveMediaSessionContextId() : Nothing();
+}
+
+void ContentPlaybackController::Focus() {
+ // Focus is not part of the MediaSession standard, so always use the
+ // default behavior and focus the window currently playing media.
+ if (nsCOMPtr<nsPIDOMWindowOuter> win = mBC->GetDOMWindow()) {
+ nsFocusManager::FocusWindow(win, CallerType::System);
+ }
+}
+
+void ContentPlaybackController::Play() {
+ const MediaSessionAction action = MediaSessionAction::Play;
+ RefPtr<MediaSession> session = GetMediaSession();
+ if (IsMediaSessionActionSupported(action)) {
+ NotifyMediaSession(action);
+ }
+ // We don't want to arbitrarily call play default handler, because we want to
+ // resume the frame which a user really gets interest in, not all media in the
+ // same page. Therefore, we would only call default handler for `play` when
+ // (1) We don't have an active media session (If we have one, the play action
+ // handler should only be triggered on that session)
+ // (2) Active media session without setting action handler for `play`
+ else if (!GetActiveMediaSessionId() || (session && session->IsActive())) {
+ NotifyContentMediaControlKeyReceiver(MediaControlKey::Play);
+ }
+}
+
+void ContentPlaybackController::Pause() {
+ const MediaSessionAction action = MediaSessionAction::Pause;
+ if (IsMediaSessionActionSupported(action)) {
+ NotifyMediaSession(action);
+ } else {
+ NotifyContentMediaControlKeyReceiver(MediaControlKey::Pause);
+ }
+}
+
+void ContentPlaybackController::SeekBackward() {
+ NotifyMediaSessionWhenActionIsSupported(MediaSessionAction::Seekbackward);
+}
+
+void ContentPlaybackController::SeekForward() {
+ NotifyMediaSessionWhenActionIsSupported(MediaSessionAction::Seekforward);
+}
+
+void ContentPlaybackController::PreviousTrack() {
+ NotifyMediaSessionWhenActionIsSupported(MediaSessionAction::Previoustrack);
+}
+
+void ContentPlaybackController::NextTrack() {
+ NotifyMediaSessionWhenActionIsSupported(MediaSessionAction::Nexttrack);
+}
+
+void ContentPlaybackController::SkipAd() {
+ NotifyMediaSessionWhenActionIsSupported(MediaSessionAction::Skipad);
+}
+
+void ContentPlaybackController::Stop() {
+ const MediaSessionAction action = MediaSessionAction::Stop;
+ if (IsMediaSessionActionSupported(action)) {
+ NotifyMediaSession(action);
+ } else {
+ NotifyContentMediaControlKeyReceiver(MediaControlKey::Stop);
+ }
+}
+
+void ContentPlaybackController::SeekTo(double aSeekTime, bool aFastSeek) {
+ MediaSessionActionDetails details;
+ details.mAction = MediaSessionAction::Seekto;
+ details.mSeekTime.Construct(aSeekTime);
+ if (aFastSeek) {
+ details.mFastSeek.Construct(aFastSeek);
+ }
+ if (IsMediaSessionActionSupported(details.mAction)) {
+ NotifyMediaSession(details);
+ }
+}
+
+void ContentMediaControlKeyHandler::HandleMediaControlAction(
+ BrowsingContext* aContext, const MediaControlAction& aAction) {
+ MOZ_ASSERT(aContext);
+ // The web content doesn't exist in this browsing context.
+ if (!aContext->GetDocShell()) {
+ return;
+ }
+ ContentPlaybackController controller(aContext);
+ switch (aAction.mKey) {
+ case MediaControlKey::Focus:
+ controller.Focus();
+ return;
+ case MediaControlKey::Play:
+ controller.Play();
+ return;
+ case MediaControlKey::Pause:
+ controller.Pause();
+ return;
+ case MediaControlKey::Stop:
+ controller.Stop();
+ return;
+ case MediaControlKey::Previoustrack:
+ controller.PreviousTrack();
+ return;
+ case MediaControlKey::Nexttrack:
+ controller.NextTrack();
+ return;
+ case MediaControlKey::Seekbackward:
+ controller.SeekBackward();
+ return;
+ case MediaControlKey::Seekforward:
+ controller.SeekForward();
+ return;
+ case MediaControlKey::Skipad:
+ controller.SkipAd();
+ return;
+ case MediaControlKey::Seekto: {
+ const SeekDetails& details = *aAction.mDetails;
+ controller.SeekTo(details.mSeekTime, details.mFastSeek);
+ return;
+ }
+ default:
+ MOZ_ASSERT_UNREACHABLE("Invalid media control key.");
+ };
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/ContentPlaybackController.h b/dom/media/mediacontrol/ContentPlaybackController.h
new file mode 100644
index 0000000000..a692dedde0
--- /dev/null
+++ b/dom/media/mediacontrol/ContentPlaybackController.h
@@ -0,0 +1,73 @@
+/* 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_MEDIACONTROL_CONTENTPLAYBACKCONTROLLER_H_
+#define DOM_MEDIA_MEDIACONTROL_CONTENTPLAYBACKCONTROLLER_H_
+
+#include "MediaControlKeySource.h"
+#include "nsPIDOMWindow.h"
+#include "mozilla/dom/BrowsingContext.h"
+
+namespace mozilla::dom {
+
+class MediaSession;
+
+/**
+ * This interface is used to handle different playback control actions in the
+ * content process. Most of the methods are designed based on the
+ * MediaSessionAction values, which are defined in the media session spec [1].
+ *
+ * The reason we need that is to explicitly separate the implementation from all
+ * different defined methods. If we want to add a new method in the future, we
+ * can do that without modifying other methods.
+ *
+ * If the active media session has a corresponding MediaSessionActionHandler,
+ * then we would invoke it, or we would do nothing. However, for certain
+ * actions, such as `play`, `pause` and `stop`, we have default action handling
+ * in order to control playback correctly even if the website doesn't use media
+ * session at all or the media session doesn't have correspending action handler
+ * [2].
+ *
+ * [1] https://w3c.github.io/mediasession/#enumdef-mediasessionaction
+ * [2]
+ * https://w3c.github.io/mediasession/#ref-for-active-media-session%E2%91%A1%E2%93%AA
+ */
+class MOZ_STACK_CLASS ContentPlaybackController {
+ public:
+ explicit ContentPlaybackController(BrowsingContext* aContext);
+ ~ContentPlaybackController() = default;
+
+ // TODO: Convert Focus() to MOZ_CAN_RUN_SCRIPT
+ MOZ_CAN_RUN_SCRIPT_BOUNDARY void Focus();
+ void Play();
+ void Pause();
+ void SeekBackward();
+ void SeekForward();
+ void PreviousTrack();
+ void NextTrack();
+ void SkipAd();
+ void Stop();
+ void SeekTo(double aSeekTime, bool aFastSeek);
+
+ private:
+ void NotifyContentMediaControlKeyReceiver(MediaControlKey aKey);
+ void NotifyMediaSession(MediaSessionAction aAction);
+ void NotifyMediaSession(const MediaSessionActionDetails& aDetails);
+ void NotifyMediaSessionWhenActionIsSupported(MediaSessionAction aAction);
+ bool IsMediaSessionActionSupported(MediaSessionAction aAction) const;
+ Maybe<uint64_t> GetActiveMediaSessionId() const;
+ MediaSession* GetMediaSession() const;
+
+ RefPtr<BrowsingContext> mBC;
+};
+
+class ContentMediaControlKeyHandler {
+ public:
+ static void HandleMediaControlAction(BrowsingContext* aContext,
+ const MediaControlAction& aAction);
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/media/mediacontrol/FetchImageHelper.cpp b/dom/media/mediacontrol/FetchImageHelper.cpp
new file mode 100644
index 0000000000..8ba8d54485
--- /dev/null
+++ b/dom/media/mediacontrol/FetchImageHelper.cpp
@@ -0,0 +1,164 @@
+/* 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 "FetchImageHelper.h"
+
+#include "mozilla/gfx/2D.h"
+#include "mozilla/Logging.h"
+#include "mozilla/NullPrincipal.h"
+#include "nsIChannel.h"
+#include "nsNetUtil.h"
+
+mozilla::LazyLogModule gFetchImageLog("FetchImageHelper");
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gFetchImageLog, LogLevel::Debug, \
+ ("FetchImageHelper=%p, " msg, this, ##__VA_ARGS__))
+
+using namespace mozilla::gfx;
+
+namespace mozilla::dom {
+
+FetchImageHelper::FetchImageHelper(const MediaImage& aImage)
+ : mSrc(aImage.mSrc) {}
+
+FetchImageHelper::~FetchImageHelper() { AbortFetchingImage(); }
+
+RefPtr<ImagePromise> FetchImageHelper::FetchImage() {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (IsFetchingImage()) {
+ return mPromise.Ensure(__func__);
+ }
+
+ LOG("Start fetching image from %s", NS_ConvertUTF16toUTF8(mSrc).get());
+ nsCOMPtr<nsIURI> uri;
+ if (NS_FAILED(NS_NewURI(getter_AddRefs(uri), mSrc))) {
+ LOG("Failed to create URI");
+ return ImagePromise::CreateAndReject(false, __func__);
+ }
+
+ MOZ_ASSERT(!mListener);
+ mListener = new ImageFetchListener();
+ if (NS_FAILED(mListener->FetchDecodedImageFromURI(uri, this))) {
+ LOG("Failed to decode image from async channel");
+ return ImagePromise::CreateAndReject(false, __func__);
+ }
+ return mPromise.Ensure(__func__);
+}
+
+void FetchImageHelper::AbortFetchingImage() {
+ MOZ_ASSERT(NS_IsMainThread());
+ LOG("AbortFetchingImage");
+ mPromise.RejectIfExists(false, __func__);
+ ClearListenerIfNeeded();
+}
+
+void FetchImageHelper::ClearListenerIfNeeded() {
+ if (mListener) {
+ mListener->Clear();
+ mListener = nullptr;
+ }
+}
+
+bool FetchImageHelper::IsFetchingImage() const {
+ MOZ_ASSERT(NS_IsMainThread());
+ return !mPromise.IsEmpty() && mListener;
+}
+
+void FetchImageHelper::HandleFetchSuccess(imgIContainer* aImage) {
+ MOZ_ASSERT(aImage);
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(IsFetchingImage());
+ LOG("Finished fetching image");
+ mPromise.Resolve(aImage, __func__);
+ ClearListenerIfNeeded();
+}
+
+void FetchImageHelper::HandleFetchFail() {
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(IsFetchingImage());
+ LOG("Reject the promise because of fetching failed");
+ mPromise.RejectIfExists(false, __func__);
+ ClearListenerIfNeeded();
+}
+
+/**
+ * Implementation for FetchImageHelper::ImageFetchListener
+ */
+NS_IMPL_ISUPPORTS(FetchImageHelper::ImageFetchListener, imgIContainerCallback)
+
+FetchImageHelper::ImageFetchListener::~ImageFetchListener() {
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(!mHelper, "Cancel() should be called before desturction!");
+}
+
+nsresult FetchImageHelper::ImageFetchListener::FetchDecodedImageFromURI(
+ nsIURI* aURI, FetchImageHelper* aHelper) {
+ MOZ_ASSERT(!mHelper && !mChannel,
+ "Should call Clear() berfore running another fetching process!");
+ RefPtr<nsIPrincipal> nullPrincipal =
+ NullPrincipal::CreateWithoutOriginAttributes();
+ nsCOMPtr<nsIChannel> channel;
+ nsresult rv =
+ NS_NewChannel(getter_AddRefs(channel), aURI, nullPrincipal,
+ nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ nsIContentPolicy::TYPE_INTERNAL_IMAGE, nullptr, nullptr,
+ nullptr, nullptr, nsIRequest::LOAD_ANONYMOUS);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ nsCOMPtr<imgITools> imgTools = do_GetService("@mozilla.org/image/tools;1");
+ if (!imgTools) {
+ return NS_ERROR_FAILURE;
+ }
+
+ rv = imgTools->DecodeImageFromChannelAsync(aURI, channel, this, nullptr);
+ if (NS_FAILED(rv)) {
+ return NS_ERROR_FAILURE;
+ }
+ MOZ_ASSERT(aHelper);
+ mHelper = aHelper;
+ mChannel = channel;
+ return NS_OK;
+}
+
+void FetchImageHelper::ImageFetchListener::Clear() {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (mChannel) {
+ mChannel->CancelWithReason(
+ NS_BINDING_ABORTED, "FetchImageHelper::ImageFetchListener::Clear"_ns);
+ mChannel = nullptr;
+ }
+ mHelper = nullptr;
+}
+
+bool FetchImageHelper::ImageFetchListener::IsFetchingImage() const {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mHelper ? mHelper->IsFetchingImage() : false;
+}
+
+NS_IMETHODIMP FetchImageHelper::ImageFetchListener::OnImageReady(
+ imgIContainer* aImage, nsresult aStatus) {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (!IsFetchingImage()) {
+ return NS_OK;
+ }
+ // We have received image, so we don't need the channel anymore.
+ mChannel = nullptr;
+
+ MOZ_ASSERT(mHelper);
+ if (NS_FAILED(aStatus) || !aImage) {
+ mHelper->HandleFetchFail();
+ Clear();
+ return aStatus;
+ }
+
+ mHelper->HandleFetchSuccess(aImage);
+
+ return NS_OK;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/FetchImageHelper.h b/dom/media/mediacontrol/FetchImageHelper.h
new file mode 100644
index 0000000000..eb3e719610
--- /dev/null
+++ b/dom/media/mediacontrol/FetchImageHelper.h
@@ -0,0 +1,82 @@
+/* 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_MEDIACONTROL_FETCHIMAGEHELPER_H_
+#define DOM_MEDIA_MEDIACONTROL_FETCHIMAGEHELPER_H_
+
+#include "imgIContainer.h"
+#include "imgITools.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "mozilla/MozPromise.h"
+
+namespace mozilla::dom {
+/**
+ * FetchImageHelper is used to fetch image data from MediaImage, and the fetched
+ * image data would be used to show on the virtual control inferface. The URL of
+ * MediaImage is defined by websites by using MediaSession API [1].
+ *
+ * By using `FetchImage()`, it would return a promise that would resolve with a
+ * `imgIContainer`, then we can get the image data from the container.
+ *
+ * [1] https://w3c.github.io/mediasession/#dictdef-mediaimage
+ */
+using ImagePromise = MozPromise<nsCOMPtr<imgIContainer>, bool,
+ /* IsExclusive = */ true>;
+class FetchImageHelper final {
+ public:
+ explicit FetchImageHelper(const MediaImage& aImage);
+ ~FetchImageHelper();
+
+ // Return a promise which would be resolved with the decoded image surface
+ // when we finish fetching and decoding image data, and it would be rejected
+ // when we fail to fecth the image.
+ RefPtr<ImagePromise> FetchImage();
+
+ // Stop fetching and decoding image and reject the image promise. If we have
+ // not started yet fetching image, then nothing would happen.
+ void AbortFetchingImage();
+
+ // Return true if we're fecthing image.
+ bool IsFetchingImage() const;
+
+ private:
+ /**
+ * ImageFetchListener is used to listen the notification of finishing fetching
+ * image data (via `OnImageReady()`) and finishing decoding image data (via
+ * `Notify()`).
+ */
+ class ImageFetchListener final : public imgIContainerCallback {
+ public:
+ NS_DECL_ISUPPORTS
+ ImageFetchListener() = default;
+
+ // Start an async channel to load the image, and return error if the channel
+ // opens failed. It would use `aHelper::HandleFetchSuccess/Fail()` to notify
+ // the result asynchronously.
+ nsresult FetchDecodedImageFromURI(nsIURI* aURI, FetchImageHelper* aHelper);
+ void Clear();
+ bool IsFetchingImage() const;
+
+ // Method of imgIContainerCallback
+ NS_IMETHOD OnImageReady(imgIContainer* aImage, nsresult aStatus) override;
+
+ private:
+ ~ImageFetchListener();
+
+ FetchImageHelper* MOZ_NON_OWNING_REF mHelper = nullptr;
+ nsCOMPtr<nsIChannel> mChannel;
+ };
+
+ void ClearListenerIfNeeded();
+ void HandleFetchSuccess(imgIContainer* aImage);
+ void HandleFetchFail();
+
+ nsString mSrc;
+ MozPromiseHolder<ImagePromise> mPromise;
+ RefPtr<ImageFetchListener> mListener;
+};
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_FETCHIMAGEHELPER_H_
diff --git a/dom/media/mediacontrol/MediaControlIPC.h b/dom/media/mediacontrol/MediaControlIPC.h
new file mode 100644
index 0000000000..100e5832c2
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlIPC.h
@@ -0,0 +1,75 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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 ipc_MediaControlIPC_h
+#define ipc_MediaControlIPC_h
+
+#include "ipc/EnumSerializer.h"
+
+#include "mozilla/dom/MediaControllerBinding.h"
+#include "mozilla/dom/MediaControlKeySource.h"
+#include "mozilla/dom/MediaPlaybackStatus.h"
+
+namespace IPC {
+template <>
+struct ParamTraits<mozilla::dom::MediaControlKey>
+ : public ContiguousEnumSerializerInclusive<
+ mozilla::dom::MediaControlKey, mozilla::dom::MediaControlKey::Focus,
+ mozilla::dom::MediaControlKey::Stop> {};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaPlaybackState>
+ : public ContiguousEnumSerializerInclusive<
+ mozilla::dom::MediaPlaybackState,
+ mozilla::dom::MediaPlaybackState::eStarted,
+ mozilla::dom::MediaPlaybackState::eStopped> {};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaAudibleState>
+ : public ContiguousEnumSerializerInclusive<
+ mozilla::dom::MediaAudibleState,
+ mozilla::dom::MediaAudibleState::eInaudible,
+ mozilla::dom::MediaAudibleState::eAudible> {};
+
+template <>
+struct ParamTraits<mozilla::dom::SeekDetails> {
+ typedef mozilla::dom::SeekDetails paramType;
+
+ static void Write(MessageWriter* aWriter, const paramType& aParam) {
+ WriteParam(aWriter, aParam.mSeekTime);
+ WriteParam(aWriter, aParam.mFastSeek);
+ }
+
+ static bool Read(MessageReader* aReader, paramType* aResult) {
+ if (!ReadParam(aReader, &aResult->mSeekTime) ||
+ !ReadParam(aReader, &aResult->mFastSeek)) {
+ return false;
+ }
+ return true;
+ }
+};
+
+template <>
+struct ParamTraits<mozilla::dom::MediaControlAction> {
+ typedef mozilla::dom::MediaControlAction paramType;
+
+ static void Write(MessageWriter* aWriter, const paramType& aParam) {
+ WriteParam(aWriter, aParam.mKey);
+ WriteParam(aWriter, aParam.mDetails);
+ }
+
+ static bool Read(MessageReader* aReader, paramType* aResult) {
+ if (!ReadParam(aReader, &aResult->mKey) ||
+ !ReadParam(aReader, &aResult->mDetails)) {
+ return false;
+ }
+ return true;
+ }
+};
+
+} // namespace IPC
+
+#endif // mozilla_MediaControlIPC_hh
diff --git a/dom/media/mediacontrol/MediaControlKeyManager.cpp b/dom/media/mediacontrol/MediaControlKeyManager.cpp
new file mode 100644
index 0000000000..4cb562aa84
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlKeyManager.cpp
@@ -0,0 +1,228 @@
+/* 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 "MediaControlKeyManager.h"
+
+#include "MediaControlUtils.h"
+#include "MediaControlService.h"
+#include "mozilla/AbstractThread.h"
+#include "mozilla/Assertions.h"
+#include "mozilla/Logging.h"
+#include "mozilla/Preferences.h"
+#include "mozilla/Services.h"
+#include "mozilla/StaticPrefs_media.h"
+#include "mozilla/widget/MediaKeysEventSourceFactory.h"
+#include "nsContentUtils.h"
+#include "nsIObserverService.h"
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaControlKeyManager=%p, " msg, this, ##__VA_ARGS__))
+
+#undef LOG_INFO
+#define LOG_INFO(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Info, \
+ ("MediaControlKeyManager=%p, " msg, this, ##__VA_ARGS__))
+
+#define MEDIA_CONTROL_PREF "media.hardwaremediakeys.enabled"
+
+namespace mozilla::dom {
+
+bool MediaControlKeyManager::IsOpened() const {
+ return mEventSource && mEventSource->IsOpened();
+}
+
+bool MediaControlKeyManager::Open() {
+ if (IsOpened()) {
+ return true;
+ }
+ const bool isEnabledMediaControl = StartMonitoringControlKeys();
+ if (isEnabledMediaControl) {
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service);
+ service->NotifyMediaControlHasEverBeenEnabled();
+ }
+ return isEnabledMediaControl;
+}
+
+void MediaControlKeyManager::Close() {
+ // We don't call parent's `Close()` because we want to keep the listener
+ // (MediaControlKeyHandler) all the time. It would be manually removed by
+ // `MediaControlService` when shutdown.
+ StopMonitoringControlKeys();
+}
+
+MediaControlKeyManager::MediaControlKeyManager()
+ : mObserver(new Observer(this)) {
+ nsContentUtils::RegisterShutdownObserver(mObserver);
+ Preferences::AddStrongObserver(mObserver, MEDIA_CONTROL_PREF);
+}
+
+MediaControlKeyManager::~MediaControlKeyManager() { Shutdown(); }
+
+void MediaControlKeyManager::Shutdown() {
+ StopMonitoringControlKeys();
+ mEventSource = nullptr;
+ if (mObserver) {
+ nsContentUtils::UnregisterShutdownObserver(mObserver);
+ Preferences::RemoveObserver(mObserver, MEDIA_CONTROL_PREF);
+ mObserver = nullptr;
+ }
+}
+
+bool MediaControlKeyManager::StartMonitoringControlKeys() {
+ if (!StaticPrefs::media_hardwaremediakeys_enabled()) {
+ return false;
+ }
+
+ if (!mEventSource) {
+ mEventSource = widget::CreateMediaControlKeySource();
+ }
+ if (mEventSource && mEventSource->Open()) {
+ LOG_INFO("StartMonitoringControlKeys");
+ mEventSource->SetPlaybackState(mPlaybackState);
+ mEventSource->SetMediaMetadata(mMetadata);
+ mEventSource->SetSupportedMediaKeys(mSupportedKeys);
+ mEventSource->AddListener(this);
+ return true;
+ }
+ // Fail to open or create event source (eg. when cross-compiling with MinGW,
+ // we cannot use the related WinAPI)
+ return false;
+}
+
+void MediaControlKeyManager::StopMonitoringControlKeys() {
+ if (!mEventSource || !mEventSource->IsOpened()) {
+ return;
+ }
+
+ LOG_INFO("StopMonitoringControlKeys");
+ mEventSource->Close();
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ // Close the source would reset the displayed playback state and metadata.
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-displayed-playback-changed",
+ nullptr);
+ obs->NotifyObservers(nullptr, "media-displayed-metadata-changed",
+ nullptr);
+ }
+ }
+}
+
+void MediaControlKeyManager::OnActionPerformed(
+ const MediaControlAction& aAction) {
+ for (auto listener : mListeners) {
+ listener->OnActionPerformed(aAction);
+ }
+}
+
+void MediaControlKeyManager::SetPlaybackState(
+ MediaSessionPlaybackState aState) {
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetPlaybackState(aState);
+ }
+ mPlaybackState = aState;
+ LOG_INFO("playbackState=%s", ToMediaSessionPlaybackStateStr(mPlaybackState));
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-displayed-playback-changed",
+ nullptr);
+ }
+ }
+}
+
+MediaSessionPlaybackState MediaControlKeyManager::GetPlaybackState() const {
+ return (mEventSource && mEventSource->IsOpened())
+ ? mEventSource->GetPlaybackState()
+ : mPlaybackState;
+}
+
+void MediaControlKeyManager::SetMediaMetadata(
+ const MediaMetadataBase& aMetadata) {
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetMediaMetadata(aMetadata);
+ }
+ mMetadata = aMetadata;
+ LOG_INFO("title=%s, artist=%s album=%s",
+ NS_ConvertUTF16toUTF8(mMetadata.mTitle).get(),
+ NS_ConvertUTF16toUTF8(mMetadata.mArtist).get(),
+ NS_ConvertUTF16toUTF8(mMetadata.mAlbum).get());
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-displayed-metadata-changed",
+ nullptr);
+ }
+ }
+}
+
+void MediaControlKeyManager::SetSupportedMediaKeys(
+ const MediaKeysArray& aSupportedKeys) {
+ mSupportedKeys.Clear();
+ for (const auto& key : aSupportedKeys) {
+ LOG_INFO("Supported keys=%s", ToMediaControlKeyStr(key));
+ mSupportedKeys.AppendElement(key);
+ }
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetSupportedMediaKeys(mSupportedKeys);
+ }
+}
+
+void MediaControlKeyManager::SetEnableFullScreen(bool aIsEnabled) {
+ LOG_INFO("Set fullscreen %s", aIsEnabled ? "enabled" : "disabled");
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetEnableFullScreen(aIsEnabled);
+ }
+}
+
+void MediaControlKeyManager::SetEnablePictureInPictureMode(bool aIsEnabled) {
+ LOG_INFO("Set Picture-In-Picture mode %s",
+ aIsEnabled ? "enabled" : "disabled");
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetEnablePictureInPictureMode(aIsEnabled);
+ }
+}
+
+void MediaControlKeyManager::SetPositionState(const PositionState& aState) {
+ LOG_INFO("Set PositionState, duration=%f, playbackRate=%f, position=%f",
+ aState.mDuration, aState.mPlaybackRate,
+ aState.mLastReportedPlaybackPosition);
+ if (mEventSource && mEventSource->IsOpened()) {
+ mEventSource->SetPositionState(aState);
+ }
+}
+
+void MediaControlKeyManager::OnPreferenceChange() {
+ const bool isPrefEnabled = StaticPrefs::media_hardwaremediakeys_enabled();
+ // Only start monitoring control keys when the pref is on and having a main
+ // controller that means already having media which need to be controlled.
+ const bool shouldMonitorKeys =
+ isPrefEnabled && MediaControlService::GetService()->GetMainController();
+ LOG_INFO("Preference change : %s media control",
+ isPrefEnabled ? "enable" : "disable");
+ if (shouldMonitorKeys) {
+ Unused << StartMonitoringControlKeys();
+ } else {
+ StopMonitoringControlKeys();
+ }
+}
+
+NS_IMPL_ISUPPORTS(MediaControlKeyManager::Observer, nsIObserver);
+
+MediaControlKeyManager::Observer::Observer(MediaControlKeyManager* aManager)
+ : mManager(aManager) {}
+
+NS_IMETHODIMP
+MediaControlKeyManager::Observer::Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) {
+ if (!strcmp(aTopic, NS_XPCOM_SHUTDOWN_OBSERVER_ID)) {
+ mManager->Shutdown();
+ } else if (!strcmp(aTopic, "nsPref:changed")) {
+ mManager->OnPreferenceChange();
+ }
+ return NS_OK;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaControlKeyManager.h b/dom/media/mediacontrol/MediaControlKeyManager.h
new file mode 100644
index 0000000000..feb857e335
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlKeyManager.h
@@ -0,0 +1,73 @@
+/* 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_MEDIACONTROL_MEDIACONTROLKEYMANAGER_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIACONTROLKEYMANAGER_H_
+
+#include "MediaControlKeySource.h"
+#include "MediaEventSource.h"
+#include "nsIObserver.h"
+
+namespace mozilla::dom {
+
+/**
+ * MediaControlKeyManager is a wrapper of MediaControlKeySource, which
+ * is used to manage creating and destroying a real media keys event source.
+ *
+ * It monitors the amount of the media controller in MediaService, and would
+ * create the event source when there is any existing controller and destroy it
+ * when there is no controller.
+ */
+class MediaControlKeyManager final : public MediaControlKeySource,
+ public MediaControlKeyListener {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(MediaControlKeyManager, override)
+
+ MediaControlKeyManager();
+
+ // MediaControlKeySource methods
+ bool Open() override;
+ void Close() override;
+ bool IsOpened() const override;
+
+ void SetPlaybackState(MediaSessionPlaybackState aState) override;
+ MediaSessionPlaybackState GetPlaybackState() const override;
+
+ // MediaControlKeyListener methods
+ void OnActionPerformed(const MediaControlAction& aAction) override;
+
+ void SetMediaMetadata(const MediaMetadataBase& aMetadata) override;
+ void SetSupportedMediaKeys(const MediaKeysArray& aSupportedKeys) override;
+ void SetEnableFullScreen(bool aIsEnabled) override;
+ void SetEnablePictureInPictureMode(bool aIsEnabled) override;
+ void SetPositionState(const PositionState& aState) override;
+
+ private:
+ ~MediaControlKeyManager();
+ void Shutdown();
+
+ class Observer final : public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+ explicit Observer(MediaControlKeyManager* aManager);
+
+ protected:
+ virtual ~Observer() = default;
+
+ MediaControlKeyManager* MOZ_OWNING_REF mManager;
+ };
+ RefPtr<Observer> mObserver;
+ void OnPreferenceChange();
+
+ bool StartMonitoringControlKeys();
+ void StopMonitoringControlKeys();
+ RefPtr<MediaControlKeySource> mEventSource;
+ MediaMetadataBase mMetadata;
+ nsTArray<MediaControlKey> mSupportedKeys;
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/media/mediacontrol/MediaControlKeySource.cpp b/dom/media/mediacontrol/MediaControlKeySource.cpp
new file mode 100644
index 0000000000..22756c860a
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlKeySource.cpp
@@ -0,0 +1,122 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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 "MediaControlKeySource.h"
+
+#include "MediaController.h"
+#include "MediaControlUtils.h"
+#include "MediaControlService.h"
+#include "mozilla/Logging.h"
+
+namespace mozilla::dom {
+
+// avoid redefined macro in unified build
+#undef LOG_SOURCE
+#define LOG_SOURCE(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaControlKeySource=%p, " msg, this, ##__VA_ARGS__))
+
+#undef LOG_KEY
+#define LOG_KEY(msg, key, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaControlKeyHandler=%p, " msg, this, ToMediaControlKeyStr(key), \
+ ##__VA_ARGS__));
+
+void MediaControlKeyHandler::OnActionPerformed(
+ const MediaControlAction& aAction) {
+ LOG_KEY("OnActionPerformed '%s'", aAction.mKey);
+
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service);
+ RefPtr<IMediaController> controller = service->GetMainController();
+ if (!controller) {
+ return;
+ }
+
+ switch (aAction.mKey) {
+ case MediaControlKey::Focus:
+ controller->Focus();
+ return;
+ case MediaControlKey::Play:
+ controller->Play();
+ return;
+ case MediaControlKey::Pause:
+ controller->Pause();
+ return;
+ case MediaControlKey::Playpause: {
+ if (controller->IsPlaying()) {
+ controller->Pause();
+ } else {
+ controller->Play();
+ }
+ return;
+ }
+ case MediaControlKey::Previoustrack:
+ controller->PrevTrack();
+ return;
+ case MediaControlKey::Nexttrack:
+ controller->NextTrack();
+ return;
+ case MediaControlKey::Seekbackward:
+ controller->SeekBackward();
+ return;
+ case MediaControlKey::Seekforward:
+ controller->SeekForward();
+ return;
+ case MediaControlKey::Skipad:
+ controller->SkipAd();
+ return;
+ case MediaControlKey::Seekto: {
+ const SeekDetails& details = *aAction.mDetails;
+ controller->SeekTo(details.mSeekTime, details.mFastSeek);
+ return;
+ }
+ case MediaControlKey::Stop:
+ controller->Stop();
+ return;
+ default:
+ MOZ_ASSERT_UNREACHABLE("Error : undefined media key!");
+ return;
+ }
+}
+
+MediaControlKeySource::MediaControlKeySource()
+ : mPlaybackState(MediaSessionPlaybackState::None) {}
+
+void MediaControlKeySource::AddListener(MediaControlKeyListener* aListener) {
+ MOZ_ASSERT(aListener);
+ LOG_SOURCE("Add listener %p", aListener);
+ mListeners.AppendElement(aListener);
+}
+
+void MediaControlKeySource::RemoveListener(MediaControlKeyListener* aListener) {
+ MOZ_ASSERT(aListener);
+ LOG_SOURCE("Remove listener %p", aListener);
+ mListeners.RemoveElement(aListener);
+}
+
+size_t MediaControlKeySource::GetListenersNum() const {
+ return mListeners.Length();
+}
+
+void MediaControlKeySource::Close() {
+ LOG_SOURCE("Close source");
+ mListeners.Clear();
+}
+
+void MediaControlKeySource::SetPlaybackState(MediaSessionPlaybackState aState) {
+ if (mPlaybackState == aState) {
+ return;
+ }
+ LOG_SOURCE("SetPlaybackState '%s'", ToMediaSessionPlaybackStateStr(aState));
+ mPlaybackState = aState;
+}
+
+MediaSessionPlaybackState MediaControlKeySource::GetPlaybackState() const {
+ return mPlaybackState;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaControlKeySource.h b/dom/media/mediacontrol/MediaControlKeySource.h
new file mode 100644
index 0000000000..f5d62a429e
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlKeySource.h
@@ -0,0 +1,122 @@
+/* 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_MEDIACONTROL_MEDIACONTROLKEYSOURCE_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIACONTROLKEYSOURCE_H_
+
+#include "mozilla/dom/MediaControllerBinding.h"
+#include "mozilla/dom/MediaMetadata.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "nsISupportsImpl.h"
+#include "nsTArray.h"
+
+namespace mozilla::dom {
+
+// This is used to store seek related properties from MediaSessionActionDetails.
+// However, currently we have no plan to support `seekOffset`.
+// https://w3c.github.io/mediasession/#the-mediasessionactiondetails-dictionary
+struct SeekDetails {
+ SeekDetails() = default;
+ explicit SeekDetails(double aSeekTime) : mSeekTime(aSeekTime) {}
+ SeekDetails(double aSeekTime, bool aFastSeek)
+ : mSeekTime(aSeekTime), mFastSeek(aFastSeek) {}
+ double mSeekTime = 0.0;
+ bool mFastSeek = false;
+};
+
+struct MediaControlAction {
+ MediaControlAction() = default;
+ explicit MediaControlAction(MediaControlKey aKey) : mKey(aKey) {}
+ MediaControlAction(MediaControlKey aKey, const SeekDetails& aDetails)
+ : mKey(aKey), mDetails(Some(aDetails)) {}
+ MediaControlKey mKey = MediaControlKey::EndGuard_;
+ Maybe<SeekDetails> mDetails;
+};
+
+/**
+ * MediaControlKeyListener is a pure interface, which is used to monitor
+ * MediaControlKey, we can add it onto the MediaControlKeySource,
+ * and then everytime when the media key events occur, `OnActionPerformed` will
+ * be called so that we can do related handling.
+ */
+class MediaControlKeyListener {
+ public:
+ NS_INLINE_DECL_PURE_VIRTUAL_REFCOUNTING
+ MediaControlKeyListener() = default;
+
+ virtual void OnActionPerformed(const MediaControlAction& aAction) = 0;
+
+ protected:
+ virtual ~MediaControlKeyListener() = default;
+};
+
+/**
+ * MediaControlKeyHandler is used to operate media controller by corresponding
+ * received media control key events.
+ */
+class MediaControlKeyHandler final : public MediaControlKeyListener {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(MediaControlKeyHandler, override)
+ void OnActionPerformed(const MediaControlAction& aAction) override;
+
+ private:
+ virtual ~MediaControlKeyHandler() = default;
+};
+
+/**
+ * MediaControlKeySource is an abstract class which is used to implement
+ * transporting media control keys event to all its listeners when media keys
+ * event happens.
+ */
+class MediaControlKeySource {
+ public:
+ NS_INLINE_DECL_PURE_VIRTUAL_REFCOUNTING
+ MediaControlKeySource();
+
+ using MediaKeysArray = nsTArray<MediaControlKey>;
+
+ virtual void AddListener(MediaControlKeyListener* aListener);
+ virtual void RemoveListener(MediaControlKeyListener* aListener);
+ size_t GetListenersNum() const;
+
+ // Return true if the initialization of the source succeeds, and inherited
+ // sources should implement this method to handle the initialization fails.
+ virtual bool Open() = 0;
+ virtual void Close();
+ virtual bool IsOpened() const = 0;
+
+ /**
+ * All following `SetXXX()` functions are used to update the playback related
+ * properties change from a specific tab, which can represent the playback
+ * status for Firefox instance. Even if we have multiple tabs playing media at
+ * the same time, we would only update information from one of that tabs that
+ * would be done by `MediaControlService`.
+ */
+ virtual void SetPlaybackState(MediaSessionPlaybackState aState);
+ virtual MediaSessionPlaybackState GetPlaybackState() const;
+
+ // Override this method if the event source needs to handle the metadata.
+ virtual void SetMediaMetadata(const MediaMetadataBase& aMetadata) {}
+
+ // Set the supported media keys which the event source can use to determine
+ // what kinds of buttons should be shown on the UI.
+ virtual void SetSupportedMediaKeys(const MediaKeysArray& aSupportedKeys) = 0;
+
+ // Override these methods if the inherited key source want to know the change
+ // for following attributes. For example, GeckoView would use these methods
+ // to notify change to the embedded application.
+ virtual void SetEnableFullScreen(bool aIsEnabled){};
+ virtual void SetEnablePictureInPictureMode(bool aIsEnabled){};
+ virtual void SetPositionState(const PositionState& aState){};
+
+ protected:
+ virtual ~MediaControlKeySource() = default;
+ nsTArray<RefPtr<MediaControlKeyListener>> mListeners;
+ MediaSessionPlaybackState mPlaybackState;
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/media/mediacontrol/MediaControlService.cpp b/dom/media/mediacontrol/MediaControlService.cpp
new file mode 100644
index 0000000000..c321e080d2
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlService.cpp
@@ -0,0 +1,540 @@
+/* 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 "MediaControlService.h"
+
+#include "MediaController.h"
+#include "MediaControlUtils.h"
+#include "mozilla/Assertions.h"
+#include "mozilla/intl/Localization.h"
+#include "mozilla/Logging.h"
+#include "mozilla/Services.h"
+#include "mozilla/StaticPrefs_media.h"
+#include "mozilla/StaticPtr.h"
+#include "mozilla/Telemetry.h"
+#include "nsIObserverService.h"
+#include "nsXULAppAPI.h"
+
+using mozilla::intl::Localization;
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaControlService=%p, " msg, this, ##__VA_ARGS__))
+
+#undef LOG_MAINCONTROLLER
+#define LOG_MAINCONTROLLER(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, (msg, ##__VA_ARGS__))
+
+#undef LOG_MAINCONTROLLER_INFO
+#define LOG_MAINCONTROLLER_INFO(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Info, (msg, ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+StaticRefPtr<MediaControlService> gMediaControlService;
+static bool sIsXPCOMShutdown = false;
+
+/* static */
+RefPtr<MediaControlService> MediaControlService::GetService() {
+ MOZ_DIAGNOSTIC_ASSERT(XRE_IsParentProcess(),
+ "MediaControlService only runs on Chrome process!");
+ if (sIsXPCOMShutdown) {
+ return nullptr;
+ }
+ if (!gMediaControlService) {
+ gMediaControlService = new MediaControlService();
+ gMediaControlService->Init();
+ }
+ RefPtr<MediaControlService> service = gMediaControlService.get();
+ return service;
+}
+
+/* static */
+void MediaControlService::GenerateMediaControlKey(const GlobalObject& global,
+ MediaControlKey aKey) {
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ if (service) {
+ service->GenerateTestMediaControlKey(aKey);
+ }
+}
+
+/* static */
+void MediaControlService::GetCurrentActiveMediaMetadata(
+ const GlobalObject& aGlobal, MediaMetadataInit& aMetadata) {
+ if (RefPtr<MediaControlService> service = MediaControlService::GetService()) {
+ MediaMetadataBase metadata = service->GetMainControllerMediaMetadata();
+ aMetadata.mTitle = metadata.mTitle;
+ aMetadata.mArtist = metadata.mArtist;
+ aMetadata.mAlbum = metadata.mAlbum;
+ for (const auto& artwork : metadata.mArtwork) {
+ // If OOM happens resulting in not able to append the element, then we
+ // would get incorrect result and fail on test, so we don't need to throw
+ // an error explicitly.
+ if (MediaImage* image = aMetadata.mArtwork.AppendElement(fallible)) {
+ image->mSrc = artwork.mSrc;
+ image->mSizes = artwork.mSizes;
+ image->mType = artwork.mType;
+ }
+ }
+ }
+}
+
+/* static */
+MediaSessionPlaybackState
+MediaControlService::GetCurrentMediaSessionPlaybackState(
+ GlobalObject& aGlobal) {
+ if (RefPtr<MediaControlService> service = MediaControlService::GetService()) {
+ return service->GetMainControllerPlaybackState();
+ }
+ return MediaSessionPlaybackState::None;
+}
+
+NS_INTERFACE_MAP_BEGIN(MediaControlService)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIObserver)
+ NS_INTERFACE_MAP_ENTRY(nsIObserver)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_ADDREF(MediaControlService)
+NS_IMPL_RELEASE(MediaControlService)
+
+MediaControlService::MediaControlService() {
+ LOG("create media control service");
+ RefPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
+ if (obs) {
+ obs->AddObserver(this, "xpcom-shutdown", false);
+ }
+}
+
+void MediaControlService::Init() {
+ mMediaKeysHandler = new MediaControlKeyHandler();
+ mMediaControlKeyManager = new MediaControlKeyManager();
+ mMediaControlKeyManager->AddListener(mMediaKeysHandler.get());
+ mControllerManager = MakeUnique<ControllerManager>(this);
+
+ // Initialize the fallback title
+ nsTArray<nsCString> resIds{
+ "branding/brand.ftl"_ns,
+ "dom/media.ftl"_ns,
+ };
+ RefPtr<Localization> l10n = Localization::Create(resIds, true);
+ {
+ nsAutoCString translation;
+ IgnoredErrorResult rv;
+ l10n->FormatValueSync("mediastatus-fallback-title"_ns, {}, translation, rv);
+ if (!rv.Failed()) {
+ mFallbackTitle = NS_ConvertUTF8toUTF16(translation);
+ }
+ }
+}
+
+MediaControlService::~MediaControlService() {
+ LOG("destroy media control service");
+ Shutdown();
+}
+
+void MediaControlService::NotifyMediaControlHasEverBeenUsed() {
+ // We've already updated the telemetry for using meida control.
+ if (mHasEverUsedMediaControl) {
+ return;
+ }
+ mHasEverUsedMediaControl = true;
+ const uint32_t usedOnMediaControl = 1;
+#ifdef XP_WIN
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Windows"_ns, usedOnMediaControl);
+#endif
+#ifdef XP_MACOSX
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"MacOS"_ns, usedOnMediaControl);
+#endif
+#ifdef MOZ_WIDGET_GTK
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Linux"_ns, usedOnMediaControl);
+#endif
+#ifdef MOZ_WIDGET_ANDROID
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Android"_ns, usedOnMediaControl);
+#endif
+}
+
+void MediaControlService::NotifyMediaControlHasEverBeenEnabled() {
+ // We've already enabled the service and update the telemetry.
+ if (mHasEverEnabledMediaControl) {
+ return;
+ }
+ mHasEverEnabledMediaControl = true;
+ const uint32_t enableOnMediaControl = 0;
+#ifdef XP_WIN
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Windows"_ns, enableOnMediaControl);
+#endif
+#ifdef XP_MACOSX
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"MacOS"_ns, enableOnMediaControl);
+#endif
+#ifdef MOZ_WIDGET_GTK
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Linux"_ns, enableOnMediaControl);
+#endif
+#ifdef MOZ_WIDGET_ANDROID
+ Telemetry::ScalarSet(Telemetry::ScalarID::MEDIA_CONTROL_PLATFORM_USAGE,
+ u"Android"_ns, enableOnMediaControl);
+#endif
+}
+
+NS_IMETHODIMP
+MediaControlService::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) {
+ if (!strcmp(aTopic, "xpcom-shutdown")) {
+ LOG("XPCOM shutdown");
+ MOZ_ASSERT(gMediaControlService);
+ RefPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
+ if (obs) {
+ obs->RemoveObserver(this, "xpcom-shutdown");
+ }
+ Shutdown();
+ sIsXPCOMShutdown = true;
+ gMediaControlService = nullptr;
+ }
+ return NS_OK;
+}
+
+void MediaControlService::Shutdown() {
+ mControllerManager->Shutdown();
+ mMediaControlKeyManager->RemoveListener(mMediaKeysHandler.get());
+}
+
+bool MediaControlService::RegisterActiveMediaController(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(mControllerManager,
+ "Register controller before initializing service");
+ if (!mControllerManager->AddController(aController)) {
+ LOG("Fail to register controller %" PRId64, aController->Id());
+ return false;
+ }
+ LOG("Register media controller %" PRId64 ", currentNum=%" PRId64,
+ aController->Id(), GetActiveControllersNum());
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-controller-amount-changed", nullptr);
+ }
+ }
+ return true;
+}
+
+bool MediaControlService::UnregisterActiveMediaController(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(mControllerManager,
+ "Unregister controller before initializing service");
+ if (!mControllerManager->RemoveController(aController)) {
+ LOG("Fail to unregister controller %" PRId64, aController->Id());
+ return false;
+ }
+ LOG("Unregister media controller %" PRId64 ", currentNum=%" PRId64,
+ aController->Id(), GetActiveControllersNum());
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-controller-amount-changed", nullptr);
+ }
+ }
+ return true;
+}
+
+void MediaControlService::NotifyControllerPlaybackStateChanged(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(
+ mControllerManager,
+ "controller state change happens before initializing service");
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+ // The controller is not an active controller.
+ if (!mControllerManager->Contains(aController)) {
+ return;
+ }
+
+ // The controller is the main controller, propagate its playback state.
+ if (GetMainController() == aController) {
+ mControllerManager->MainControllerPlaybackStateChanged(
+ aController->PlaybackState());
+ return;
+ }
+
+ // The controller is not the main controller, but will become a new main
+ // controller. As the service can contains multiple controllers and only one
+ // controller can be controlled by media control keys. Therefore, when
+ // controller's state becomes `playing`, then we would like to let that
+ // controller being controlled, rather than other controller which might not
+ // be playing at the time.
+ if (GetMainController() != aController &&
+ aController->PlaybackState() == MediaSessionPlaybackState::Playing) {
+ mControllerManager->UpdateMainControllerIfNeeded(aController);
+ }
+}
+
+void MediaControlService::RequestUpdateMainController(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+ MOZ_DIAGNOSTIC_ASSERT(
+ mControllerManager,
+ "using controller in PIP mode before initializing service");
+ // The controller is not an active controller.
+ if (!mControllerManager->Contains(aController)) {
+ return;
+ }
+ mControllerManager->UpdateMainControllerIfNeeded(aController);
+}
+
+uint64_t MediaControlService::GetActiveControllersNum() const {
+ MOZ_DIAGNOSTIC_ASSERT(mControllerManager);
+ return mControllerManager->GetControllersNum();
+}
+
+MediaController* MediaControlService::GetMainController() const {
+ MOZ_DIAGNOSTIC_ASSERT(mControllerManager);
+ return mControllerManager->GetMainController();
+}
+
+void MediaControlService::GenerateTestMediaControlKey(MediaControlKey aKey) {
+ if (!StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ return;
+ }
+ // Generate a seek details for `seekto`
+ if (aKey == MediaControlKey::Seekto) {
+ mMediaKeysHandler->OnActionPerformed(
+ MediaControlAction(aKey, SeekDetails()));
+ } else {
+ mMediaKeysHandler->OnActionPerformed(MediaControlAction(aKey));
+ }
+}
+
+MediaMetadataBase MediaControlService::GetMainControllerMediaMetadata() const {
+ MediaMetadataBase metadata;
+ if (!StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ return metadata;
+ }
+ return GetMainController() ? GetMainController()->GetCurrentMediaMetadata()
+ : metadata;
+}
+
+MediaSessionPlaybackState MediaControlService::GetMainControllerPlaybackState()
+ const {
+ if (!StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ return MediaSessionPlaybackState::None;
+ }
+ return GetMainController() ? GetMainController()->PlaybackState()
+ : MediaSessionPlaybackState::None;
+}
+
+nsString MediaControlService::GetFallbackTitle() const {
+ return mFallbackTitle;
+}
+
+// Following functions belong to ControllerManager
+MediaControlService::ControllerManager::ControllerManager(
+ MediaControlService* aService)
+ : mSource(aService->GetMediaControlKeySource()) {
+ MOZ_ASSERT(mSource);
+}
+
+bool MediaControlService::ControllerManager::AddController(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+ if (mControllers.contains(aController)) {
+ return false;
+ }
+ mControllers.insertBack(aController);
+ UpdateMainControllerIfNeeded(aController);
+ return true;
+}
+
+bool MediaControlService::ControllerManager::RemoveController(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+ if (!mControllers.contains(aController)) {
+ return false;
+ }
+ // This is LinkedListElement's method which will remove controller from
+ // `mController`.
+ static_cast<LinkedListControllerPtr>(aController)->remove();
+ // If main controller is removed from the list, the last controller in the
+ // list would become the main controller. Or reset the main controller when
+ // the list is already empty.
+ if (GetMainController() == aController) {
+ UpdateMainControllerInternal(
+ mControllers.isEmpty() ? nullptr : mControllers.getLast());
+ }
+ return true;
+}
+
+void MediaControlService::ControllerManager::UpdateMainControllerIfNeeded(
+ MediaController* aController) {
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+
+ if (GetMainController() == aController) {
+ LOG_MAINCONTROLLER("This controller is alreay the main controller");
+ return;
+ }
+
+ if (GetMainController() &&
+ GetMainController()->IsBeingUsedInPIPModeOrFullscreen() &&
+ !aController->IsBeingUsedInPIPModeOrFullscreen()) {
+ LOG_MAINCONTROLLER(
+ "Normal media controller can't replace the controller being used in "
+ "PIP mode or fullscreen");
+ return ReorderGivenController(aController,
+ InsertOptions::eInsertAsNormalController);
+ }
+ ReorderGivenController(aController, InsertOptions::eInsertAsMainController);
+ UpdateMainControllerInternal(aController);
+}
+
+void MediaControlService::ControllerManager::ReorderGivenController(
+ MediaController* aController, InsertOptions aOption) {
+ MOZ_DIAGNOSTIC_ASSERT(aController);
+ MOZ_DIAGNOSTIC_ASSERT(mControllers.contains(aController));
+ // Reset the controller's position and make it not in any list.
+ static_cast<LinkedListControllerPtr>(aController)->remove();
+
+ if (aOption == InsertOptions::eInsertAsMainController) {
+ // Make the main controller as the last element in the list to maintain the
+ // order of controllers because we always use the last controller in the
+ // list as the next main controller when removing current main controller
+ // from the list. Eg. If the list contains [A, B, C], and now the last
+ // element C is the main controller. When B becomes main controller later,
+ // the list would become [A, C, B]. And if A becomes main controller, list
+ // would become [C, B, A]. Then, if we remove A from the list, the next main
+ // controller would be B. But if we don't maintain the controller order when
+ // main controller changes, we would pick C as the main controller because
+ // the list is still [A, B, C].
+ return mControllers.insertBack(aController);
+ }
+
+ MOZ_ASSERT(aOption == InsertOptions::eInsertAsNormalController);
+ MOZ_ASSERT(GetMainController() != aController);
+ // We might have multiple controllers which have higher priority (being used
+ // in PIP or fullscreen) from the head, the normal controller should be
+ // inserted before them. Therefore, search a higher priority controller from
+ // the head and insert new controller before it.
+ // Eg. a list [A, B, C, D, E] and D and E have higher priority, if we want
+ // to insert F, then the final result would be [A, B, C, F, D, E]
+ auto* current = static_cast<LinkedListControllerPtr>(mControllers.getFirst());
+ while (!static_cast<MediaController*>(current)
+ ->IsBeingUsedInPIPModeOrFullscreen()) {
+ current = current->getNext();
+ }
+ MOZ_ASSERT(current, "Should have at least one higher priority controller!");
+ current->setPrevious(aController);
+}
+
+void MediaControlService::ControllerManager::Shutdown() {
+ mControllers.clear();
+ DisconnectMainControllerEvents();
+}
+
+void MediaControlService::ControllerManager::MainControllerPlaybackStateChanged(
+ MediaSessionPlaybackState aState) {
+ MOZ_ASSERT(NS_IsMainThread());
+ mSource->SetPlaybackState(aState);
+}
+
+void MediaControlService::ControllerManager::MainControllerMetadataChanged(
+ const MediaMetadataBase& aMetadata) {
+ MOZ_ASSERT(NS_IsMainThread());
+ mSource->SetMediaMetadata(aMetadata);
+}
+
+void MediaControlService::ControllerManager::UpdateMainControllerInternal(
+ MediaController* aController) {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (aController) {
+ aController->Select();
+ }
+ if (mMainController) {
+ mMainController->Unselect();
+ }
+ mMainController = aController;
+
+ if (!mMainController) {
+ LOG_MAINCONTROLLER_INFO("Clear main controller");
+ mSource->Close();
+ DisconnectMainControllerEvents();
+ } else {
+ LOG_MAINCONTROLLER_INFO("Set controller %" PRId64 " as main controller",
+ mMainController->Id());
+ if (!mSource->Open()) {
+ LOG("Failed to open source for monitoring media keys");
+ }
+ // We would still update those status to the event source even if it failed
+ // to open, because it would save the result and set them to the real
+ // source when it opens. In addition, another benefit to do that is to
+ // prevent testing from affecting by platform specific issues, because our
+ // testing events rely on those status changes and they are all platform
+ // independent.
+ mSource->SetPlaybackState(mMainController->PlaybackState());
+ mSource->SetMediaMetadata(mMainController->GetCurrentMediaMetadata());
+ mSource->SetSupportedMediaKeys(mMainController->GetSupportedMediaKeys());
+ ConnectMainControllerEvents();
+ }
+
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "main-media-controller-changed", nullptr);
+ }
+ }
+}
+
+void MediaControlService::ControllerManager::ConnectMainControllerEvents() {
+ // As main controller has been changed, we should disconnect listeners from
+ // the previous controller and reconnect them to the new controller.
+ DisconnectMainControllerEvents();
+ // Listen to main controller's event in order to propagate the content that
+ // might be displayed on the virtual control interface created by the source.
+ mMetadataChangedListener = mMainController->MetadataChangedEvent().Connect(
+ AbstractThread::MainThread(), this,
+ &ControllerManager::MainControllerMetadataChanged);
+ mSupportedKeysChangedListener =
+ mMainController->SupportedKeysChangedEvent().Connect(
+ AbstractThread::MainThread(),
+ [this](const MediaKeysArray& aSupportedKeys) {
+ mSource->SetSupportedMediaKeys(aSupportedKeys);
+ });
+ mFullScreenChangedListener =
+ mMainController->FullScreenChangedEvent().Connect(
+ AbstractThread::MainThread(), [this](bool aIsEnabled) {
+ mSource->SetEnableFullScreen(aIsEnabled);
+ });
+ mPictureInPictureModeChangedListener =
+ mMainController->PictureInPictureModeChangedEvent().Connect(
+ AbstractThread::MainThread(), [this](bool aIsEnabled) {
+ mSource->SetEnablePictureInPictureMode(aIsEnabled);
+ });
+ mPositionChangedListener = mMainController->PositionChangedEvent().Connect(
+ AbstractThread::MainThread(), [this](const PositionState& aState) {
+ mSource->SetPositionState(aState);
+ });
+}
+
+void MediaControlService::ControllerManager::DisconnectMainControllerEvents() {
+ mMetadataChangedListener.DisconnectIfExists();
+ mSupportedKeysChangedListener.DisconnectIfExists();
+ mFullScreenChangedListener.DisconnectIfExists();
+ mPictureInPictureModeChangedListener.DisconnectIfExists();
+ mPositionChangedListener.DisconnectIfExists();
+}
+
+MediaController* MediaControlService::ControllerManager::GetMainController()
+ const {
+ return mMainController.get();
+}
+
+uint64_t MediaControlService::ControllerManager::GetControllersNum() const {
+ return mControllers.length();
+}
+
+bool MediaControlService::ControllerManager::Contains(
+ MediaController* aController) const {
+ return mControllers.contains(aController);
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaControlService.h b/dom/media/mediacontrol/MediaControlService.h
new file mode 100644
index 0000000000..d0112ffe3c
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlService.h
@@ -0,0 +1,181 @@
+/* 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_MEDIACONTROL_MEDIACONTROLSERVICE_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIACONTROLSERVICE_H_
+
+#include "mozilla/AlreadyAddRefed.h"
+
+#include "AudioFocusManager.h"
+#include "MediaController.h"
+#include "MediaControlKeyManager.h"
+#include "mozilla/dom/MediaControllerBinding.h"
+#include "nsIObserver.h"
+#include "nsTArray.h"
+
+namespace mozilla::dom {
+
+/**
+ * MediaControlService is an interface to access controllers by providing
+ * controller Id. Everytime when controller becomes active, which means there is
+ * one or more media started in the corresponding browsing context, so now the
+ * controller is actually controlling something in the content process, so it
+ * would be added into the list of the MediaControlService. The controller would
+ * be removed from the list of the MediaControlService when it becomes inactive,
+ * which means no media is playing in the corresponding browsing context. Note
+ * that, a controller can't be added to or remove from the list twice. It should
+ * should have a responsibility to add and remove itself in the proper time.
+ */
+class MediaControlService final : public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+
+ static RefPtr<MediaControlService> GetService();
+
+ // Currently these following static methods are only being used in testing.
+ static void GenerateMediaControlKey(const GlobalObject& global,
+ MediaControlKey aKey);
+ static void GetCurrentActiveMediaMetadata(const GlobalObject& aGlobal,
+ MediaMetadataInit& aMetadata);
+ static MediaSessionPlaybackState GetCurrentMediaSessionPlaybackState(
+ GlobalObject& aGlobal);
+
+ AudioFocusManager& GetAudioFocusManager() { return mAudioFocusManager; }
+ MediaControlKeySource* GetMediaControlKeySource() {
+ return mMediaControlKeyManager;
+ }
+
+ // Use these functions to register/unresgister controller to/from the active
+ // controller list in the service. Return true if the controller is registered
+ // or unregistered sucessfully.
+ bool RegisterActiveMediaController(MediaController* aController);
+ bool UnregisterActiveMediaController(MediaController* aController);
+ uint64_t GetActiveControllersNum() const;
+
+ // This method would be called when the controller changes its playback state.
+ void NotifyControllerPlaybackStateChanged(MediaController* aController);
+
+ // This method is used to help a media controller become a main controller, if
+ // it fits the requirement.
+ void RequestUpdateMainController(MediaController* aController);
+
+ // The main controller is the controller which can receive the media control
+ // key events and would show its metadata to virtual controller interface.
+ MediaController* GetMainController() const;
+
+ /**
+ * These following functions are used for testing only. We use them to
+ * generate fake media control key events, get the media metadata and playback
+ * state from the main controller.
+ */
+ void GenerateTestMediaControlKey(MediaControlKey aKey);
+ MediaMetadataBase GetMainControllerMediaMetadata() const;
+ MediaSessionPlaybackState GetMainControllerPlaybackState() const;
+
+ // Media title that should be used as a fallback. This commonly used
+ // when playing media in private browsing mode and we are trying to avoid
+ // exposing potentially sensitive titles.
+ nsString GetFallbackTitle() const;
+
+ // These functions are used to update the variable which would be used for
+ // telemetry probe.
+ void NotifyMediaControlHasEverBeenUsed();
+ void NotifyMediaControlHasEverBeenEnabled();
+
+ private:
+ MediaControlService();
+ ~MediaControlService();
+
+ /**
+ * When there are multiple media controllers existing, we would only choose
+ * one media controller as the main controller which can be controlled by
+ * media control keys event. The latest controller which is added into the
+ * service would become the main controller.
+ *
+ * However, as the main controller would be changed from time to time, so we
+ * create this wrapper to hold a real main controller if it exists. This class
+ * would also observe the playback state of controller in order to update the
+ * playback state of the event source.
+ *
+ * In addition, after finishing bug1592037, we would get the media metadata
+ * from the main controller, and update them to the event source in order to
+ * show those information on the virtual media controller interface on each
+ * platform.
+ */
+ class ControllerManager final {
+ public:
+ explicit ControllerManager(MediaControlService* aService);
+ ~ControllerManager() = default;
+
+ using MediaKeysArray = nsTArray<MediaControlKey>;
+ using LinkedListControllerPtr = LinkedListElement<RefPtr<MediaController>>*;
+ using ConstLinkedListControllerPtr =
+ const LinkedListElement<RefPtr<MediaController>>*;
+
+ bool AddController(MediaController* aController);
+ bool RemoveController(MediaController* aController);
+ void UpdateMainControllerIfNeeded(MediaController* aController);
+
+ void Shutdown();
+
+ MediaController* GetMainController() const;
+ bool Contains(MediaController* aController) const;
+ uint64_t GetControllersNum() const;
+
+ // These functions are used for monitoring main controller's status change.
+ void MainControllerPlaybackStateChanged(MediaSessionPlaybackState aState);
+ void MainControllerMetadataChanged(const MediaMetadataBase& aMetadata);
+
+ private:
+ // When applying `eInsertAsMainController`, we would always insert the
+ // element to the tail of the list. Eg. Insert C , [A, B] -> [A, B, C]
+ // When applying `eInsertAsNormalController`, we would insert the element
+ // prior to the element with a higher priority controller. Eg. Insert E and
+ // C and D have higher priority. [A, B, C, D] -> [A, B, E, C, D]
+ enum class InsertOptions {
+ eInsertAsMainController,
+ eInsertAsNormalController,
+ };
+
+ // Adjust the given controller's order by the insert option.
+ void ReorderGivenController(MediaController* aController,
+ InsertOptions aOption);
+
+ void UpdateMainControllerInternal(MediaController* aController);
+ void ConnectMainControllerEvents();
+ void DisconnectMainControllerEvents();
+
+ LinkedList<RefPtr<MediaController>> mControllers;
+ RefPtr<MediaController> mMainController;
+
+ // These member are use to listen main controller's play state changes and
+ // update the playback state to the event source.
+ RefPtr<MediaControlKeySource> mSource;
+ MediaEventListener mMetadataChangedListener;
+ MediaEventListener mSupportedKeysChangedListener;
+ MediaEventListener mFullScreenChangedListener;
+ MediaEventListener mPictureInPictureModeChangedListener;
+ MediaEventListener mPositionChangedListener;
+ };
+
+ void Init();
+ void Shutdown();
+
+ AudioFocusManager mAudioFocusManager;
+ RefPtr<MediaControlKeyManager> mMediaControlKeyManager;
+ RefPtr<MediaControlKeyListener> mMediaKeysHandler;
+ MediaEventProducer<uint64_t> mMediaControllerAmountChangedEvent;
+ UniquePtr<ControllerManager> mControllerManager;
+ nsString mFallbackTitle;
+
+ // Used for telemetry probe.
+ void UpdateTelemetryUsageProbe();
+ bool mHasEverUsedMediaControl = false;
+ bool mHasEverEnabledMediaControl = false;
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/media/mediacontrol/MediaControlUtils.cpp b/dom/media/mediacontrol/MediaControlUtils.cpp
new file mode 100644
index 0000000000..090844e47c
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlUtils.cpp
@@ -0,0 +1,26 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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 "MediaControlUtils.h"
+
+#include "mozilla/dom/BrowsingContext.h"
+
+mozilla::LazyLogModule gMediaControlLog("MediaControl");
+
+namespace mozilla::dom {
+
+BrowsingContext* GetAliveTopBrowsingContext(BrowsingContext* aBC) {
+ if (!aBC || aBC->IsDiscarded()) {
+ return nullptr;
+ }
+ aBC = aBC->Top();
+ if (!aBC || aBC->IsDiscarded()) {
+ return nullptr;
+ }
+ return aBC;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaControlUtils.h b/dom/media/mediacontrol/MediaControlUtils.h
new file mode 100644
index 0000000000..a327c2f3d8
--- /dev/null
+++ b/dom/media/mediacontrol/MediaControlUtils.h
@@ -0,0 +1,216 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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_MEDIACONTROL_MEDIACONTROLUTILS_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIACONTROLUTILS_H_
+
+#include "imgIEncoder.h"
+#include "imgITools.h"
+#include "MediaController.h"
+#include "mozilla/dom/ChromeUtilsBinding.h"
+#include "mozilla/dom/MediaControllerBinding.h"
+#include "mozilla/Logging.h"
+#include "nsReadableUtils.h"
+#include "nsServiceManagerUtils.h"
+
+extern mozilla::LazyLogModule gMediaControlLog;
+
+namespace mozilla::dom {
+
+inline const char* ToMediaControlKeyStr(MediaControlKey aKey) {
+ switch (aKey) {
+ case MediaControlKey::Focus:
+ return "Focus";
+ case MediaControlKey::Pause:
+ return "Pause";
+ case MediaControlKey::Play:
+ return "Play";
+ case MediaControlKey::Playpause:
+ return "Play & pause";
+ case MediaControlKey::Previoustrack:
+ return "Previous track";
+ case MediaControlKey::Nexttrack:
+ return "Next track";
+ case MediaControlKey::Seekbackward:
+ return "Seek backward";
+ case MediaControlKey::Seekforward:
+ return "Seek forward";
+ case MediaControlKey::Skipad:
+ return "Skip Ad";
+ case MediaControlKey::Seekto:
+ return "Seek to";
+ case MediaControlKey::Stop:
+ return "Stop";
+ default:
+ MOZ_ASSERT_UNREACHABLE("Invalid action.");
+ return "Unknown";
+ }
+}
+
+inline const char* ToMediaSessionActionStr(MediaSessionAction aAction) {
+ switch (aAction) {
+ case MediaSessionAction::Play:
+ return "play";
+ case MediaSessionAction::Pause:
+ return "pause";
+ case MediaSessionAction::Seekbackward:
+ return "seek backward";
+ case MediaSessionAction::Seekforward:
+ return "seek forward";
+ case MediaSessionAction::Previoustrack:
+ return "previous track";
+ case MediaSessionAction::Nexttrack:
+ return "next track";
+ case MediaSessionAction::Skipad:
+ return "skip ad";
+ case MediaSessionAction::Seekto:
+ return "Seek to";
+ default:
+ MOZ_ASSERT(aAction == MediaSessionAction::Stop);
+ return "stop";
+ }
+}
+
+inline MediaControlKey ConvertMediaSessionActionToControlKey(
+ MediaSessionAction aAction) {
+ switch (aAction) {
+ case MediaSessionAction::Play:
+ return MediaControlKey::Play;
+ case MediaSessionAction::Pause:
+ return MediaControlKey::Pause;
+ case MediaSessionAction::Seekbackward:
+ return MediaControlKey::Seekbackward;
+ case MediaSessionAction::Seekforward:
+ return MediaControlKey::Seekforward;
+ case MediaSessionAction::Previoustrack:
+ return MediaControlKey::Previoustrack;
+ case MediaSessionAction::Nexttrack:
+ return MediaControlKey::Nexttrack;
+ case MediaSessionAction::Skipad:
+ return MediaControlKey::Skipad;
+ case MediaSessionAction::Seekto:
+ return MediaControlKey::Seekto;
+ default:
+ MOZ_ASSERT(aAction == MediaSessionAction::Stop);
+ return MediaControlKey::Stop;
+ }
+}
+
+inline MediaSessionAction ConvertToMediaSessionAction(uint8_t aActionValue) {
+ MOZ_DIAGNOSTIC_ASSERT(aActionValue < uint8_t(MediaSessionAction::EndGuard_));
+ return static_cast<MediaSessionAction>(aActionValue);
+}
+
+inline const char* ToMediaPlaybackStateStr(MediaPlaybackState aState) {
+ switch (aState) {
+ case MediaPlaybackState::eStarted:
+ return "started";
+ case MediaPlaybackState::ePlayed:
+ return "played";
+ case MediaPlaybackState::ePaused:
+ return "paused";
+ case MediaPlaybackState::eStopped:
+ return "stopped";
+ default:
+ MOZ_ASSERT_UNREACHABLE("Invalid media state.");
+ return "Unknown";
+ }
+}
+
+inline const char* ToMediaAudibleStateStr(MediaAudibleState aState) {
+ switch (aState) {
+ case MediaAudibleState::eInaudible:
+ return "inaudible";
+ case MediaAudibleState::eAudible:
+ return "audible";
+ default:
+ MOZ_ASSERT_UNREACHABLE("Invalid audible state.");
+ return "Unknown";
+ }
+}
+
+inline const char* ToMediaSessionPlaybackStateStr(
+ const MediaSessionPlaybackState& aState) {
+ switch (aState) {
+ case MediaSessionPlaybackState::None:
+ return "none";
+ case MediaSessionPlaybackState::Paused:
+ return "paused";
+ case MediaSessionPlaybackState::Playing:
+ return "playing";
+ default:
+ MOZ_ASSERT_UNREACHABLE("Invalid MediaSessionPlaybackState.");
+ return "Unknown";
+ }
+}
+
+BrowsingContext* GetAliveTopBrowsingContext(BrowsingContext* aBC);
+
+inline bool IsImageIn(const nsTArray<MediaImage>& aArtwork,
+ const nsAString& aImageUrl) {
+ for (const MediaImage& image : aArtwork) {
+ if (image.mSrc == aImageUrl) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// The image buffer would be allocated in aStream whose size is aSize and the
+// buffer head is aBuffer
+inline nsresult GetEncodedImageBuffer(imgIContainer* aImage,
+ const nsACString& aMimeType,
+ nsIInputStream** aStream, uint32_t* aSize,
+ char** aBuffer) {
+ MOZ_ASSERT(aImage);
+
+ nsCOMPtr<imgITools> imgTools = do_GetService("@mozilla.org/image/tools;1");
+ if (!imgTools) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsCOMPtr<nsIInputStream> inputStream;
+ nsresult rv = imgTools->EncodeImage(aImage, aMimeType, u""_ns,
+ getter_AddRefs(inputStream));
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ if (!inputStream) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsCOMPtr<imgIEncoder> encoder = do_QueryInterface(inputStream);
+ if (!encoder) {
+ return NS_ERROR_FAILURE;
+ }
+
+ rv = encoder->GetImageBufferUsed(aSize);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = encoder->GetImageBuffer(aBuffer);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ encoder.forget(aStream);
+ return NS_OK;
+}
+
+inline bool IsValidImageUrl(const nsAString& aUrl) {
+ return StringBeginsWith(aUrl, u"http://"_ns) ||
+ StringBeginsWith(aUrl, u"https://"_ns);
+}
+
+inline uint32_t GetMediaKeyMask(mozilla::dom::MediaControlKey aKey) {
+ return 1 << static_cast<uint8_t>(aKey);
+}
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_MEDIACONTROLUTILS_H_
diff --git a/dom/media/mediacontrol/MediaController.cpp b/dom/media/mediacontrol/MediaController.cpp
new file mode 100644
index 0000000000..b06635091d
--- /dev/null
+++ b/dom/media/mediacontrol/MediaController.cpp
@@ -0,0 +1,560 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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 "MediaController.h"
+
+#include "MediaControlService.h"
+#include "MediaControlUtils.h"
+#include "MediaControlKeySource.h"
+#include "mozilla/AsyncEventDispatcher.h"
+#include "mozilla/StaticPrefs_media.h"
+#include "mozilla/dom/BrowsingContext.h"
+#include "mozilla/dom/CanonicalBrowsingContext.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/dom/PositionStateEvent.h"
+
+// avoid redefined macro in unified build
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaController=%p, Id=%" PRId64 ", " msg, this, this->Id(), \
+ ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+NS_IMPL_CYCLE_COLLECTION_INHERITED(MediaController, DOMEventTargetHelper)
+NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED(MediaController,
+ DOMEventTargetHelper,
+ nsITimerCallback, nsINamed)
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(MediaController,
+ DOMEventTargetHelper)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+nsISupports* MediaController::GetParentObject() const {
+ RefPtr<BrowsingContext> bc = BrowsingContext::Get(Id());
+ return bc;
+}
+
+JSObject* MediaController::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return MediaController_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+void MediaController::GetSupportedKeys(
+ nsTArray<MediaControlKey>& aRetVal) const {
+ aRetVal.Clear();
+ for (const auto& key : mSupportedKeys) {
+ aRetVal.AppendElement(key);
+ }
+}
+
+void MediaController::GetMetadata(MediaMetadataInit& aMetadata,
+ ErrorResult& aRv) {
+ if (!IsActive() || mShutdown) {
+ aRv.Throw(NS_ERROR_NOT_AVAILABLE);
+ return;
+ }
+
+ const MediaMetadataBase metadata = GetCurrentMediaMetadata();
+ aMetadata.mTitle = metadata.mTitle;
+ aMetadata.mArtist = metadata.mArtist;
+ aMetadata.mAlbum = metadata.mAlbum;
+ for (const auto& artwork : metadata.mArtwork) {
+ if (MediaImage* image = aMetadata.mArtwork.AppendElement(fallible)) {
+ image->mSrc = artwork.mSrc;
+ image->mSizes = artwork.mSizes;
+ image->mType = artwork.mType;
+ } else {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+ }
+}
+
+static const MediaControlKey sDefaultSupportedKeys[] = {
+ MediaControlKey::Focus, MediaControlKey::Play, MediaControlKey::Pause,
+ MediaControlKey::Playpause, MediaControlKey::Stop,
+};
+
+static void GetDefaultSupportedKeys(nsTArray<MediaControlKey>& aKeys) {
+ for (const auto& key : sDefaultSupportedKeys) {
+ aKeys.AppendElement(key);
+ }
+}
+
+MediaController::MediaController(uint64_t aBrowsingContextId)
+ : MediaStatusManager(aBrowsingContextId) {
+ MOZ_DIAGNOSTIC_ASSERT(XRE_IsParentProcess(),
+ "MediaController only runs on Chrome process!");
+ LOG("Create controller %" PRId64, Id());
+ GetDefaultSupportedKeys(mSupportedKeys);
+ mSupportedActionsChangedListener = SupportedActionsChangedEvent().Connect(
+ AbstractThread::MainThread(), this,
+ &MediaController::HandleSupportedMediaSessionActionsChanged);
+ mPlaybackChangedListener = PlaybackChangedEvent().Connect(
+ AbstractThread::MainThread(), this,
+ &MediaController::HandleActualPlaybackStateChanged);
+ mPositionStateChangedListener = PositionChangedEvent().Connect(
+ AbstractThread::MainThread(), this,
+ &MediaController::HandlePositionStateChanged);
+ mMetadataChangedListener =
+ MetadataChangedEvent().Connect(AbstractThread::MainThread(), this,
+ &MediaController::HandleMetadataChanged);
+}
+
+MediaController::~MediaController() {
+ LOG("Destroy controller %" PRId64, Id());
+ if (!mShutdown) {
+ Shutdown();
+ }
+};
+
+void MediaController::Focus() {
+ LOG("Focus");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Focus));
+}
+
+void MediaController::Play() {
+ LOG("Play");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Play));
+}
+
+void MediaController::Pause() {
+ LOG("Pause");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Pause));
+}
+
+void MediaController::PrevTrack() {
+ LOG("Prev Track");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Previoustrack));
+}
+
+void MediaController::NextTrack() {
+ LOG("Next Track");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Nexttrack));
+}
+
+void MediaController::SeekBackward() {
+ LOG("Seek Backward");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Seekbackward));
+}
+
+void MediaController::SeekForward() {
+ LOG("Seek Forward");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Seekforward));
+}
+
+void MediaController::SkipAd() {
+ LOG("Skip Ad");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Skipad));
+}
+
+void MediaController::SeekTo(double aSeekTime, bool aFastSeek) {
+ LOG("Seek To");
+ UpdateMediaControlActionToContentMediaIfNeeded(MediaControlAction(
+ MediaControlKey::Seekto, SeekDetails(aSeekTime, aFastSeek)));
+}
+
+void MediaController::Stop() {
+ LOG("Stop");
+ UpdateMediaControlActionToContentMediaIfNeeded(
+ MediaControlAction(MediaControlKey::Stop));
+ MediaStatusManager::ClearActiveMediaSessionContextIdIfNeeded();
+}
+
+uint64_t MediaController::Id() const { return mTopLevelBrowsingContextId; }
+
+bool MediaController::IsAudible() const { return IsMediaAudible(); }
+
+bool MediaController::IsPlaying() const { return IsMediaPlaying(); }
+
+bool MediaController::IsActive() const { return mIsActive; };
+
+bool MediaController::ShouldPropagateActionToAllContexts(
+ const MediaControlAction& aAction) const {
+ // These three actions have default action handler for each frame, so we
+ // need to propagate to all contexts. We would handle default handlers in
+ // `ContentMediaController::HandleMediaKey`.
+ return aAction.mKey == MediaControlKey::Play ||
+ aAction.mKey == MediaControlKey::Pause ||
+ aAction.mKey == MediaControlKey::Stop;
+}
+
+void MediaController::UpdateMediaControlActionToContentMediaIfNeeded(
+ const MediaControlAction& aAction) {
+ // If the controller isn't active or it has been shutdown, we don't need to
+ // update media action to the content process.
+ if (!mIsActive || mShutdown) {
+ return;
+ }
+
+ // For some actions which have default action handler, we want to propagate
+ // them on all contexts in order to trigger the default handler on each
+ // context separately. Otherwise, other action should only be propagated to
+ // the context where active media session exists.
+ const bool propateToAll = ShouldPropagateActionToAllContexts(aAction);
+ const uint64_t targetContextId = propateToAll || !mActiveMediaSessionContextId
+ ? Id()
+ : *mActiveMediaSessionContextId;
+ RefPtr<BrowsingContext> context = BrowsingContext::Get(targetContextId);
+ if (!context || context->IsDiscarded()) {
+ return;
+ }
+
+ if (propateToAll) {
+ context->PreOrderWalk([&](BrowsingContext* bc) {
+ bc->Canonical()->UpdateMediaControlAction(aAction);
+ });
+ } else {
+ context->Canonical()->UpdateMediaControlAction(aAction);
+ }
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service);
+ service->NotifyMediaControlHasEverBeenUsed();
+}
+
+void MediaController::Shutdown() {
+ MOZ_ASSERT(!mShutdown, "Do not call shutdown twice!");
+ // The media controller would be removed from the service when we receive a
+ // notification from the content process about all controlled media has been
+ // stoppped. However, if controlled media is stopped after detaching
+ // browsing context, then sending the notification from the content process
+ // would fail so that we are not able to notify the chrome process to remove
+ // the corresponding controller. Therefore, we should manually remove the
+ // controller from the service.
+ Deactivate();
+ mShutdown = true;
+ mSupportedActionsChangedListener.DisconnectIfExists();
+ mPlaybackChangedListener.DisconnectIfExists();
+ mPositionStateChangedListener.DisconnectIfExists();
+ mMetadataChangedListener.DisconnectIfExists();
+}
+
+void MediaController::NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) {
+ if (mShutdown) {
+ return;
+ }
+ MediaStatusManager::NotifyMediaPlaybackChanged(aBrowsingContextId, aState);
+ UpdateDeactivationTimerIfNeeded();
+ UpdateActivatedStateIfNeeded();
+}
+
+void MediaController::UpdateDeactivationTimerIfNeeded() {
+ if (!StaticPrefs::media_mediacontrol_stopcontrol_timer()) {
+ return;
+ }
+
+ bool shouldBeAlwaysActive = IsPlaying() || IsBeingUsedInPIPModeOrFullscreen();
+ if (shouldBeAlwaysActive && mDeactivationTimer) {
+ LOG("Cancel deactivation timer");
+ mDeactivationTimer->Cancel();
+ mDeactivationTimer = nullptr;
+ } else if (!shouldBeAlwaysActive && !mDeactivationTimer) {
+ nsresult rv = NS_NewTimerWithCallback(
+ getter_AddRefs(mDeactivationTimer), this,
+ StaticPrefs::media_mediacontrol_stopcontrol_timer_ms(),
+ nsITimer::TYPE_ONE_SHOT, AbstractThread::MainThread());
+ if (NS_SUCCEEDED(rv)) {
+ LOG("Create a deactivation timer");
+ } else {
+ LOG("Failed to create a deactivation timer");
+ }
+ }
+}
+
+bool MediaController::IsBeingUsedInPIPModeOrFullscreen() const {
+ return mIsInPictureInPictureMode || mIsInFullScreenMode;
+}
+
+NS_IMETHODIMP MediaController::Notify(nsITimer* aTimer) {
+ mDeactivationTimer = nullptr;
+ if (!StaticPrefs::media_mediacontrol_stopcontrol_timer()) {
+ return NS_OK;
+ }
+
+ if (mShutdown) {
+ LOG("Cancel deactivation timer because controller has been shutdown");
+ return NS_OK;
+ }
+
+ // As the media being used in the PIP mode or fullscreen would always display
+ // on the screen, users would have high chance to interact with it again, so
+ // we don't want to stop media control.
+ if (IsBeingUsedInPIPModeOrFullscreen()) {
+ LOG("Cancel deactivation timer because controller is in PIP mode");
+ return NS_OK;
+ }
+
+ if (IsPlaying()) {
+ LOG("Cancel deactivation timer because controller is still playing");
+ return NS_OK;
+ }
+
+ if (!mIsActive) {
+ LOG("Cancel deactivation timer because controller has been deactivated");
+ return NS_OK;
+ }
+ Deactivate();
+ return NS_OK;
+}
+
+NS_IMETHODIMP MediaController::GetName(nsACString& aName) {
+ aName.AssignLiteral("MediaController");
+ return NS_OK;
+}
+
+void MediaController::NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) {
+ if (mShutdown) {
+ return;
+ }
+
+ bool oldAudible = IsAudible();
+ MediaStatusManager::NotifyMediaAudibleChanged(aBrowsingContextId, aState);
+ if (IsAudible() == oldAudible) {
+ return;
+ }
+ UpdateActivatedStateIfNeeded();
+
+ // Request the audio focus amongs different controllers that could cause
+ // pausing other audible controllers if we enable the audio focus management.
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service);
+ if (IsAudible()) {
+ service->GetAudioFocusManager().RequestAudioFocus(this);
+ } else {
+ service->GetAudioFocusManager().RevokeAudioFocus(this);
+ }
+}
+
+bool MediaController::ShouldActivateController() const {
+ MOZ_ASSERT(!mShutdown);
+ // After media is successfully loaded and match our critiera, such as its
+ // duration is longer enough, which is used to exclude the notification-ish
+ // sound, then it would be able to be controlled once the controll gets
+ // activated.
+ //
+ // Activating a controller means that we would start to intercept the media
+ // keys on the platform and show the virtual control interface (if needed).
+ // The controller would be activated when (1) controllable media starts in the
+ // browsing context that controller belongs to (2) controllable media enters
+ // fullscreen or PIP mode.
+ return IsAnyMediaBeingControlled() &&
+ (IsPlaying() || IsBeingUsedInPIPModeOrFullscreen()) && !mIsActive;
+}
+
+bool MediaController::ShouldDeactivateController() const {
+ MOZ_ASSERT(!mShutdown);
+ // If we don't have an active media session and no controlled media exists,
+ // then we don't need to keep controller active, because there is nothing to
+ // control. However, if we still have an active media session, then we should
+ // keep controller active in order to receive media keys even if we don't have
+ // any controlled media existing, because a website might start other media
+ // when media session receives media keys.
+ return !IsAnyMediaBeingControlled() && mIsActive &&
+ !mActiveMediaSessionContextId;
+}
+
+void MediaController::Activate() {
+ MOZ_ASSERT(!mShutdown);
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ if (service && !mIsActive) {
+ LOG("Activate");
+ mIsActive = service->RegisterActiveMediaController(this);
+ MOZ_ASSERT(mIsActive, "Fail to register controller!");
+ DispatchAsyncEvent(u"activated"_ns);
+ }
+}
+
+void MediaController::Deactivate() {
+ MOZ_ASSERT(!mShutdown);
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ if (service) {
+ service->GetAudioFocusManager().RevokeAudioFocus(this);
+ if (mIsActive) {
+ LOG("Deactivate");
+ mIsActive = !service->UnregisterActiveMediaController(this);
+ MOZ_ASSERT(!mIsActive, "Fail to unregister controller!");
+ DispatchAsyncEvent(u"deactivated"_ns);
+ }
+ }
+}
+
+void MediaController::SetIsInPictureInPictureMode(
+ uint64_t aBrowsingContextId, bool aIsInPictureInPictureMode) {
+ if (mIsInPictureInPictureMode == aIsInPictureInPictureMode) {
+ return;
+ }
+ LOG("Set IsInPictureInPictureMode to %s",
+ aIsInPictureInPictureMode ? "true" : "false");
+ mIsInPictureInPictureMode = aIsInPictureInPictureMode;
+ ForceToBecomeMainControllerIfNeeded();
+ UpdateDeactivationTimerIfNeeded();
+ mPictureInPictureModeChangedEvent.Notify(mIsInPictureInPictureMode);
+}
+
+void MediaController::NotifyMediaFullScreenState(uint64_t aBrowsingContextId,
+ bool aIsInFullScreen) {
+ if (mIsInFullScreenMode == aIsInFullScreen) {
+ return;
+ }
+ LOG("%s fullscreen", aIsInFullScreen ? "Entered" : "Left");
+ mIsInFullScreenMode = aIsInFullScreen;
+ ForceToBecomeMainControllerIfNeeded();
+ mFullScreenChangedEvent.Notify(mIsInFullScreenMode);
+}
+
+bool MediaController::IsMainController() const {
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ return service ? service->GetMainController() == this : false;
+}
+
+bool MediaController::ShouldRequestForMainController() const {
+ // This controller is already the main controller.
+ if (IsMainController()) {
+ return false;
+ }
+ // We would only force controller to become main controller if it's in the
+ // PIP mode or fullscreen, otherwise it should follow the general rule.
+ // In addition, do nothing if the controller has been shutdowned.
+ return IsBeingUsedInPIPModeOrFullscreen() && !mShutdown;
+}
+
+void MediaController::ForceToBecomeMainControllerIfNeeded() {
+ if (!ShouldRequestForMainController()) {
+ return;
+ }
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ MOZ_ASSERT(service, "service was shutdown before shutting down controller?");
+ // If the controller hasn't been activated and it's ready to be activated,
+ // then activating it should also make it become a main controller. If it's
+ // already activated but isn't a main controller yet, then explicitly request
+ // it.
+ if (!IsActive() && ShouldActivateController()) {
+ Activate();
+ } else if (IsActive()) {
+ service->RequestUpdateMainController(this);
+ }
+}
+
+void MediaController::HandleActualPlaybackStateChanged() {
+ // Media control service would like to know all controllers' playback state
+ // in order to decide which controller should be the main controller that is
+ // usually the last tab which plays media.
+ if (RefPtr<MediaControlService> service = MediaControlService::GetService()) {
+ service->NotifyControllerPlaybackStateChanged(this);
+ }
+ DispatchAsyncEvent(u"playbackstatechange"_ns);
+}
+
+void MediaController::UpdateActivatedStateIfNeeded() {
+ if (ShouldActivateController()) {
+ Activate();
+ } else if (ShouldDeactivateController()) {
+ Deactivate();
+ }
+}
+
+void MediaController::HandleSupportedMediaSessionActionsChanged(
+ const nsTArray<MediaSessionAction>& aSupportedAction) {
+ // Convert actions to keys, some of them have been included in the supported
+ // keys, such as "play", "pause" and "stop".
+ nsTArray<MediaControlKey> newSupportedKeys;
+ GetDefaultSupportedKeys(newSupportedKeys);
+ for (const auto& action : aSupportedAction) {
+ MediaControlKey key = ConvertMediaSessionActionToControlKey(action);
+ if (!newSupportedKeys.Contains(key)) {
+ newSupportedKeys.AppendElement(key);
+ }
+ }
+ // As the supported key event should only be notified when supported keys
+ // change, so abort following steps if they don't change.
+ if (newSupportedKeys == mSupportedKeys) {
+ return;
+ }
+ LOG("Supported keys changes");
+ mSupportedKeys = newSupportedKeys;
+ mSupportedKeysChangedEvent.Notify(mSupportedKeys);
+ RefPtr<AsyncEventDispatcher> asyncDispatcher = new AsyncEventDispatcher(
+ this, u"supportedkeyschange"_ns, CanBubble::eYes);
+ asyncDispatcher->PostDOMEvent();
+ MediaController_Binding::ClearCachedSupportedKeysValue(this);
+}
+
+void MediaController::HandlePositionStateChanged(const PositionState& aState) {
+ PositionStateEventInit init;
+ init.mDuration = aState.mDuration;
+ init.mPlaybackRate = aState.mPlaybackRate;
+ init.mPosition = aState.mLastReportedPlaybackPosition;
+ RefPtr<PositionStateEvent> event =
+ PositionStateEvent::Constructor(this, u"positionstatechange"_ns, init);
+ DispatchAsyncEvent(event);
+}
+
+void MediaController::HandleMetadataChanged(
+ const MediaMetadataBase& aMetadata) {
+ // The reason we don't append metadata with `metadatachange` event is that
+ // allocating artwork might fail if the memory is not enough, but for the
+ // event we are not able to throw an error. Therefore, we want to the listener
+ // to use `getMetadata()` to get metadata, because it would throw an error if
+ // we fail to allocate artwork.
+ DispatchAsyncEvent(u"metadatachange"_ns);
+ // If metadata change is because of resetting active media session, then we
+ // should check if controller needs to be deactivated.
+ if (ShouldDeactivateController()) {
+ Deactivate();
+ }
+}
+
+void MediaController::DispatchAsyncEvent(const nsAString& aName) {
+ RefPtr<Event> event = NS_NewDOMEvent(this, nullptr, nullptr);
+ event->InitEvent(aName, false, false);
+ event->SetTrusted(true);
+ DispatchAsyncEvent(event);
+}
+
+void MediaController::DispatchAsyncEvent(Event* aEvent) {
+ MOZ_ASSERT(aEvent);
+ nsAutoString eventType;
+ aEvent->GetType(eventType);
+ if (!mIsActive && !eventType.EqualsLiteral("deactivated")) {
+ LOG("Only 'deactivated' can be dispatched on a deactivated controller, not "
+ "'%s'",
+ NS_ConvertUTF16toUTF8(eventType).get());
+ return;
+ }
+ LOG("Dispatch event %s", NS_ConvertUTF16toUTF8(eventType).get());
+ RefPtr<AsyncEventDispatcher> asyncDispatcher =
+ new AsyncEventDispatcher(this, aEvent);
+ asyncDispatcher->PostDOMEvent();
+}
+
+CopyableTArray<MediaControlKey> MediaController::GetSupportedMediaKeys() const {
+ return mSupportedKeys;
+}
+
+void MediaController::Select() const {
+ if (RefPtr<BrowsingContext> bc = BrowsingContext::Get(Id())) {
+ bc->Canonical()->AddPageAwakeRequest();
+ }
+}
+
+void MediaController::Unselect() const {
+ if (RefPtr<BrowsingContext> bc = BrowsingContext::Get(Id())) {
+ bc->Canonical()->RemovePageAwakeRequest();
+ }
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaController.h b/dom/media/mediacontrol/MediaController.h
new file mode 100644
index 0000000000..0f0e624f6a
--- /dev/null
+++ b/dom/media/mediacontrol/MediaController.h
@@ -0,0 +1,214 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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_MEDIACONTROL_MEDIACONTROLLER_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIACONTROLLER_H_
+
+#include "MediaEventSource.h"
+#include "MediaPlaybackStatus.h"
+#include "MediaStatusManager.h"
+#include "mozilla/DOMEventTargetHelper.h"
+#include "mozilla/dom/MediaControllerBinding.h"
+#include "mozilla/dom/MediaSession.h"
+#include "mozilla/LinkedList.h"
+#include "nsISupportsImpl.h"
+#include "nsITimer.h"
+
+namespace mozilla::dom {
+
+class BrowsingContext;
+
+/**
+ * IMediaController is an interface which includes control related methods and
+ * methods used to know its playback state.
+ */
+class IMediaController {
+ public:
+ NS_INLINE_DECL_PURE_VIRTUAL_REFCOUNTING
+
+ // Focus the window currently playing media.
+ virtual void Focus() = 0;
+ virtual void Play() = 0;
+ virtual void Pause() = 0;
+ virtual void Stop() = 0;
+ virtual void PrevTrack() = 0;
+ virtual void NextTrack() = 0;
+ virtual void SeekBackward() = 0;
+ virtual void SeekForward() = 0;
+ virtual void SkipAd() = 0;
+ virtual void SeekTo(double aSeekTime, bool aFastSeek) = 0;
+
+ // Return the ID of the top level browsing context within a tab.
+ virtual uint64_t Id() const = 0;
+ virtual bool IsAudible() const = 0;
+ virtual bool IsPlaying() const = 0;
+ virtual bool IsActive() const = 0;
+};
+
+/**
+ * MediaController is a class, which is used to control all media within a tab.
+ * It can only be used in Chrome process and the controlled media are usually
+ * in the content process (unless we disable e10s).
+ *
+ * Each tab would have only one media controller, they are 1-1 corresponding
+ * relationship, we use tab's top-level browsing context ID to initialize the
+ * controller and use that as its ID.
+ *
+ * The controller would be activated when its controlled media starts and
+ * becomes audible. After the controller is activated, then we can use its
+ * controlling methods, such as `Play()`, `Pause()` to control the media within
+ * the tab.
+ *
+ * If there is at least one controlled media playing in the tab, then we would
+ * say the controller is `playing`. If there is at least one controlled media is
+ * playing and audible, then we would say the controller is `audible`.
+ *
+ * Note that, if we don't enable audio competition, then we might have multiple
+ * tabs playing media at the same time, we can use the ID to query the specific
+ * controller from `MediaControlService`.
+ */
+class MediaController final : public DOMEventTargetHelper,
+ public IMediaController,
+ public LinkedListElement<RefPtr<MediaController>>,
+ public MediaStatusManager,
+ public nsITimerCallback,
+ public nsINamed {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_NSITIMERCALLBACK
+ NS_DECL_NSINAMED
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(MediaController,
+ DOMEventTargetHelper)
+ explicit MediaController(uint64_t aBrowsingContextId);
+
+ // WebIDL methods
+ nsISupports* GetParentObject() const;
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+ void GetSupportedKeys(nsTArray<MediaControlKey>& aRetVal) const;
+ void GetMetadata(MediaMetadataInit& aMetadata, ErrorResult& aRv);
+ IMPL_EVENT_HANDLER(activated);
+ IMPL_EVENT_HANDLER(deactivated);
+ IMPL_EVENT_HANDLER(metadatachange);
+ IMPL_EVENT_HANDLER(supportedkeyschange);
+ IMPL_EVENT_HANDLER(playbackstatechange);
+ IMPL_EVENT_HANDLER(positionstatechange);
+
+ // IMediaController's methods
+ void Focus() override;
+ void Play() override;
+ void Pause() override;
+ void Stop() override;
+ void PrevTrack() override;
+ void NextTrack() override;
+ void SeekBackward() override;
+ void SeekForward() override;
+ void SkipAd() override;
+ void SeekTo(double aSeekTime, bool aFastSeek) override;
+
+ uint64_t Id() const override;
+ bool IsAudible() const override;
+ bool IsPlaying() const override;
+ bool IsActive() const override;
+
+ // IMediaInfoUpdater's methods
+ void NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) override;
+ void NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) override;
+ void SetIsInPictureInPictureMode(uint64_t aBrowsingContextId,
+ bool aIsInPictureInPictureMode) override;
+ void NotifyMediaFullScreenState(uint64_t aBrowsingContextId,
+ bool aIsInFullScreen) override;
+
+ // Calling this method explicitly would mark this controller as deprecated,
+ // then calling any its method won't take any effect.
+ void Shutdown();
+
+ // This event would be notified media controller's supported media keys
+ // change.
+ MediaEventSource<nsTArray<MediaControlKey>>& SupportedKeysChangedEvent() {
+ return mSupportedKeysChangedEvent;
+ }
+
+ MediaEventSource<bool>& FullScreenChangedEvent() {
+ return mFullScreenChangedEvent;
+ }
+
+ MediaEventSource<bool>& PictureInPictureModeChangedEvent() {
+ return mPictureInPictureModeChangedEvent;
+ }
+
+ CopyableTArray<MediaControlKey> GetSupportedMediaKeys() const;
+
+ bool IsBeingUsedInPIPModeOrFullscreen() const;
+
+ // These methods are used to select/unselect the media controller as a main
+ // controller.
+ void Select() const;
+ void Unselect() const;
+
+ private:
+ ~MediaController();
+ void HandleActualPlaybackStateChanged();
+ void UpdateMediaControlActionToContentMediaIfNeeded(
+ const MediaControlAction& aAction);
+ void HandleSupportedMediaSessionActionsChanged(
+ const nsTArray<MediaSessionAction>& aSupportedAction);
+
+ void HandlePositionStateChanged(const PositionState& aState);
+ void HandleMetadataChanged(const MediaMetadataBase& aMetadata);
+
+ // This would register controller to the media control service that takes a
+ // responsibility to manage all active controllers.
+ void Activate();
+
+ // This would unregister controller from the media control service.
+ void Deactivate();
+
+ void UpdateActivatedStateIfNeeded();
+ bool ShouldActivateController() const;
+ bool ShouldDeactivateController() const;
+
+ void UpdateDeactivationTimerIfNeeded();
+
+ void DispatchAsyncEvent(const nsAString& aName);
+ void DispatchAsyncEvent(Event* aEvent);
+
+ bool IsMainController() const;
+ void ForceToBecomeMainControllerIfNeeded();
+ bool ShouldRequestForMainController() const;
+
+ bool ShouldPropagateActionToAllContexts(
+ const MediaControlAction& aAction) const;
+
+ bool mIsActive = false;
+ bool mShutdown = false;
+ bool mIsInPictureInPictureMode = false;
+ bool mIsInFullScreenMode = false;
+
+ // We would monitor the change of media session actions and convert them to
+ // the media keys, then determine the supported media keys.
+ MediaEventListener mSupportedActionsChangedListener;
+ MediaEventProducer<nsTArray<MediaControlKey>> mSupportedKeysChangedEvent;
+
+ MediaEventListener mPlaybackChangedListener;
+ MediaEventListener mPositionStateChangedListener;
+ MediaEventListener mMetadataChangedListener;
+
+ MediaEventProducer<bool> mFullScreenChangedEvent;
+ MediaEventProducer<bool> mPictureInPictureModeChangedEvent;
+ // Use copyable array so that we can use the result as a parameter for the
+ // media event.
+ CopyableTArray<MediaControlKey> mSupportedKeys;
+ // Timer to deactivate the controller if the time of being paused exceeds the
+ // threshold of time.
+ nsCOMPtr<nsITimer> mDeactivationTimer;
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/media/mediacontrol/MediaPlaybackStatus.cpp b/dom/media/mediacontrol/MediaPlaybackStatus.cpp
new file mode 100644
index 0000000000..80dedf8599
--- /dev/null
+++ b/dom/media/mediacontrol/MediaPlaybackStatus.cpp
@@ -0,0 +1,142 @@
+/* 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 "MediaPlaybackStatus.h"
+
+#include "MediaControlUtils.h"
+
+namespace mozilla::dom {
+
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaPlaybackStatus=%p, " msg, this, ##__VA_ARGS__))
+
+void MediaPlaybackStatus::UpdateMediaPlaybackState(uint64_t aContextId,
+ MediaPlaybackState aState) {
+ LOG("Update playback state '%s' for context %" PRIu64,
+ ToMediaPlaybackStateStr(aState), aContextId);
+ MOZ_ASSERT(NS_IsMainThread());
+
+ ContextMediaInfo& info = GetNotNullContextInfo(aContextId);
+ if (aState == MediaPlaybackState::eStarted) {
+ info.IncreaseControlledMediaNum();
+ } else if (aState == MediaPlaybackState::eStopped) {
+ info.DecreaseControlledMediaNum();
+ } else if (aState == MediaPlaybackState::ePlayed) {
+ info.IncreasePlayingMediaNum();
+ } else {
+ MOZ_ASSERT(aState == MediaPlaybackState::ePaused);
+ info.DecreasePlayingMediaNum();
+ }
+
+ // The context still has controlled media, we should keep its alive.
+ if (info.IsAnyMediaBeingControlled()) {
+ return;
+ }
+ MOZ_ASSERT(!info.IsPlaying());
+ MOZ_ASSERT(!info.IsAudible());
+ // DO NOT access `info` after this line.
+ DestroyContextInfo(aContextId);
+}
+
+void MediaPlaybackStatus::DestroyContextInfo(uint64_t aContextId) {
+ MOZ_ASSERT(NS_IsMainThread());
+ LOG("Remove context %" PRIu64, aContextId);
+ mContextInfoMap.Remove(aContextId);
+ // If the removed context is owning the audio focus, we would find another
+ // context to take the audio focus if it's possible.
+ if (IsContextOwningAudioFocus(aContextId)) {
+ ChooseNewContextToOwnAudioFocus();
+ }
+}
+
+void MediaPlaybackStatus::UpdateMediaAudibleState(uint64_t aContextId,
+ MediaAudibleState aState) {
+ LOG("Update audible state '%s' for context %" PRIu64,
+ ToMediaAudibleStateStr(aState), aContextId);
+ MOZ_ASSERT(NS_IsMainThread());
+ ContextMediaInfo& info = GetNotNullContextInfo(aContextId);
+ if (aState == MediaAudibleState::eAudible) {
+ info.IncreaseAudibleMediaNum();
+ } else {
+ MOZ_ASSERT(aState == MediaAudibleState::eInaudible);
+ info.DecreaseAudibleMediaNum();
+ }
+ if (ShouldRequestAudioFocusForInfo(info)) {
+ SetOwningAudioFocusContextId(Some(aContextId));
+ } else if (ShouldAbandonAudioFocusForInfo(info)) {
+ ChooseNewContextToOwnAudioFocus();
+ }
+}
+
+bool MediaPlaybackStatus::IsPlaying() const {
+ MOZ_ASSERT(NS_IsMainThread());
+ return std::any_of(mContextInfoMap.Values().cbegin(),
+ mContextInfoMap.Values().cend(),
+ [](const auto& info) { return info->IsPlaying(); });
+}
+
+bool MediaPlaybackStatus::IsAudible() const {
+ MOZ_ASSERT(NS_IsMainThread());
+ return std::any_of(mContextInfoMap.Values().cbegin(),
+ mContextInfoMap.Values().cend(),
+ [](const auto& info) { return info->IsAudible(); });
+}
+
+bool MediaPlaybackStatus::IsAnyMediaBeingControlled() const {
+ MOZ_ASSERT(NS_IsMainThread());
+ return std::any_of(
+ mContextInfoMap.Values().cbegin(), mContextInfoMap.Values().cend(),
+ [](const auto& info) { return info->IsAnyMediaBeingControlled(); });
+}
+
+MediaPlaybackStatus::ContextMediaInfo&
+MediaPlaybackStatus::GetNotNullContextInfo(uint64_t aContextId) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return *mContextInfoMap.GetOrInsertNew(aContextId, aContextId);
+}
+
+Maybe<uint64_t> MediaPlaybackStatus::GetAudioFocusOwnerContextId() const {
+ return mOwningAudioFocusContextId;
+}
+
+void MediaPlaybackStatus::ChooseNewContextToOwnAudioFocus() {
+ for (const auto& info : mContextInfoMap.Values()) {
+ if (info->IsAudible()) {
+ SetOwningAudioFocusContextId(Some(info->Id()));
+ return;
+ }
+ }
+ // No context is audible, so no one should the own audio focus.
+ SetOwningAudioFocusContextId(Nothing());
+}
+
+void MediaPlaybackStatus::SetOwningAudioFocusContextId(
+ Maybe<uint64_t>&& aContextId) {
+ if (mOwningAudioFocusContextId == aContextId) {
+ return;
+ }
+ mOwningAudioFocusContextId = aContextId;
+}
+
+bool MediaPlaybackStatus::ShouldRequestAudioFocusForInfo(
+ const ContextMediaInfo& aInfo) const {
+ return aInfo.IsAudible() && !IsContextOwningAudioFocus(aInfo.Id());
+}
+
+bool MediaPlaybackStatus::ShouldAbandonAudioFocusForInfo(
+ const ContextMediaInfo& aInfo) const {
+ // The owner becomes inaudible and there is other context still playing, so we
+ // should switch the audio focus to the audible context.
+ return !aInfo.IsAudible() && IsContextOwningAudioFocus(aInfo.Id()) &&
+ IsAudible();
+}
+
+bool MediaPlaybackStatus::IsContextOwningAudioFocus(uint64_t aContextId) const {
+ return mOwningAudioFocusContextId ? *mOwningAudioFocusContextId == aContextId
+ : false;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaPlaybackStatus.h b/dom/media/mediacontrol/MediaPlaybackStatus.h
new file mode 100644
index 0000000000..bf0a5cc611
--- /dev/null
+++ b/dom/media/mediacontrol/MediaPlaybackStatus.h
@@ -0,0 +1,139 @@
+/* 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_MEDIACONTROL_MEDIAPLAYBACKSTATUS_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIAPLAYBACKSTATUS_H_
+
+#include "mozilla/Maybe.h"
+#include "mozilla/RefPtr.h"
+#include "nsISupportsImpl.h"
+#include "nsTArray.h"
+#include "nsTHashMap.h"
+
+namespace mozilla::dom {
+
+/**
+ * This enum is used to update controlled media state to the media controller in
+ * the chrome process.
+ * `eStarted`: media has successfully registered to the content media controller
+ * `ePlayed` : media has started playing
+ * `ePaused` : media has paused playing, but still can be resumed by content
+ * media controller
+ * `eStopped`: media has unregistered from the content media controller, we can
+ * not control it anymore
+ */
+enum class MediaPlaybackState : uint32_t {
+ eStarted,
+ ePlayed,
+ ePaused,
+ eStopped,
+};
+
+/**
+ * This enum is used to update controlled media audible audible state to the
+ * media controller in the chrome process.
+ */
+enum class MediaAudibleState : bool {
+ eInaudible = false,
+ eAudible = true,
+};
+
+/**
+ * MediaPlaybackStatus is an internal module for the media controller, it
+ * represents a tab's media related status, such like "does the tab contain any
+ * controlled media? is the tab playing? is the tab audible?".
+ *
+ * The reason we need this class is that we would like to encapsulate the
+ * details of determining the tab's media status. A tab can contains multiple
+ * browsing contexts, and each browsing context can have different media status.
+ * The final media status would be decided by checking all those context status.
+ *
+ * Use `UpdateMediaXXXState()` to update controlled media status, and use
+ * `IsXXX()` methods to acquire the playback status of the tab.
+ *
+ * As we know each context's audible state, we can decide which context should
+ * owns the audio focus when multiple contexts are all playing audible media at
+ * the same time. In that cases, the latest context that plays media would own
+ * the audio focus. When the context owning the audio focus is destroyed, we
+ * would see if there is another other context still playing audible media, and
+ * switch the audio focus to another context.
+ */
+class MediaPlaybackStatus final {
+ public:
+ void UpdateMediaPlaybackState(uint64_t aContextId, MediaPlaybackState aState);
+ void UpdateMediaAudibleState(uint64_t aContextId, MediaAudibleState aState);
+
+ bool IsPlaying() const;
+ bool IsAudible() const;
+ bool IsAnyMediaBeingControlled() const;
+
+ Maybe<uint64_t> GetAudioFocusOwnerContextId() const;
+
+ private:
+ /**
+ * This internal class stores detailed media status of controlled media for
+ * a browsing context.
+ */
+ class ContextMediaInfo final {
+ public:
+ explicit ContextMediaInfo(uint64_t aContextId) : mContextId(aContextId) {}
+ ~ContextMediaInfo() = default;
+
+ void IncreaseControlledMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mControlledMediaNum < UINT_MAX);
+ mControlledMediaNum++;
+ }
+ void DecreaseControlledMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mControlledMediaNum > 0);
+ mControlledMediaNum--;
+ }
+ void IncreasePlayingMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mPlayingMediaNum < mControlledMediaNum);
+ mPlayingMediaNum++;
+ }
+ void DecreasePlayingMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mPlayingMediaNum > 0);
+ mPlayingMediaNum--;
+ }
+ void IncreaseAudibleMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mAudibleMediaNum < mPlayingMediaNum);
+ mAudibleMediaNum++;
+ }
+ void DecreaseAudibleMediaNum() {
+ MOZ_DIAGNOSTIC_ASSERT(mAudibleMediaNum > 0);
+ mAudibleMediaNum--;
+ }
+ bool IsPlaying() const { return mPlayingMediaNum > 0; }
+ bool IsAudible() const { return mAudibleMediaNum > 0; }
+ bool IsAnyMediaBeingControlled() const { return mControlledMediaNum > 0; }
+ uint64_t Id() const { return mContextId; }
+
+ private:
+ /**
+ * The possible value for those three numbers should follow this rule,
+ * mControlledMediaNum >= mPlayingMediaNum >= mAudibleMediaNum
+ */
+ uint32_t mControlledMediaNum = 0;
+ uint32_t mAudibleMediaNum = 0;
+ uint32_t mPlayingMediaNum = 0;
+ uint64_t mContextId = 0;
+ };
+
+ ContextMediaInfo& GetNotNullContextInfo(uint64_t aContextId);
+ void DestroyContextInfo(uint64_t aContextId);
+
+ void ChooseNewContextToOwnAudioFocus();
+ void SetOwningAudioFocusContextId(Maybe<uint64_t>&& aContextId);
+ bool IsContextOwningAudioFocus(uint64_t aContextId) const;
+ bool ShouldRequestAudioFocusForInfo(const ContextMediaInfo& aInfo) const;
+ bool ShouldAbandonAudioFocusForInfo(const ContextMediaInfo& aInfo) const;
+
+ // This contains all the media status of browsing contexts within a tab.
+ nsTHashMap<uint64_t, UniquePtr<ContextMediaInfo>> mContextInfoMap;
+ Maybe<uint64_t> mOwningAudioFocusContextId;
+};
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_MEDIAPLAYBACKSTATUS_H_
diff --git a/dom/media/mediacontrol/MediaStatusManager.cpp b/dom/media/mediacontrol/MediaStatusManager.cpp
new file mode 100644
index 0000000000..4365e6b531
--- /dev/null
+++ b/dom/media/mediacontrol/MediaStatusManager.cpp
@@ -0,0 +1,482 @@
+/* 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 "MediaStatusManager.h"
+
+#include "MediaControlService.h"
+#include "mozilla/dom/CanonicalBrowsingContext.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/MediaControlUtils.h"
+#include "mozilla/dom/WindowGlobalParent.h"
+#include "mozilla/StaticPrefs_media.h"
+#include "nsContentUtils.h"
+#include "nsIChromeRegistry.h"
+#include "nsIObserverService.h"
+#include "nsIXULAppInfo.h"
+#include "nsNetUtil.h"
+
+#ifdef MOZ_PLACES
+# include "nsIFaviconService.h"
+#endif // MOZ_PLACES
+
+extern mozilla::LazyLogModule gMediaControlLog;
+
+// avoid redefined macro in unified build
+#undef LOG
+#define LOG(msg, ...) \
+ MOZ_LOG(gMediaControlLog, LogLevel::Debug, \
+ ("MediaStatusManager=%p, " msg, this, ##__VA_ARGS__))
+
+namespace mozilla::dom {
+
+static bool IsMetadataEmpty(const Maybe<MediaMetadataBase>& aMetadata) {
+ // Media session's metadata is null.
+ if (!aMetadata) {
+ return true;
+ }
+
+ // All attirbutes in metadata are empty.
+ // https://w3c.github.io/mediasession/#empty-metadata
+ const MediaMetadataBase& metadata = *aMetadata;
+ return metadata.mTitle.IsEmpty() && metadata.mArtist.IsEmpty() &&
+ metadata.mAlbum.IsEmpty() && metadata.mArtwork.IsEmpty();
+}
+
+MediaStatusManager::MediaStatusManager(uint64_t aBrowsingContextId)
+ : mTopLevelBrowsingContextId(aBrowsingContextId) {
+ MOZ_DIAGNOSTIC_ASSERT(XRE_IsParentProcess(),
+ "MediaStatusManager only runs on Chrome process!");
+}
+
+void MediaStatusManager::NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) {
+ Maybe<uint64_t> oldAudioFocusOwnerId =
+ mPlaybackStatusDelegate.GetAudioFocusOwnerContextId();
+ mPlaybackStatusDelegate.UpdateMediaAudibleState(aBrowsingContextId, aState);
+ Maybe<uint64_t> newAudioFocusOwnerId =
+ mPlaybackStatusDelegate.GetAudioFocusOwnerContextId();
+ if (oldAudioFocusOwnerId != newAudioFocusOwnerId) {
+ HandleAudioFocusOwnerChanged(newAudioFocusOwnerId);
+ }
+}
+
+void MediaStatusManager::NotifySessionCreated(uint64_t aBrowsingContextId) {
+ const bool created = mMediaSessionInfoMap.WithEntryHandle(
+ aBrowsingContextId, [&](auto&& entry) {
+ if (entry) return false;
+
+ LOG("Session %" PRIu64 " has been created", aBrowsingContextId);
+ entry.Insert(MediaSessionInfo::EmptyInfo());
+ return true;
+ });
+
+ if (created && IsSessionOwningAudioFocus(aBrowsingContextId)) {
+ // This can't be done from within the WithEntryHandle functor, since it
+ // accesses mMediaSessionInfoMap.
+ SetActiveMediaSessionContextId(aBrowsingContextId);
+ }
+}
+
+void MediaStatusManager::NotifySessionDestroyed(uint64_t aBrowsingContextId) {
+ if (mMediaSessionInfoMap.Remove(aBrowsingContextId)) {
+ LOG("Session %" PRIu64 " has been destroyed", aBrowsingContextId);
+
+ if (mActiveMediaSessionContextId &&
+ *mActiveMediaSessionContextId == aBrowsingContextId) {
+ ClearActiveMediaSessionContextIdIfNeeded();
+ }
+ }
+}
+
+void MediaStatusManager::UpdateMetadata(
+ uint64_t aBrowsingContextId, const Maybe<MediaMetadataBase>& aMetadata) {
+ auto info = mMediaSessionInfoMap.Lookup(aBrowsingContextId);
+ if (!info) {
+ return;
+ }
+ if (IsMetadataEmpty(aMetadata)) {
+ LOG("Reset metadata for session %" PRIu64, aBrowsingContextId);
+ info->mMetadata.reset();
+ } else {
+ LOG("Update metadata for session %" PRIu64 " title=%s artist=%s album=%s",
+ aBrowsingContextId, NS_ConvertUTF16toUTF8((*aMetadata).mTitle).get(),
+ NS_ConvertUTF16toUTF8(aMetadata->mArtist).get(),
+ NS_ConvertUTF16toUTF8(aMetadata->mAlbum).get());
+ info->mMetadata = aMetadata;
+ }
+ // Only notify the event if the changed metadata belongs to the active media
+ // session.
+ if (mActiveMediaSessionContextId &&
+ *mActiveMediaSessionContextId == aBrowsingContextId) {
+ LOG("Notify metadata change for active session %" PRIu64,
+ aBrowsingContextId);
+ mMetadataChangedEvent.Notify(GetCurrentMediaMetadata());
+ }
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "media-session-controller-metadata-changed",
+ nullptr);
+ }
+ }
+}
+
+void MediaStatusManager::HandleAudioFocusOwnerChanged(
+ Maybe<uint64_t>& aBrowsingContextId) {
+ // No one is holding the audio focus.
+ if (!aBrowsingContextId) {
+ LOG("No one is owning audio focus");
+ return ClearActiveMediaSessionContextIdIfNeeded();
+ }
+
+ // This owner of audio focus doesn't have media session, so we should deactive
+ // the active session because the active session must own the audio focus.
+ if (!mMediaSessionInfoMap.Contains(*aBrowsingContextId)) {
+ LOG("The owner of audio focus doesn't have media session");
+ return ClearActiveMediaSessionContextIdIfNeeded();
+ }
+
+ // This owner has media session so it should become an active session context.
+ SetActiveMediaSessionContextId(*aBrowsingContextId);
+}
+
+void MediaStatusManager::SetActiveMediaSessionContextId(
+ uint64_t aBrowsingContextId) {
+ if (mActiveMediaSessionContextId &&
+ *mActiveMediaSessionContextId == aBrowsingContextId) {
+ LOG("Active session context %" PRIu64 " keeps unchanged",
+ *mActiveMediaSessionContextId);
+ return;
+ }
+ mActiveMediaSessionContextId = Some(aBrowsingContextId);
+ StoreMediaSessionContextIdOnWindowContext();
+ LOG("context %" PRIu64 " becomes active session context",
+ *mActiveMediaSessionContextId);
+ mMetadataChangedEvent.Notify(GetCurrentMediaMetadata());
+ mSupportedActionsChangedEvent.Notify(GetSupportedActions());
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "active-media-session-changed", nullptr);
+ }
+ }
+}
+
+void MediaStatusManager::ClearActiveMediaSessionContextIdIfNeeded() {
+ if (!mActiveMediaSessionContextId) {
+ return;
+ }
+ LOG("Clear active session context");
+ mActiveMediaSessionContextId.reset();
+ StoreMediaSessionContextIdOnWindowContext();
+ mMetadataChangedEvent.Notify(GetCurrentMediaMetadata());
+ mSupportedActionsChangedEvent.Notify(GetSupportedActions());
+ if (StaticPrefs::media_mediacontrol_testingevents_enabled()) {
+ if (nsCOMPtr<nsIObserverService> obs = services::GetObserverService()) {
+ obs->NotifyObservers(nullptr, "active-media-session-changed", nullptr);
+ }
+ }
+}
+
+void MediaStatusManager::StoreMediaSessionContextIdOnWindowContext() {
+ RefPtr<CanonicalBrowsingContext> bc =
+ CanonicalBrowsingContext::Get(mTopLevelBrowsingContextId);
+ if (bc && bc->GetTopWindowContext()) {
+ Unused << bc->GetTopWindowContext()->SetActiveMediaSessionContextId(
+ mActiveMediaSessionContextId);
+ }
+}
+
+bool MediaStatusManager::IsSessionOwningAudioFocus(
+ uint64_t aBrowsingContextId) const {
+ Maybe<uint64_t> audioFocusContextId =
+ mPlaybackStatusDelegate.GetAudioFocusOwnerContextId();
+ return audioFocusContextId ? *audioFocusContextId == aBrowsingContextId
+ : false;
+}
+
+MediaMetadataBase MediaStatusManager::CreateDefaultMetadata() const {
+ MediaMetadataBase metadata;
+ metadata.mTitle = GetDefaultTitle();
+ metadata.mArtwork.AppendElement()->mSrc = GetDefaultFaviconURL();
+
+ LOG("Default media metadata, title=%s, album src=%s",
+ NS_ConvertUTF16toUTF8(metadata.mTitle).get(),
+ NS_ConvertUTF16toUTF8(metadata.mArtwork[0].mSrc).get());
+ return metadata;
+}
+
+nsString MediaStatusManager::GetDefaultTitle() const {
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ nsString defaultTitle = service->GetFallbackTitle();
+
+ RefPtr<CanonicalBrowsingContext> bc =
+ CanonicalBrowsingContext::Get(mTopLevelBrowsingContextId);
+ if (!bc) {
+ return defaultTitle;
+ }
+
+ RefPtr<WindowGlobalParent> globalParent = bc->GetCurrentWindowGlobal();
+ if (!globalParent) {
+ return defaultTitle;
+ }
+
+ // The media metadata would be shown on the virtual controller interface. For
+ // example, on Android, the interface would be shown on both notification bar
+ // and lockscreen. Therefore, what information we provide via metadata is
+ // quite important, because if we're in private browsing, we don't want to
+ // expose details about what website the user is browsing on the lockscreen.
+ // Therefore, using the default title when in the private browsing or the
+ // document title is empty. Otherwise, use the document title.
+ nsString documentTitle;
+ if (!IsInPrivateBrowsing()) {
+ globalParent->GetDocumentTitle(documentTitle);
+ }
+ return documentTitle.IsEmpty() ? defaultTitle : documentTitle;
+}
+
+nsString MediaStatusManager::GetDefaultFaviconURL() const {
+#ifdef MOZ_PLACES
+ nsCOMPtr<nsIURI> faviconURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(faviconURI),
+ nsLiteralCString(FAVICON_DEFAULT_URL));
+ NS_ENSURE_SUCCESS(rv, u""_ns);
+
+ // Convert URI from `chrome://XXX` to `file://XXX` because we would like to
+ // let OS related frameworks, such as SMTC and MPRIS, handle this URL in order
+ // to show the icon on virtual controller interface.
+ nsCOMPtr<nsIChromeRegistry> regService = services::GetChromeRegistry();
+ if (!regService) {
+ return u""_ns;
+ }
+ nsCOMPtr<nsIURI> processedURI;
+ regService->ConvertChromeURL(faviconURI, getter_AddRefs(processedURI));
+
+ nsAutoCString spec;
+ if (NS_FAILED(processedURI->GetSpec(spec))) {
+ return u""_ns;
+ }
+ return NS_ConvertUTF8toUTF16(spec);
+#else
+ return u""_ns;
+#endif
+}
+
+void MediaStatusManager::SetDeclaredPlaybackState(
+ uint64_t aBrowsingContextId, MediaSessionPlaybackState aState) {
+ auto info = mMediaSessionInfoMap.Lookup(aBrowsingContextId);
+ if (!info) {
+ return;
+ }
+ LOG("SetDeclaredPlaybackState from %s to %s",
+ ToMediaSessionPlaybackStateStr(info->mDeclaredPlaybackState),
+ ToMediaSessionPlaybackStateStr(aState));
+ info->mDeclaredPlaybackState = aState;
+ UpdateActualPlaybackState();
+}
+
+MediaSessionPlaybackState MediaStatusManager::GetCurrentDeclaredPlaybackState()
+ const {
+ if (!mActiveMediaSessionContextId) {
+ return MediaSessionPlaybackState::None;
+ }
+ return mMediaSessionInfoMap.Get(*mActiveMediaSessionContextId)
+ .mDeclaredPlaybackState;
+}
+
+void MediaStatusManager::NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) {
+ LOG("UpdateMediaPlaybackState %s for context %" PRIu64,
+ ToMediaPlaybackStateStr(aState), aBrowsingContextId);
+ const bool oldPlaying = mPlaybackStatusDelegate.IsPlaying();
+ mPlaybackStatusDelegate.UpdateMediaPlaybackState(aBrowsingContextId, aState);
+
+ // Playback state doesn't change, we don't need to update the guessed playback
+ // state. This is used to prevent the state from changing from `none` to
+ // `paused` when receiving `MediaPlaybackState::eStarted`.
+ if (mPlaybackStatusDelegate.IsPlaying() == oldPlaying) {
+ return;
+ }
+ if (mPlaybackStatusDelegate.IsPlaying()) {
+ SetGuessedPlayState(MediaSessionPlaybackState::Playing);
+ } else {
+ SetGuessedPlayState(MediaSessionPlaybackState::Paused);
+ }
+}
+
+void MediaStatusManager::SetGuessedPlayState(MediaSessionPlaybackState aState) {
+ if (aState == mGuessedPlaybackState) {
+ return;
+ }
+ LOG("SetGuessedPlayState : '%s'", ToMediaSessionPlaybackStateStr(aState));
+ mGuessedPlaybackState = aState;
+ UpdateActualPlaybackState();
+}
+
+void MediaStatusManager::UpdateActualPlaybackState() {
+ // The way to compute the actual playback state is based on the spec.
+ // https://w3c.github.io/mediasession/#actual-playback-state
+ MediaSessionPlaybackState newState =
+ GetCurrentDeclaredPlaybackState() == MediaSessionPlaybackState::Playing
+ ? MediaSessionPlaybackState::Playing
+ : mGuessedPlaybackState;
+ if (mActualPlaybackState == newState) {
+ return;
+ }
+ mActualPlaybackState = newState;
+ LOG("UpdateActualPlaybackState : '%s'",
+ ToMediaSessionPlaybackStateStr(mActualPlaybackState));
+ mPlaybackStateChangedEvent.Notify(mActualPlaybackState);
+}
+
+void MediaStatusManager::EnableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) {
+ auto info = mMediaSessionInfoMap.Lookup(aBrowsingContextId);
+ if (!info) {
+ return;
+ }
+ if (info->IsActionSupported(aAction)) {
+ LOG("Action '%s' has already been enabled for context %" PRIu64,
+ ToMediaSessionActionStr(aAction), aBrowsingContextId);
+ return;
+ }
+ LOG("Enable action %s for context %" PRIu64, ToMediaSessionActionStr(aAction),
+ aBrowsingContextId);
+ info->EnableAction(aAction);
+ NotifySupportedKeysChangedIfNeeded(aBrowsingContextId);
+}
+
+void MediaStatusManager::DisableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) {
+ auto info = mMediaSessionInfoMap.Lookup(aBrowsingContextId);
+ if (!info) {
+ return;
+ }
+ if (!info->IsActionSupported(aAction)) {
+ LOG("Action '%s' hasn't been enabled yet for context %" PRIu64,
+ ToMediaSessionActionStr(aAction), aBrowsingContextId);
+ return;
+ }
+ LOG("Disable action %s for context %" PRIu64,
+ ToMediaSessionActionStr(aAction), aBrowsingContextId);
+ info->DisableAction(aAction);
+ NotifySupportedKeysChangedIfNeeded(aBrowsingContextId);
+}
+
+void MediaStatusManager::UpdatePositionState(uint64_t aBrowsingContextId,
+ const PositionState& aState) {
+ // The position state comes from non-active media session which we don't care.
+ if (!mActiveMediaSessionContextId ||
+ *mActiveMediaSessionContextId != aBrowsingContextId) {
+ return;
+ }
+ mPositionStateChangedEvent.Notify(aState);
+}
+
+void MediaStatusManager::NotifySupportedKeysChangedIfNeeded(
+ uint64_t aBrowsingContextId) {
+ // Only the active media session's supported actions would be shown in virtual
+ // control interface, so we only notify the event when supported actions
+ // change happens on the active media session.
+ if (!mActiveMediaSessionContextId ||
+ *mActiveMediaSessionContextId != aBrowsingContextId) {
+ return;
+ }
+ mSupportedActionsChangedEvent.Notify(GetSupportedActions());
+}
+
+CopyableTArray<MediaSessionAction> MediaStatusManager::GetSupportedActions()
+ const {
+ CopyableTArray<MediaSessionAction> supportedActions;
+ if (!mActiveMediaSessionContextId) {
+ return supportedActions;
+ }
+
+ MediaSessionInfo info =
+ mMediaSessionInfoMap.Get(*mActiveMediaSessionContextId);
+ const uint8_t actionNums = uint8_t(MediaSessionAction::EndGuard_);
+ for (uint8_t actionValue = 0; actionValue < actionNums; actionValue++) {
+ MediaSessionAction action = ConvertToMediaSessionAction(actionValue);
+ if (info.IsActionSupported(action)) {
+ supportedActions.AppendElement(action);
+ }
+ }
+ return supportedActions;
+}
+
+MediaMetadataBase MediaStatusManager::GetCurrentMediaMetadata() const {
+ // If we don't have active media session, active media session doesn't have
+ // media metadata, or we're in private browsing mode, then we should create a
+ // default metadata which is using website's title and favicon as title and
+ // artwork.
+ if (mActiveMediaSessionContextId && !IsInPrivateBrowsing()) {
+ MediaSessionInfo info =
+ mMediaSessionInfoMap.Get(*mActiveMediaSessionContextId);
+ if (!info.mMetadata) {
+ return CreateDefaultMetadata();
+ }
+ MediaMetadataBase& metadata = *(info.mMetadata);
+ FillMissingTitleAndArtworkIfNeeded(metadata);
+ return metadata;
+ }
+ return CreateDefaultMetadata();
+}
+
+void MediaStatusManager::FillMissingTitleAndArtworkIfNeeded(
+ MediaMetadataBase& aMetadata) const {
+ // If the metadata doesn't set its title and artwork properly, we would like
+ // to use default title and favicon instead in order to prevent showing
+ // nothing on the virtual control interface.
+ if (aMetadata.mTitle.IsEmpty()) {
+ aMetadata.mTitle = GetDefaultTitle();
+ }
+ if (aMetadata.mArtwork.IsEmpty()) {
+ aMetadata.mArtwork.AppendElement()->mSrc = GetDefaultFaviconURL();
+ }
+}
+
+bool MediaStatusManager::IsInPrivateBrowsing() const {
+ RefPtr<CanonicalBrowsingContext> bc =
+ CanonicalBrowsingContext::Get(mTopLevelBrowsingContextId);
+ if (!bc) {
+ return false;
+ }
+ RefPtr<Element> element = bc->GetEmbedderElement();
+ if (!element) {
+ return false;
+ }
+ return nsContentUtils::IsInPrivateBrowsing(element->OwnerDoc());
+}
+
+MediaSessionPlaybackState MediaStatusManager::PlaybackState() const {
+ return mActualPlaybackState;
+}
+
+bool MediaStatusManager::IsMediaAudible() const {
+ return mPlaybackStatusDelegate.IsAudible();
+}
+
+bool MediaStatusManager::IsMediaPlaying() const {
+ return mActualPlaybackState == MediaSessionPlaybackState::Playing;
+}
+
+bool MediaStatusManager::IsAnyMediaBeingControlled() const {
+ return mPlaybackStatusDelegate.IsAnyMediaBeingControlled();
+}
+
+void MediaStatusManager::NotifyPageTitleChanged() {
+ // If active media session has set non-empty metadata, then we would use that
+ // instead of using default metadata.
+ if (mActiveMediaSessionContextId &&
+ mMediaSessionInfoMap.Lookup(*mActiveMediaSessionContextId)->mMetadata) {
+ return;
+ }
+ // In private browsing mode, we won't show page title on default metadata so
+ // we don't need to update that.
+ if (IsInPrivateBrowsing()) {
+ return;
+ }
+ LOG("page title changed, update default metadata");
+ mMetadataChangedEvent.Notify(GetCurrentMediaMetadata());
+}
+
+} // namespace mozilla::dom
diff --git a/dom/media/mediacontrol/MediaStatusManager.h b/dom/media/mediacontrol/MediaStatusManager.h
new file mode 100644
index 0000000000..24247d119d
--- /dev/null
+++ b/dom/media/mediacontrol/MediaStatusManager.h
@@ -0,0 +1,276 @@
+/* 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_MEDIACONTROL_MEDIASTATUSMANAGER_H_
+#define DOM_MEDIA_MEDIACONTROL_MEDIASTATUSMANAGER_H_
+
+#include "MediaControlKeySource.h"
+#include "MediaEventSource.h"
+#include "MediaPlaybackStatus.h"
+#include "mozilla/dom/MediaMetadata.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+#include "mozilla/Maybe.h"
+#include "nsTHashMap.h"
+#include "nsISupportsImpl.h"
+
+namespace mozilla::dom {
+
+class MediaSessionInfo {
+ public:
+ MediaSessionInfo() = default;
+
+ explicit MediaSessionInfo(MediaMetadataBase& aMetadata) {
+ mMetadata.emplace(aMetadata);
+ }
+
+ MediaSessionInfo(MediaMetadataBase& aMetadata,
+ MediaSessionPlaybackState& aState) {
+ mMetadata.emplace(aMetadata);
+ mDeclaredPlaybackState = aState;
+ }
+
+ static MediaSessionInfo EmptyInfo() { return MediaSessionInfo(); }
+
+ static uint32_t GetActionBitMask(MediaSessionAction aAction) {
+ return 1 << static_cast<uint8_t>(aAction);
+ }
+
+ void EnableAction(MediaSessionAction aAction) {
+ mSupportedActions |= GetActionBitMask(aAction);
+ }
+
+ void DisableAction(MediaSessionAction aAction) {
+ mSupportedActions &= ~GetActionBitMask(aAction);
+ }
+
+ bool IsActionSupported(MediaSessionAction aAction) const {
+ return mSupportedActions & GetActionBitMask(aAction);
+ }
+
+ // These attributes are all propagated from the media session in the content
+ // process.
+ Maybe<MediaMetadataBase> mMetadata;
+ MediaSessionPlaybackState mDeclaredPlaybackState =
+ MediaSessionPlaybackState::None;
+ // Use bitwise to store the supported actions.
+ uint32_t mSupportedActions = 0;
+};
+
+/**
+ * IMediaInfoUpdater is an interface which provides methods to update the media
+ * related information that happens in the content process.
+ */
+class IMediaInfoUpdater {
+ NS_INLINE_DECL_PURE_VIRTUAL_REFCOUNTING
+
+ // Use this method to update controlled media's playback state and the
+ // browsing context where controlled media exists. When notifying the state
+ // change, we MUST follow the following rules.
+ // (1) `eStart` MUST be the first state and `eStop` MUST be the last state
+ // (2) Do not notify same state again
+ // (3) `ePaused` can only be notified after notifying `ePlayed`.
+ virtual void NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) = 0;
+
+ // Use this method to update the audible state of controlled media, and MUST
+ // follow the following rules in which `audible` and `inaudible` should be a
+ // pair. `inaudible` should always be notified after `audible`. When audible
+ // media paused, `inaudible` should be notified
+ // Eg. (O) `audible` -> `inaudible` -> `audible` -> `inaudible`
+ // (X) `inaudible` -> `audible` [notify `inaudible` before `audible`]
+ // (X) `audible` -> `audible` [notify `audible` twice]
+ // (X) `audible` -> (media pauses) [forgot to notify `inaudible`]
+ virtual void NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) = 0;
+
+ // Use this method to update media session's declared playback state for the
+ // specific media session.
+ virtual void SetDeclaredPlaybackState(uint64_t aBrowsingContextId,
+ MediaSessionPlaybackState aState) = 0;
+
+ // Use these methods to update controller's media session list. We'd use it
+ // when media session is created/destroyed in the content process.
+ virtual void NotifySessionCreated(uint64_t aBrowsingContextId) = 0;
+ virtual void NotifySessionDestroyed(uint64_t aBrowsingContextId) = 0;
+
+ // Use this method to update the metadata for the specific media session.
+ virtual void UpdateMetadata(uint64_t aBrowsingContextId,
+ const Maybe<MediaMetadataBase>& aMetadata) = 0;
+
+ // Use this method to update the picture in picture mode state of controlled
+ // media, and it's safe to notify same state again.
+ virtual void SetIsInPictureInPictureMode(uint64_t aBrowsingContextId,
+ bool aIsInPictureInPictureMode) = 0;
+
+ // Use these methods to update the supported media session action for the
+ // specific media session. For a media session from a given browsing context,
+ // do not re-enable the same action, or disable the action without enabling it
+ // before.
+ virtual void EnableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) = 0;
+ virtual void DisableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) = 0;
+
+ // Use this method when media enters or leaves the fullscreen.
+ virtual void NotifyMediaFullScreenState(uint64_t aBrowsingContextId,
+ bool aIsInFullScreen) = 0;
+
+ // Use this method when media session update its position state.
+ virtual void UpdatePositionState(uint64_t aBrowsingContextId,
+ const PositionState& aState) = 0;
+};
+
+/**
+ * MediaStatusManager would decide the media related status which can represents
+ * the whole tab. The status includes the playback status, tab's metadata and
+ * the active media session ID if it exists.
+ *
+ * We would use `IMediaInfoUpdater` methods to update the media playback related
+ * information and then use `MediaPlaybackStatus` to determine the final
+ * playback state.
+ *
+ * The metadata would be the one from the active media session, or the default
+ * one. This class would determine which media session is an active media
+ * session [1] whithin a tab. It tracks all alive media sessions within a tab
+ * and store their metadata which could be used to show on the virtual media
+ * control interface. In addition, we can use it to get the current media
+ * metadata even if there is no media session existing. However, the meaning of
+ * active media session here is not equal to the definition from the spec [1].
+ * We just choose the session which is the active one inside the tab, the global
+ * active media session among different tabs would be the one inside the main
+ * controller which is determined by MediaControlService.
+ *
+ * [1] https://w3c.github.io/mediasession/#active-media-session
+ */
+class MediaStatusManager : public IMediaInfoUpdater {
+ public:
+ explicit MediaStatusManager(uint64_t aBrowsingContextId);
+
+ // IMediaInfoUpdater's methods
+ void NotifyMediaPlaybackChanged(uint64_t aBrowsingContextId,
+ MediaPlaybackState aState) override;
+ void NotifyMediaAudibleChanged(uint64_t aBrowsingContextId,
+ MediaAudibleState aState) override;
+ void SetDeclaredPlaybackState(uint64_t aSessionContextId,
+ MediaSessionPlaybackState aState) override;
+ void NotifySessionCreated(uint64_t aSessionContextId) override;
+ void NotifySessionDestroyed(uint64_t aSessionContextId) override;
+ void UpdateMetadata(uint64_t aSessionContextId,
+ const Maybe<MediaMetadataBase>& aMetadata) override;
+ void EnableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) override;
+ void DisableAction(uint64_t aBrowsingContextId,
+ MediaSessionAction aAction) override;
+ void UpdatePositionState(uint64_t aBrowsingContextId,
+ const PositionState& aState) override;
+
+ // Return active media session's metadata if active media session exists and
+ // it has already set its metadata. Otherwise, return default media metadata
+ // which is based on website's title and favicon.
+ MediaMetadataBase GetCurrentMediaMetadata() const;
+
+ bool IsMediaAudible() const;
+ bool IsMediaPlaying() const;
+ bool IsAnyMediaBeingControlled() const;
+
+ // These events would be notified when the active media session's certain
+ // property changes.
+ MediaEventSource<MediaMetadataBase>& MetadataChangedEvent() {
+ return mMetadataChangedEvent;
+ }
+
+ MediaEventSource<PositionState>& PositionChangedEvent() {
+ return mPositionStateChangedEvent;
+ }
+
+ MediaEventSource<MediaSessionPlaybackState>& PlaybackChangedEvent() {
+ return mPlaybackStateChangedEvent;
+ }
+
+ // Return the actual playback state.
+ MediaSessionPlaybackState PlaybackState() const;
+
+ // When page title changes, we might need to update it on the default
+ // metadata as well.
+ void NotifyPageTitleChanged();
+
+ protected:
+ ~MediaStatusManager() = default;
+
+ // This event would be notified when the active media session changes its
+ // supported actions.
+ MediaEventSource<nsTArray<MediaSessionAction>>&
+ SupportedActionsChangedEvent() {
+ return mSupportedActionsChangedEvent;
+ }
+
+ uint64_t mTopLevelBrowsingContextId;
+
+ // Within a tab, the Id of the browsing context which has already created a
+ // media session and owns the audio focus within a tab.
+ Maybe<uint64_t> mActiveMediaSessionContextId;
+
+ void ClearActiveMediaSessionContextIdIfNeeded();
+
+ private:
+ nsString GetDefaultFaviconURL() const;
+ nsString GetDefaultTitle() const;
+ MediaMetadataBase CreateDefaultMetadata() const;
+ bool IsInPrivateBrowsing() const;
+ void FillMissingTitleAndArtworkIfNeeded(MediaMetadataBase& aMetadata) const;
+
+ bool IsSessionOwningAudioFocus(uint64_t aBrowsingContextId) const;
+ void SetActiveMediaSessionContextId(uint64_t aBrowsingContextId);
+ void HandleAudioFocusOwnerChanged(Maybe<uint64_t>& aBrowsingContextId);
+
+ void NotifySupportedKeysChangedIfNeeded(uint64_t aBrowsingContextId);
+
+ // Return a copyable array filled with the supported media session actions.
+ // Use copyable array so that we can use the result as a parameter for the
+ // media event.
+ CopyableTArray<MediaSessionAction> GetSupportedActions() const;
+
+ void StoreMediaSessionContextIdOnWindowContext();
+
+ // When the amount of playing media changes, we would use this function to
+ // update the guessed playback state.
+ void SetGuessedPlayState(MediaSessionPlaybackState aState);
+
+ // Whenever the declared playback state or the guessed playback state changes,
+ // we should recompute actual playback state to know if we need to update the
+ // virtual control interface.
+ void UpdateActualPlaybackState();
+
+ // Return the active media session's declared playback state. If the active
+ // media session doesn't exist, return 'None' instead.
+ MediaSessionPlaybackState GetCurrentDeclaredPlaybackState() const;
+
+ // This state can match to the `guessed playback state` in the spec [1], it
+ // indicates if we have any media element playing within the tab which this
+ // controller belongs to. But currently we only take media elements into
+ // account, which is different from the way the spec recommends. In addition,
+ // We don't support web audio and plugin and not consider audible state of
+ // media.
+ // [1] https://w3c.github.io/mediasession/#guessed-playback-state
+ MediaSessionPlaybackState mGuessedPlaybackState =
+ MediaSessionPlaybackState::None;
+
+ // This playback state would be the final playback which can be used to know
+ // if the controller is playing or not.
+ // https://w3c.github.io/mediasession/#actual-playback-state
+ MediaSessionPlaybackState mActualPlaybackState =
+ MediaSessionPlaybackState::None;
+
+ nsTHashMap<nsUint64HashKey, MediaSessionInfo> mMediaSessionInfoMap;
+ MediaEventProducer<MediaMetadataBase> mMetadataChangedEvent;
+ MediaEventProducer<nsTArray<MediaSessionAction>>
+ mSupportedActionsChangedEvent;
+ MediaEventProducer<PositionState> mPositionStateChangedEvent;
+ MediaEventProducer<MediaSessionPlaybackState> mPlaybackStateChangedEvent;
+ MediaPlaybackStatus mPlaybackStatusDelegate;
+};
+
+} // namespace mozilla::dom
+
+#endif // DOM_MEDIA_MEDIACONTROL_MEDIASTATUSMANAGER_H_
diff --git a/dom/media/mediacontrol/PositionStateEvent.h b/dom/media/mediacontrol/PositionStateEvent.h
new file mode 100644
index 0000000000..5d6a5ebb78
--- /dev/null
+++ b/dom/media/mediacontrol/PositionStateEvent.h
@@ -0,0 +1,60 @@
+/* -*- 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/. */
+
+/* THIS FILE IS AUTOGENERATED FROM PositionStateEvent.webidl BY Codegen.py - DO
+ * NOT EDIT */
+
+#ifndef mozilla_dom_PositionStateEvent_h
+#define mozilla_dom_PositionStateEvent_h
+
+#include "js/RootingAPI.h"
+#include "mozilla/AlreadyAddRefed.h"
+#include "mozilla/dom/Event.h"
+#include "nsISupports.h"
+#include "nsStringFwd.h"
+
+struct JSContext;
+namespace mozilla {
+namespace dom {
+struct PositionStateEventInit;
+
+class PositionStateEvent : public Event {
+ public:
+ NS_INLINE_DECL_REFCOUNTING_INHERITED(PositionStateEvent, Event)
+
+ protected:
+ virtual ~PositionStateEvent();
+ explicit PositionStateEvent(mozilla::dom::EventTarget* aOwner);
+
+ double mDuration;
+ double mPlaybackRate;
+ double mPosition;
+
+ public:
+ PositionStateEvent* AsPositionStateEvent() override;
+
+ JSObject* WrapObjectInternal(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ static already_AddRefed<PositionStateEvent> Constructor(
+ mozilla::dom::EventTarget* aOwner, const nsAString& aType,
+ const PositionStateEventInit& aEventInitDict);
+
+ static already_AddRefed<PositionStateEvent> Constructor(
+ const GlobalObject& aGlobal, const nsAString& aType,
+ const PositionStateEventInit& aEventInitDict);
+
+ double Duration() const;
+
+ double PlaybackRate() const;
+
+ double Position() const;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PositionStateEvent_h
diff --git a/dom/media/mediacontrol/moz.build b/dom/media/mediacontrol/moz.build
new file mode 100644
index 0000000000..54b3c40a36
--- /dev/null
+++ b/dom/media/mediacontrol/moz.build
@@ -0,0 +1,43 @@
+# 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/.
+
+EXPORTS.mozilla.dom += [
+ "AudioFocusManager.h",
+ "ContentMediaController.h",
+ "ContentPlaybackController.h",
+ "FetchImageHelper.h",
+ "MediaControlKeyManager.h",
+ "MediaControlKeySource.h",
+ "MediaController.h",
+ "MediaControlService.h",
+ "MediaControlUtils.h",
+ "MediaPlaybackStatus.h",
+ "MediaStatusManager.h",
+]
+
+EXPORTS.ipc += [
+ "MediaControlIPC.h",
+]
+
+UNIFIED_SOURCES += [
+ "AudioFocusManager.cpp",
+ "ContentMediaController.cpp",
+ "ContentPlaybackController.cpp",
+ "FetchImageHelper.cpp",
+ "MediaControlKeyManager.cpp",
+ "MediaControlKeySource.cpp",
+ "MediaController.cpp",
+ "MediaControlService.cpp",
+ "MediaControlUtils.cpp",
+ "MediaPlaybackStatus.cpp",
+ "MediaStatusManager.cpp",
+]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+if CONFIG["ENABLE_TESTS"]:
+ DIRS += ["tests/gtest"]
+
+FINAL_LIBRARY = "xul"
diff --git a/dom/media/mediacontrol/tests/browser/browser.ini b/dom/media/mediacontrol/tests/browser/browser.ini
new file mode 100644
index 0000000000..d6338e598b
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser.ini
@@ -0,0 +1,53 @@
+[DEFAULT]
+subsuite = media-bc
+tags = mediacontrol
+skip-if = os == "linux" # Bug 1673527
+support-files =
+ file_autoplay.html
+ file_audio_and_inaudible_media.html
+ file_empty_title.html
+ file_error_media.html
+ file_iframe_media.html
+ file_main_frame_with_multiple_child_session_frames.html
+ file_multiple_audible_media.html
+ file_muted_autoplay.html
+ file_no_src_media.html
+ file_non_autoplay.html
+ file_non_eligible_media.html
+ file_non_looping_media.html
+ head.js
+ ../../../test/bogus.ogv
+ ../../../test/gizmo.mp4
+ ../../../test/gizmo-noaudio.webm
+ ../../../test/gizmo-short.mp4
+ !/toolkit/components/pictureinpicture/tests/head.js
+ ../../../../../toolkit/content/tests/browser/silentAudioTrack.webm
+
+[browser_audio_focus_management.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_control_page_with_audible_and_inaudible_media.js]
+[browser_default_action_handler.js]
+[browser_only_control_non_real_time_media.js]
+[browser_media_control_audio_focus_within_a_page.js]
+[browser_media_control_before_media_starts.js]
+[browser_media_control_captured_audio.js]
+[browser_media_control_metadata.js]
+[browser_media_control_keys_event.js]
+[browser_media_control_main_controller.js]
+[browser_media_control_non_eligible_media.js]
+skip-if =
+ verify && os == 'mac' # bug 1673509
+[browser_media_control_playback_state.js]
+[browser_media_control_position_state.js]
+[browser_media_control_seekto.js]
+[browser_media_control_supported_keys.js]
+[browser_media_control_stop_timer.js]
+[browser_nosrc_and_error_media.js]
+skip-if =
+ verify && os == 'mac' # bug 1673509
+[browser_seek_captured_audio.js]
+[browser_stop_control_after_media_reaches_to_end.js]
+[browser_suspend_inactive_tab.js]
+[browser_remove_controllable_media_for_active_controller.js]
+[browser_resume_latest_paused_media.js]
diff --git a/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js b/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js
new file mode 100644
index 0000000000..980281243d
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_audio_focus_management.js
@@ -0,0 +1,179 @@
+const PAGE_AUDIBLE =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_autoplay.html";
+const PAGE_INAUDIBLE =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html";
+
+const testVideoId = "autoplay";
+
+/**
+ * These tests are used to ensure that the audio focus management works correctly
+ * amongs different tabs no matter the pref is on or off. If the pref is on,
+ * there is only one tab which is allowed to play audio at a time, the last tab
+ * starting audio will immediately stop other tabs which own audio focus. But
+ * notice that playing inaudible media won't gain audio focus. If the pref is
+ * off, all audible tabs can own audio focus at the same time without
+ * interfering each others.
+ */
+add_task(async function testDisableAudioFocusManagement() {
+ await switchAudioFocusManagerment(false);
+
+ info(`open audible autoplay media in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(`open same page on another tab, which shouldn't cause audio competing`);
+ const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ info(`media in tab1 should be playing still`);
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(`remove tabs`);
+ await clearTabsAndResetPref([tab1, tab2]);
+});
+
+add_task(async function testEnableAudioFocusManagement() {
+ await switchAudioFocusManagerment(true);
+
+ info(`open audible autoplay media in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(`open same page on another tab, which should cause audio competing`);
+ const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ info(`media in tab1 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId);
+
+ info(`remove tabs`);
+ await clearTabsAndResetPref([tab1, tab2]);
+});
+
+add_task(async function testCheckAudioCompetingMultipleTimes() {
+ await switchAudioFocusManagerment(true);
+
+ info(`open audible autoplay media in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(`open same page on another tab, which should cause audio competing`);
+ const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ info(`media in tab1 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId);
+
+ info(`play media in tab1 again`);
+ await playMedia(tab1);
+
+ info(`media in tab2 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab2, testVideoId);
+
+ info(`play media in tab2 again`);
+ await playMedia(tab2);
+
+ info(`media in tab1 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId);
+
+ info(`remove tabs`);
+ await clearTabsAndResetPref([tab1, tab2]);
+});
+
+add_task(async function testMutedMediaWontInvolveAudioCompeting() {
+ await switchAudioFocusManagerment(true);
+
+ info(`open audible autoplay media in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(
+ `open inaudible media page on another tab, which shouldn't cause audio competing`
+ );
+ const tab2 = await createLoadedTabWrapper(PAGE_INAUDIBLE, {
+ needCheck: false,
+ });
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ info(`media in tab1 should be playing still`);
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(
+ `open audible media page on the third tab, which should cause audio competing`
+ );
+ const tab3 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab3, testVideoId);
+
+ info(`media in tab1 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId);
+
+ info(`media in tab2 should not be affected because it's inaudible.`);
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ info(`remove tabs`);
+ await clearTabsAndResetPref([tab1, tab2, tab3]);
+});
+
+add_task(async function testStopMultipleTabsWhenSwitchingPrefDynamically() {
+ await switchAudioFocusManagerment(false);
+
+ info(`open audible autoplay media in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab1, testVideoId);
+
+ info(`open same page on another tab, which shouldn't cause audio competing`);
+ const tab2 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab2, testVideoId);
+
+ await switchAudioFocusManagerment(true);
+
+ info(`open same page on the third tab, which should cause audio competing`);
+ const tab3 = await createLoadedTabWrapper(PAGE_AUDIBLE, { needCheck: false });
+ await checkOrWaitUntilMediaStartedPlaying(tab3, testVideoId);
+
+ info(`media in tab1 and tab2 should be stopped`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab1, testVideoId);
+ await checkOrWaitUntilMediaStoppedPlaying(tab2, testVideoId);
+
+ info(`remove tabs`);
+ await clearTabsAndResetPref([tab1, tab2, tab3]);
+});
+
+/**
+ * The following are helper funcions.
+ */
+async function switchAudioFocusManagerment(enable) {
+ const state = enable ? "Enable" : "Disable";
+ info(`${state} audio focus management`);
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.audioFocus.management", enable]],
+ });
+}
+
+async function playMedia(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ return new Promise(resolve => {
+ const video = content.document.getElementById("autoplay");
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+
+ ok(video.paused, `media has not started yet`);
+ info(`wait until media starts playing`);
+ video.play();
+ video.onplaying = () => {
+ video.onplaying = null;
+ ok(true, `media started playing`);
+ resolve();
+ };
+ });
+ });
+}
+
+async function clearTabsAndResetPref(tabs) {
+ info(`clear tabs and reset pref`);
+ for (let tab of tabs) {
+ await tab.close();
+ }
+ await switchAudioFocusManagerment(false);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js b/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js
new file mode 100644
index 0000000000..6d9bc38b04
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_control_page_with_audible_and_inaudible_media.js
@@ -0,0 +1,94 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * When a page has audible media and inaudible media playing at the same time,
+ * only audible media should be controlled by media control keys. However, once
+ * inaudible media becomes audible, then it should be able to be controlled.
+ */
+add_task(async function testSetPositionState() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`play video1 (audible) and video2 (inaudible)`);
+ await playBothAudibleAndInaudibleMedia(tab);
+
+ info(`pressing 'pause' should only affect video1 (audible)`);
+ await generateMediaControlKeyEvent("pause");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: false,
+ });
+
+ info(`make video2 become audible, then it would be able to be controlled`);
+ await unmuteInaudibleMedia(tab);
+
+ info(`pressing 'pause' should affect video2 (audible`);
+ await generateMediaControlKeyEvent("pause");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: true,
+ });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+async function playBothAudibleAndInaudibleMedia(tab) {
+ const playbackStateChangedPromise = waitUntilDisplayedPlaybackChanged();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const videos = content.document.getElementsByTagName("video");
+ let promises = [];
+ for (let video of videos) {
+ info(`play ${video.id} video`);
+ promises.push(video.play());
+ }
+ return Promise.all(promises);
+ });
+ await playbackStateChangedPromise;
+}
+
+function checkMediaPausedState(
+ tab,
+ { shouldVideo1BePaused, shouldVideo2BePaused }
+) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [shouldVideo1BePaused, shouldVideo2BePaused],
+ (shouldVideo1BePaused, shouldVideo2BePaused) => {
+ const video1 = content.document.getElementById("video1");
+ const video2 = content.document.getElementById("video2");
+ is(
+ video1.paused,
+ shouldVideo1BePaused,
+ "Correct paused state for video1"
+ );
+ is(
+ video2.paused,
+ shouldVideo2BePaused,
+ "Correct paused state for video2"
+ );
+ }
+ );
+}
+
+function unmuteInaudibleMedia(tab) {
+ const unmutePromise = SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const video2 = content.document.getElementById("video2");
+ video2.muted = false;
+ });
+ // Inaudible media was not controllable, so it won't affect the controller's
+ // playback state. However, when it becomes audible, which means being able to
+ // be controlled by media controller, it would make the playback state chanege
+ // to `playing` because now we have an audible playinng media in the page.
+ return Promise.all([unmutePromise, waitUntilDisplayedPlaybackChanged()]);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js b/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js
new file mode 100644
index 0000000000..c33c08b0c2
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_default_action_handler.js
@@ -0,0 +1,422 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const PAGE2_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html";
+const IFRAME_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+const CORS_IFRAME_URL =
+ "https://example.org/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+const CORS_IFRAME2_URL =
+ "https://test1.example.org/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+const videoId = "video";
+
+/**
+ * This test is used to check the scenario when we should use the customized
+ * action handler and the the default action handler (play/pause/stop).
+ * If a frame (DOM Window, it could be main frame or an iframe) has active media
+ * session, then it should use the customized action handler it it has one.
+ * Otherwise, the default action handler should be used.
+ */
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.media.mediasession.enabled", true],
+ ["media.mediacontrol.testingevents.enabled", true],
+ ],
+ });
+});
+
+add_task(async function triggerDefaultActionHandler() {
+ // Default handler should be triggered no matter if media session exists or not.
+ const kCreateMediaSession = [true, false];
+ for (const shouldCreateSession of kCreateMediaSession) {
+ info(`open page and start media`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ await playMedia(tab, videoId);
+
+ if (shouldCreateSession) {
+ info(
+ `media has started, so created session should become active session`
+ );
+ await Promise.all([
+ waitUntilActiveMediaSessionChanged(),
+ createMediaSession(tab),
+ ]);
+ }
+
+ info(`test 'pause' action`);
+ await simulateMediaAction(tab, "pause");
+
+ info(`default action handler should pause media`);
+ await checkOrWaitUntilMediaPauses(tab, { videoId });
+
+ info(`test 'play' action`);
+ await simulateMediaAction(tab, "play");
+
+ info(`default action handler should resume media`);
+ await checkOrWaitUntilMediaPlays(tab, { videoId });
+
+ info(`test 'stop' action`);
+ await simulateMediaAction(tab, "stop");
+
+ info(`default action handler should pause media`);
+ await checkOrWaitUntilMediaPauses(tab, { videoId });
+
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ ok(
+ !controller.isActive,
+ `controller should be deactivated after receiving stop`
+ );
+
+ info(`remove tab`);
+ await tab.close();
+ }
+});
+
+add_task(async function triggerNonDefaultHandlerWhenSetCustomizedHandler() {
+ info(`open page and start media`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ await Promise.all([
+ new Promise(r => (tab.controller.onactivated = r)),
+ startMedia(tab, { videoId }),
+ ]);
+
+ const kActions = ["play", "pause", "stop"];
+ for (const action of kActions) {
+ info(`set action handler for '${action}'`);
+ await setActionHandler(tab, action);
+
+ info(`press '${action}' should trigger action handler (not a default one)`);
+ await simulateMediaAction(tab, action);
+ await waitUntilActionHandlerIsTriggered(tab, action);
+
+ info(`action handler doesn't pause media, media should keep playing`);
+ await checkOrWaitUntilMediaPlays(tab, { videoId });
+ }
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(
+ async function triggerDefaultHandlerToPausePlaybackOnInactiveSession() {
+ const kIframeUrls = [IFRAME_URL, CORS_IFRAME_URL];
+ for (const url of kIframeUrls) {
+ const kActions = ["play", "pause", "stop"];
+ for (const action of kActions) {
+ info(`open page and load iframe`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ const frameId = "iframe";
+ await loadIframe(tab, frameId, url);
+
+ info(`start media from iframe would make it become active session`);
+ await Promise.all([
+ new Promise(r => (tab.controller.onactivated = r)),
+ startMedia(tab, { frameId }),
+ ]);
+
+ info(`press '${action}' should trigger iframe's action handler`);
+ await setActionHandler(tab, action, frameId);
+ await simulateMediaAction(tab, action);
+ await waitUntilActionHandlerIsTriggered(tab, action, frameId);
+
+ info(`start media from main frame so iframe would become inactive`);
+ // When action is `play`, controller is already playing, because above
+ // code won't pause media. So we need to wait for the active session
+ // changed to ensure the following tests can be executed on the right
+ // browsing context.
+ let waitForControllerStatusChanged =
+ action == "play"
+ ? waitUntilActiveMediaSessionChanged()
+ : ensureControllerIsPlaying(tab.controller);
+ await Promise.all([
+ waitForControllerStatusChanged,
+ startMedia(tab, { videoId }),
+ ]);
+
+ if (action == "play") {
+ info(`pause media first in order to test 'play'`);
+ await pauseAllMedia(tab);
+
+ info(
+ `press '${action}' would trigger default andler on main frame because it doesn't set action handler`
+ );
+ await simulateMediaAction(tab, action);
+ await checkOrWaitUntilMediaPlays(tab, { videoId });
+
+ info(
+ `default handler should also be triggered on inactive iframe, which would resume media`
+ );
+ await checkOrWaitUntilMediaPlays(tab, { frameId });
+ } else {
+ info(
+ `press '${action}' would trigger default andler on main frame because it doesn't set action handler`
+ );
+ await simulateMediaAction(tab, action);
+ await checkOrWaitUntilMediaPauses(tab, { videoId });
+
+ info(
+ `default handler should also be triggered on inactive iframe, which would pause media`
+ );
+ await checkOrWaitUntilMediaPauses(tab, { frameId });
+ }
+
+ info(`remove tab`);
+ await tab.close();
+ }
+ }
+ }
+);
+
+add_task(async function onlyResumeActiveMediaSession() {
+ info(`open page and load iframes`);
+ const tab = await createLoadedTabWrapper(PAGE2_URL);
+ const frame1Id = "frame1";
+ const frame2Id = "frame2";
+ await loadIframe(tab, frame1Id, CORS_IFRAME_URL);
+ await loadIframe(tab, frame2Id, CORS_IFRAME2_URL);
+
+ info(`start media from iframe1 would make it become active session`);
+ await createMediaSession(tab, frame1Id);
+ await Promise.all([
+ waitUntilActiveMediaSessionChanged(),
+ startMedia(tab, { frameId: frame1Id }),
+ ]);
+
+ info(`start media from iframe2 would make it become active session`);
+ await createMediaSession(tab, frame2Id);
+ await Promise.all([
+ waitUntilActiveMediaSessionChanged(),
+ startMedia(tab, { frameId: frame2Id }),
+ ]);
+
+ info(`press 'pause' should pause both iframes`);
+ await simulateMediaAction(tab, "pause");
+ await checkOrWaitUntilMediaPauses(tab, { frameId: frame1Id });
+ await checkOrWaitUntilMediaPauses(tab, { frameId: frame2Id });
+
+ info(
+ `press 'play' should only resume iframe2 which has active media session`
+ );
+ await simulateMediaAction(tab, "play");
+ await checkOrWaitUntilMediaPauses(tab, { frameId: frame1Id });
+ await checkOrWaitUntilMediaPlays(tab, { frameId: frame2Id });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function startMedia(tab, { videoId, frameId }) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [videoId, frameId],
+ (videoId, frameId) => {
+ if (frameId) {
+ return content.messageHelper(
+ content.document.getElementById(frameId),
+ "play",
+ "played"
+ );
+ }
+ return content.document.getElementById(videoId).play();
+ }
+ );
+}
+
+function pauseAllMedia(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ await content.messageHelper(
+ content.document.getElementById("iframe"),
+ "pause",
+ "paused"
+ );
+ const videos = content.document.getElementsByTagName("video");
+ for (let video of videos) {
+ video.pause();
+ }
+ });
+}
+
+function createMediaSession(tab, frameId = null) {
+ info(`create media session`);
+ return SpecialPowers.spawn(tab.linkedBrowser, [frameId], async frameId => {
+ if (frameId) {
+ await content.messageHelper(
+ content.document.getElementById(frameId),
+ "create-media-session",
+ "created-media-session"
+ );
+ return;
+ }
+ // simply calling a media session would create an instance.
+ content.navigator.mediaSession;
+ });
+}
+
+function checkOrWaitUntilMediaPauses(tab, { videoId, frameId }) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [videoId, frameId],
+ (videoId, frameId) => {
+ if (frameId) {
+ return content.messageHelper(
+ content.document.getElementById(frameId),
+ "check-pause",
+ "checked-pause"
+ );
+ }
+ return new Promise(r => {
+ const video = content.document.getElementById(videoId);
+ if (video.paused) {
+ ok(true, `media stopped playing`);
+ r();
+ } else {
+ info(`wait until media stops playing`);
+ video.onpause = () => {
+ video.onpause = null;
+ ok(true, `media stopped playing`);
+ r();
+ };
+ }
+ });
+ }
+ );
+}
+
+function checkOrWaitUntilMediaPlays(tab, { videoId, frameId }) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [videoId, frameId],
+ (videoId, frameId) => {
+ if (frameId) {
+ return content.messageHelper(
+ content.document.getElementById(frameId),
+ "check-playing",
+ "checked-playing"
+ );
+ }
+ return new Promise(r => {
+ const video = content.document.getElementById(videoId);
+ if (!video.paused) {
+ ok(true, `media is playing`);
+ r();
+ } else {
+ info(`wait until media starts playing`);
+ video.onplay = () => {
+ video.onplay = null;
+ ok(true, `media starts playing`);
+ r();
+ };
+ }
+ });
+ }
+ );
+}
+
+function setActionHandler(tab, action, frameId = null) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [action, frameId],
+ async (action, frameId) => {
+ if (frameId) {
+ await content.messageHelper(
+ content.document.getElementById(frameId),
+ {
+ cmd: "setActionHandler",
+ action,
+ },
+ "setActionHandler-done"
+ );
+ return;
+ }
+ // Create this on the first function call
+ if (content.actionHandlerPromises === undefined) {
+ content.actionHandlerPromises = {};
+ }
+ content.actionHandlerPromises[action] = new Promise(r => {
+ content.navigator.mediaSession.setActionHandler(action, () => {
+ info(`receive ${action}`);
+ r();
+ });
+ });
+ }
+ );
+}
+
+async function waitUntilActionHandlerIsTriggered(tab, action, frameId = null) {
+ info(`wait until '${action}' action handler is triggered`);
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [action, frameId],
+ (action, frameId) => {
+ if (frameId) {
+ return content.messageHelper(
+ content.document.getElementById(frameId),
+ {
+ cmd: "checkActionHandler",
+ action,
+ },
+ "checkActionHandler-done"
+ );
+ }
+ const actionTriggerPromise = content.actionHandlerPromises[action];
+ ok(actionTriggerPromise, `Has created promise for ${action}`);
+ return actionTriggerPromise;
+ }
+ );
+}
+
+async function simulateMediaAction(tab, action) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ if (!controller.isActive) {
+ await new Promise(r => (controller.onactivated = r));
+ }
+ MediaControlService.generateMediaControlKey(action);
+}
+
+function loadIframe(tab, iframeId, url) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [iframeId, url],
+ async (iframeId, url) => {
+ const iframe = content.document.getElementById(iframeId);
+ info(`load iframe with url '${url}'`);
+ iframe.src = url;
+ await new Promise(r => (iframe.onload = r));
+ // create a helper to simplify communication process with iframe
+ content.messageHelper = (target, sentMessage, expectedResponse) => {
+ target.contentWindow.postMessage(sentMessage, "*");
+ return new Promise(r => {
+ content.onmessage = event => {
+ if (event.data == expectedResponse) {
+ ok(true, `Received response ${expectedResponse}`);
+ content.onmessage = null;
+ r();
+ }
+ };
+ });
+ };
+ }
+ );
+}
+
+function waitUntilActiveMediaSessionChanged() {
+ return BrowserUtils.promiseObserved("active-media-session-changed");
+}
+
+function ensureControllerIsPlaying(controller) {
+ return new Promise(r => {
+ if (controller.isPlaying) {
+ r();
+ return;
+ }
+ controller.onplaybackstatechange = () => {
+ if (controller.isPlaying) {
+ r();
+ }
+ };
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js b/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js
new file mode 100644
index 0000000000..5db37059dd
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_audio_focus_within_a_page.js
@@ -0,0 +1,358 @@
+const mainPageURL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html";
+const frameURL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+
+const frame1 = "frame1";
+const frame2 = "frame2";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test is used to check the behaviors when we play media from different
+ * frames. When a page contains multiple frames, if those frames are using the
+ * media session and set the metadata, then we have to know which frame owns the
+ * audio focus that would be the last tab playing media. When the frame owns
+ * audio focus, it means its metadata would be displayed on the virtual control
+ * interface if it has a media session.
+ */
+add_task(async function testAudioFocusChangesAmongMultipleFrames() {
+ /**
+ * Play the media from the main frame, so it would own the audio focus and
+ * its metadata should be shown on the virtual control interface. As the main
+ * frame doesn't use media session, the current metadata would be the default
+ * metadata.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await playAndWaitUntilMetadataChanged(tab);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Play media for frame1, so the audio focus switches to frame1 because it's
+ * the last tab playing media and frame1's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadata = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Play media for frame2, so the audio focus switches to frame2 because it's
+ * the last tab playing media and frame2's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame2, frameURL);
+ metadata = await setMetadataAndGetReturnResult(tab, frame2);
+ await playAndWaitUntilMetadataChanged(tab, frame2);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+});
+
+add_task(async function testAudioFocusChangesAfterPausingAudioFocusOwner() {
+ /**
+ * Play the media from the main frame, so it would own the audio focus and
+ * its metadata should be shown on the virtual control interface. As the main
+ * frame doesn't use media session, the current metadata would be the default
+ * metadata.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await playAndWaitUntilMetadataChanged(tab);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Play media for frame1, so the audio focus switches to frame1 because it's
+ * the last tab playing media and frame1's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadata = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Pause media for frame1, so the audio focus switches back to the main frame
+ * which is still playing media.
+ */
+ await pauseAndWaitUntilMetadataChangedFrom(tab, frame1);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+});
+
+add_task(async function testAudioFocusUnchangesAfterPausingAudioFocusOwner() {
+ /**
+ * Play the media from the main frame, so it would own the audio focus and
+ * its metadata should be shown on the virtual control interface. As the main
+ * frame doesn't use media session, the current metadata would be the default
+ * metadata.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await playAndWaitUntilMetadataChanged(tab);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Play media for frame1, so the audio focus switches to frame1 because it's
+ * the last tab playing media and frame1's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadata = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Pause main frame's media first. When pausing frame1's media, there are not
+ * other frames playing media, so frame1 still owns the audio focus and its
+ * metadata should be displayed.
+ */
+ await pauseMediaFrom(tab);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+});
+
+add_task(
+ async function testSwitchAudioFocusToMainFrameAfterRemovingAudioFocusOwner() {
+ /**
+ * Play the media from the main frame, so it would own the audio focus and
+ * its metadata should be displayed on the virtual control interface. As the
+ * main frame doesn't use media session, the current metadata would be the
+ * default metadata.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await playAndWaitUntilMetadataChanged(tab);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Play media for frame1, so the audio focus switches to frame1 because it's
+ * the last tab playing media and frame1's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadata = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Remove frame1, the audio focus would switch to the main frame which
+ * metadata should be displayed.
+ */
+ await Promise.all([
+ waitUntilDisplayedMetadataChanged(),
+ removeFrame(tab, frame1),
+ ]);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+ }
+);
+
+add_task(
+ async function testSwitchAudioFocusToIframeAfterRemovingAudioFocusOwner() {
+ /**
+ * Play media for frame1, so frame1 owns the audio focus and frame1's metadata
+ * should be displayed.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadataFrame1 = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadataFrame1);
+
+ /**
+ * Play media for frame2, so the audio focus switches to frame2 because it's
+ * the last tab playing media and frame2's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame2, frameURL);
+ let metadataFrame2 = await setMetadataAndGetReturnResult(tab, frame2);
+ await playAndWaitUntilMetadataChanged(tab, frame2);
+ isCurrentMetadataEqualTo(metadataFrame2);
+
+ /**
+ * Remove frame2, the audio focus would switch to frame1 which metadata should
+ * be displayed.
+ */
+ await Promise.all([
+ waitUntilDisplayedMetadataChanged(),
+ removeFrame(tab, frame2),
+ ]);
+ isCurrentMetadataEqualTo(metadataFrame1);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+ }
+);
+
+add_task(async function testNoAudioFocusAfterRemovingAudioFocusOwner() {
+ /**
+ * Play the media from the main frame, so it would own the audio focus and
+ * its metadata should be shown on the virtual control interface. As the main
+ * frame doesn't use media session, the current metadata would be the default
+ * metadata.
+ */
+ const tab = await createLoadedTabWrapper(mainPageURL);
+ await playAndWaitUntilMetadataChanged(tab);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Play media for frame1, so the audio focus switches to frame1 because it's
+ * the last tab playing media and frame1's metadata should be displayed.
+ */
+ await loadPageForFrame(tab, frame1, frameURL);
+ let metadata = await setMetadataAndGetReturnResult(tab, frame1);
+ await playAndWaitUntilMetadataChanged(tab, frame1);
+ isCurrentMetadataEqualTo(metadata);
+
+ /**
+ * Pause media in main frame and then remove frame1. As the frame which owns
+ * the audio focus is removed and no frame is still playing media, the current
+ * metadata would be the default metadata.
+ */
+ await pauseMediaFrom(tab);
+ await Promise.all([
+ waitUntilDisplayedMetadataChanged(),
+ removeFrame(tab, frame1),
+ ]);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ /**
+ * Remove tab and end test.
+ */
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function loadPageForFrame(tab, frameId, pageUrl) {
+ info(`start to load page for ${frameId}`);
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [frameId, pageUrl],
+ async (id, url) => {
+ const iframe = content.document.getElementById(id);
+ if (!iframe) {
+ ok(false, `can not get iframe '${id}'`);
+ }
+ iframe.src = url;
+ await new Promise(r => (iframe.onload = r));
+ // Set the document title that would be used as the value for properties
+ // in frame's medadata.
+ iframe.contentDocument.title = id;
+ }
+ );
+}
+
+function playMediaFrom(tab, frameId = undefined) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => {
+ if (id == undefined) {
+ info(`start to play media from main frame`);
+ const video = content.document.getElementById("video");
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ return video.play();
+ }
+
+ info(`start to play media from ${id}`);
+ const iframe = content.document.getElementById(id);
+ if (!iframe) {
+ ok(false, `can not get ${id}`);
+ }
+ iframe.contentWindow.postMessage("play", "*");
+ return new Promise(r => {
+ content.onmessage = event => {
+ is(event.data, "played", `media started playing in ${id}`);
+ r();
+ };
+ });
+ });
+}
+
+function playAndWaitUntilMetadataChanged(tab, frameId = undefined) {
+ const metadataChanged = frameId
+ ? new Promise(r => (tab.controller.onmetadatachange = r))
+ : waitUntilDisplayedMetadataChanged();
+ return Promise.all([metadataChanged, playMediaFrom(tab, frameId)]);
+}
+
+function pauseMediaFrom(tab, frameId = undefined) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => {
+ if (id == undefined) {
+ info(`start to pause media from in frame`);
+ const video = content.document.getElementById("video");
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ return video.pause();
+ }
+
+ info(`start to pause media in ${id}`);
+ const iframe = content.document.getElementById(id);
+ if (!iframe) {
+ ok(false, `can not get ${id}`);
+ }
+ iframe.contentWindow.postMessage("pause", "*");
+ return new Promise(r => {
+ content.onmessage = event => {
+ is(event.data, "paused", `media paused in ${id}`);
+ r();
+ };
+ });
+ });
+}
+
+function pauseAndWaitUntilMetadataChangedFrom(tab, frameId = undefined) {
+ const metadataChanged = waitUntilDisplayedMetadataChanged();
+ return Promise.all([metadataChanged, pauseMediaFrom(tab, frameId)]);
+}
+
+function setMetadataAndGetReturnResult(tab, frameId) {
+ info(`start to set metadata for ${frameId}`);
+ return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => {
+ const iframe = content.document.getElementById(id);
+ if (!iframe) {
+ ok(false, `can not get ${id}`);
+ }
+ iframe.contentWindow.postMessage("setMetadata", "*");
+ info(`wait until we get metadata for ${id}`);
+ return new Promise(r => {
+ content.onmessage = event => {
+ ok(
+ event.data.title && event.data.artist && event.data.album,
+ "correct return format"
+ );
+ r(event.data);
+ };
+ });
+ });
+}
+
+function removeFrame(tab, frameId) {
+ info(`remove ${frameId}`);
+ return SpecialPowers.spawn(tab.linkedBrowser, [frameId], id => {
+ const iframe = content.document.getElementById(id);
+ if (!iframe) {
+ ok(false, `can not get ${id}`);
+ }
+ content.document.body.removeChild(iframe);
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js b/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js
new file mode 100644
index 0000000000..6956762cfa
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js
@@ -0,0 +1,206 @@
+// Import this in order to use `triggerPictureInPicture()`.
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js",
+ this
+);
+
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const IFRAME_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ],
+ });
+});
+
+/**
+ * Usually we would only start controlling media after media starts, but if
+ * media has entered Picture-in-Picture mode or fullscreen, then we would allow
+ * users to control them directly without prior to starting media.
+ */
+add_task(async function testMediaEntersPIPMode() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`trigger PIP mode`);
+ const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId);
+
+ info(`press 'play' and wait until media starts`);
+ await generateMediaControlKeyEvent("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ await tab.close();
+});
+
+add_task(async function testMutedMediaEntersPIPMode() {
+ info(`open media page and mute video`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ await muteMedia(tab, testVideoId);
+
+ info(`trigger PIP mode`);
+ const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId);
+
+ info(`press 'play' and wait until media starts`);
+ await generateMediaControlKeyEvent("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ await tab.close();
+});
+
+add_task(async function testMediaEntersFullScreen() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`make video fullscreen`);
+ await enableFullScreen(tab, testVideoId);
+
+ info(`press 'play' and wait until media starts`);
+ await generateMediaControlKeyEvent("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testMutedMediaEntersFullScreen() {
+ info(`open media page and mute video`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ await muteMedia(tab, testVideoId);
+
+ info(`make video fullscreen`);
+ await enableFullScreen(tab, testVideoId);
+
+ info(`press 'play' and wait until media starts`);
+ await generateMediaControlKeyEvent("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testNonMediaEntersFullScreen() {
+ info(`open media page which won't have an activated controller`);
+ // As we won't activate controller in this test case, not need to
+ // check controller's status.
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, {
+ needCheck: false,
+ });
+
+ info(`make non-media element fullscreen`);
+ const nonMediaElementId = "image";
+ await enableFullScreen(tab, nonMediaElementId);
+
+ info(`press 'play' which should not start media`);
+ // Use `generateMediaControlKey()` directly because `play` won't affect the
+ // controller's playback state (don't need to wait for the change).
+ MediaControlService.generateMediaControlKey("play");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testMediaInIframeEntersFullScreen() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`make video in iframe fullscreen`);
+ await enableMediaFullScreenInIframe(tab, testVideoId);
+
+ info(`press 'play' and wait until media starts`);
+ await generateMediaControlKeyEvent("play");
+
+ info(`full screen media in inframe should be started`);
+ await waitUntilIframeMediaStartedPlaying(tab);
+
+ info(`media not in fullscreen should keep paused`);
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`remove iframe that contains fullscreen video`);
+ await removeIframeFromDocument(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function muteMedia(tab, videoId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [videoId], videoId => {
+ content.document.getElementById(videoId).muted = true;
+ });
+}
+
+function enableFullScreen(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => {
+ return new Promise(r => {
+ const element = content.document.getElementById(elementId);
+ element.requestFullscreen();
+ element.onfullscreenchange = () => {
+ element.onfullscreenchange = null;
+ element.onfullscreenerror = null;
+ r();
+ };
+ element.onfullscreenerror = () => {
+ // Retry until the element successfully enters fullscreen.
+ element.requestFullscreen();
+ };
+ });
+ });
+}
+
+function enableMediaFullScreenInIframe(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => {
+ info(`create iframe and wait until it finishes loading`);
+ const iframe = content.document.getElementById("iframe");
+ iframe.src = url;
+ await new Promise(r => (iframe.onload = r));
+
+ info(`trigger media in iframe entering into fullscreen`);
+ iframe.contentWindow.postMessage("fullscreen", "*");
+ info(`wait until media in frame enters fullscreen`);
+ return new Promise(r => {
+ content.onmessage = event => {
+ is(
+ event.data,
+ "entered-fullscreen",
+ `media in iframe entered fullscreen`
+ );
+ r();
+ };
+ });
+ });
+}
+
+function waitUntilIframeMediaStartedPlaying(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => {
+ info(`check if media in iframe starts playing`);
+ const iframe = content.document.getElementById("iframe");
+ iframe.contentWindow.postMessage("check-playing", "*");
+ return new Promise(r => {
+ content.onmessage = event => {
+ is(event.data, "checked-playing", `media in iframe is playing`);
+ r();
+ };
+ });
+ });
+}
+
+function removeIframeFromDocument(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ info(`remove iframe from document`);
+ content.document.getElementById("iframe").remove();
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js b/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js
new file mode 100644
index 0000000000..0da8acd62b
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_captured_audio.js
@@ -0,0 +1,45 @@
+const PAGE_NON_AUTOPLAY_MEDIA =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * When we capture audio from an media element to the web audio, if the media
+ * is audible, it should be controlled by media keys as well.
+ */
+add_task(async function testAudibleCapturedMedia() {
+ info(`open new non autoplay media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY_MEDIA);
+
+ info(`capture audio and start playing`);
+ await captureAudio(tab, testVideoId);
+ await playMedia(tab, testVideoId);
+
+ info(`pressing 'pause' key, captured media should be paused`);
+ await generateMediaControlKeyEvent("pause");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function captureAudio(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ const context = new content.AudioContext();
+ // Capture audio from the media element to a MediaElementAudioSourceNode.
+ context.createMediaElementSource(video);
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js b/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js
new file mode 100644
index 0000000000..a8525e61c5
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_keys_event.js
@@ -0,0 +1,62 @@
+const PAGE =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const testVideoId = "video";
+
+/**
+ * This test is used to generate platform-independent media control keys event
+ * and see if media can be controlled correctly and current we only support
+ * `play`, `pause`, `playPause` and `stop` events.
+ */
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+add_task(async function testPlayPauseAndStop() {
+ info(`open page and start media`);
+ const tab = await createLoadedTabWrapper(PAGE);
+ await playMedia(tab, testVideoId);
+
+ info(`pressing 'pause' key`);
+ MediaControlService.generateMediaControlKey("pause");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`pressing 'play' key`);
+ MediaControlService.generateMediaControlKey("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`pressing 'stop' key`);
+ MediaControlService.generateMediaControlKey("stop");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`Has stopped controlling media, pressing 'play' won't resume it`);
+ MediaControlService.generateMediaControlKey("play");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testPlayPause() {
+ info(`open page and start media`);
+ const tab = await createLoadedTabWrapper(PAGE);
+ await playMedia(tab, testVideoId);
+
+ info(`pressing 'playPause' key, media should stop`);
+ MediaControlService.generateMediaControlKey("playpause");
+ await Promise.all([
+ new Promise(r => (tab.controller.onplaybackstatechange = r)),
+ checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId),
+ ]);
+
+ info(`pressing 'playPause' key, media should start`);
+ MediaControlService.generateMediaControlKey("playpause");
+ await Promise.all([
+ new Promise(r => (tab.controller.onplaybackstatechange = r)),
+ checkOrWaitUntilMediaStartedPlaying(tab, testVideoId),
+ ]);
+
+ info(`remove tab`);
+ await tab.close();
+});
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js b/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js
new file mode 100644
index 0000000000..fb899182c9
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js
@@ -0,0 +1,342 @@
+// Import this in order to use `triggerPictureInPicture()`.
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js",
+ this
+);
+
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test is used to check in different situaition if we can determine the
+ * main controller correctly that is the controller which can receive media
+ * control keys and show its metadata on the virtual control interface.
+ *
+ * We will assign different metadata for each tab and know which tab is the main
+ * controller by checking main controller's metadata.
+ *
+ * We will always choose the last tab which plays media as the main controller,
+ * and maintain a list by the order of playing media. If the top element in the
+ * list has been removed, then we will use the last element in the list as the
+ * main controller.
+ *
+ * Eg. tab1 plays first, then tab2 plays, then tab3 plays, the list would be
+ * like [tab1, tab2, tab3] and the main controller would be tab3. If tab3 has
+ * been closed, then the list would become [tab1, tab2] and the tab2 would be
+ * the main controller.
+ */
+add_task(async function testDeterminingMainController() {
+ info(`open three different tabs`);
+ const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ /**
+ * part1 : [] -> [tab0] -> [tab0, tab1] -> [tab0, tab1, tab2]
+ */
+ info(`# [] -> [tab0] -> [tab0, tab1] -> [tab0, tab1, tab2] #`);
+ info(`set different metadata for each tab`);
+ await setMediaMetadataForTabs([tab0, tab1, tab2]);
+
+ info(`start media for tab0, main controller should become tab0`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`start media for tab1, main controller should become tab1`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab1);
+
+ info(`currrent metadata should be equal to tab1's metadata`);
+ await isCurrentMetadataEqualTo(tab1.metadata);
+
+ info(`start media for tab2, main controller should become tab2`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab2);
+
+ info(`currrent metadata should be equal to tab2's metadata`);
+ await isCurrentMetadataEqualTo(tab2.metadata);
+
+ /**
+ * part2 : [tab0, tab1, tab2] -> [tab0, tab2, tab1] -> [tab2, tab1, tab0]
+ */
+ info(`# [tab0, tab1, tab2] -> [tab0, tab2, tab1] -> [tab2, tab1, tab0] #`);
+ info(`start media for tab1, main controller should become tab1`);
+ await makeTabBecomeMainController(tab1);
+
+ info(`currrent metadata should be equal to tab1's metadata`);
+ await isCurrentMetadataEqualTo(tab1.metadata);
+
+ info(`start media for tab0, main controller should become tab0`);
+ await makeTabBecomeMainController(tab0);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ /**
+ * part3 : [tab2, tab1, tab0] -> [tab2, tab1] -> [tab2] -> []
+ */
+ info(`# [tab2, tab1, tab0] -> [tab2, tab1] -> [tab2] -> [] #`);
+ info(`remove tab0 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]);
+
+ info(`currrent metadata should be equal to tab1's metadata`);
+ await isCurrentMetadataEqualTo(tab1.metadata);
+
+ info(`remove tab1 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]);
+
+ info(`currrent metadata should be equal to tab2's metadata`);
+ await isCurrentMetadataEqualTo(tab2.metadata);
+
+ info(`remove tab2 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab2.close()]);
+ isCurrentMetadataEmpty();
+});
+
+add_task(async function testPIPControllerWontBeReplacedByNormalController() {
+ info(`open two different tabs`);
+ const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`set different metadata for each tab`);
+ await setMediaMetadataForTabs([tab0, tab1]);
+
+ info(`start media for tab0, main controller should become tab0`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`trigger Picture-in-Picture mode for tab0`);
+ const winPIP = await triggerPictureInPicture(tab0.linkedBrowser, testVideoId);
+
+ info(`start media for tab1, main controller should still be tab0`);
+ await playMediaAndWaitUntilRegisteringController(tab1, testVideoId);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`remove tab0 and wait until main controller changes`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]);
+
+ info(`currrent metadata should be equal to tab1's metadata`);
+ await isCurrentMetadataEqualTo(tab1.metadata);
+
+ info(`remove tab1 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]);
+ isCurrentMetadataEmpty();
+});
+
+add_task(
+ async function testFullscreenControllerWontBeReplacedByNormalController() {
+ info(`open two different tabs`);
+ const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`set different metadata for each tab`);
+ await setMediaMetadataForTabs([tab0, tab1]);
+
+ info(`start media for tab0, main controller should become tab0`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0);
+
+ info(`current metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`video in tab0 enters fullscreen`);
+ await switchTabToForegroundAndEnableFullScreen(tab0, testVideoId);
+
+ info(
+ `normal controller won't become the main controller, ` +
+ `which is still fullscreen controller`
+ );
+ await playMediaAndWaitUntilRegisteringController(tab1, testVideoId);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`remove tabs`);
+ await Promise.all([tab0.close(), tab1.close()]);
+ }
+);
+
+add_task(async function testFullscreenAndPIPControllers() {
+ info(`open three different tabs`);
+ const tab0 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+ const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`set different metadata for each tab`);
+ await setMediaMetadataForTabs([tab0, tab1, tab2]);
+
+ /**
+ * Current controller list : [tab0 (fullscreen)]
+ */
+ info(`start media for tab0, main controller should become tab0`);
+ await makeTabBecomeMainControllerAndWaitForMetadataChange(tab0);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ info(`video in tab0 enters fullscreen`);
+ await switchTabToForegroundAndEnableFullScreen(tab0, testVideoId);
+
+ /**
+ * Current controller list : [tab1, tab0 (fullscreen)]
+ */
+ info(`start media for tab1, main controller should still be tab0`);
+ await playMediaAndWaitUntilRegisteringController(tab1, testVideoId);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ /**
+ * Current controller list : [tab0 (fullscreen), tab1 (PIP)]
+ */
+ info(`tab1 enters PIP so tab1 should become new main controller`);
+ const mainControllerChange = waitUntilMainMediaControllerChanged();
+ const winPIP = await triggerPictureInPicture(tab1.linkedBrowser, testVideoId);
+ await mainControllerChange;
+
+ info(`currrent metadata should be equal to tab1's metadata`);
+ await isCurrentMetadataEqualTo(tab1.metadata);
+
+ /**
+ * Current controller list : [tab2, tab0 (fullscreen), tab1 (PIP)]
+ */
+ info(`play video from tab2 which shouldn't affect main controller`);
+ await playMediaAndWaitUntilRegisteringController(tab2, testVideoId);
+
+ /**
+ * Current controller list : [tab2, tab0 (fullscreen)]
+ */
+ info(`remove tab1 and wait until main controller changes`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab1.close()]);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab0.metadata);
+
+ /**
+ * Current controller list : [tab2]
+ */
+ info(`remove tab0 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab0.close()]);
+
+ info(`currrent metadata should be equal to tab0's metadata`);
+ await isCurrentMetadataEqualTo(tab2.metadata);
+
+ /**
+ * Current controller list : []
+ */
+ info(`remove tab2 and wait until main controller changes`);
+ await Promise.all([waitUntilMainMediaControllerChanged(), tab2.close()]);
+ isCurrentMetadataEmpty();
+});
+
+/**
+ * The following are helper functions
+ */
+async function setMediaMetadataForTabs(tabs) {
+ for (let idx = 0; idx < tabs.length; idx++) {
+ const tabName = "tab" + idx;
+ info(`create metadata for ${tabName}`);
+ tabs[idx].metadata = {
+ title: tabName,
+ artist: tabName,
+ album: tabName,
+ artwork: [{ src: tabName, sizes: "128x128", type: "image/jpeg" }],
+ };
+ const spawn = SpecialPowers.spawn(
+ tabs[idx].linkedBrowser,
+ [tabs[idx].metadata],
+ data => {
+ content.navigator.mediaSession.metadata = new content.MediaMetadata(
+ data
+ );
+ }
+ );
+ // As those controller hasn't been activated yet, we can't listen to
+ // `mediacontroll.onmetadatachange`, which would only be notified after a
+ // controller becomes active.
+ await Promise.all([spawn, waitUntilControllerMetadataChanged()]);
+ }
+}
+
+function makeTabBecomeMainController(tab) {
+ const playPromise = SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [testVideoId],
+ async Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ // If media has been started, we would stop media first and then start it
+ // again, which would make controller's playback state change to `playing`
+ // again and result in updating new main controller.
+ if (!video.paused) {
+ video.pause();
+ info(`wait until media stops`);
+ await new Promise(r => (video.onpause = r));
+ }
+ info(`start media`);
+ return video.play();
+ }
+ );
+ return Promise.all([playPromise, waitUntilMainMediaControllerChanged()]);
+}
+
+function makeTabBecomeMainControllerAndWaitForMetadataChange(tab) {
+ return Promise.all([
+ new Promise(r => (tab.controller.onmetadatachange = r)),
+ makeTabBecomeMainController(tab),
+ ]);
+}
+
+function playMediaAndWaitUntilRegisteringController(tab, elementId) {
+ const playPromise = SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [elementId],
+ Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ return video.play();
+ }
+ );
+ return Promise.all([waitUntilMediaControllerAmountChanged(), playPromise]);
+}
+
+async function switchTabToForegroundAndEnableFullScreen(tab, elementId) {
+ // Fullscreen can only be allowed to enter from a focus tab.
+ await BrowserTestUtils.switchTab(gBrowser, tab.tabElement);
+ await SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => {
+ return new Promise(r => {
+ const element = content.document.getElementById(elementId);
+ element.requestFullscreen();
+ element.onfullscreenchange = () => {
+ element.onfullscreenchange = null;
+ element.onfullscreenerror = null;
+ r();
+ };
+ element.onfullscreenerror = () => {
+ // Retry until the element successfully enters fullscreen.
+ element.requestFullscreen();
+ };
+ });
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js b/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js
new file mode 100644
index 0000000000..473f24151f
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_metadata.js
@@ -0,0 +1,416 @@
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const PAGE_EMPTY_TITLE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_empty_title.html";
+
+const testVideoId = "video";
+const defaultFaviconName = "defaultFavicon.svg";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test includes many different test cases of checking the current media
+ * metadata from the tab which is being controlled by the media control. Each
+ * `add_task(..)` is a different testing scenario.
+ *
+ * Media metadata is the information that can tell user about what title, artist,
+ * album and even art work the tab is currently playing to. The metadta is
+ * usually set from MediaSession API, but if the site doesn't use that API, we
+ * would also generate a default metadata instead.
+ *
+ * The current metadata would only be available after the page starts playing
+ * media at least once, if the page hasn't started any media before, then the
+ * current metadata is always empty.
+ *
+ * For following situations, we would create a default metadata which title is
+ * website's title and artwork is from our default favicon icon.
+ * (1) the page doesn't use MediaSession API
+ * (2) media session doesn't has metadata
+ * (3) media session has an empty metadata
+ *
+ * Otherwise, the current metadata would be media session's metadata from the
+ * tab which is currently controlled by the media control.
+ */
+add_task(async function testDefaultMetadataForPageWithoutMediaSession() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`should use default metadata because of lacking of media session`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(
+ async function testDefaultMetadataForEmptyTitlePageWithoutMediaSession() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_EMPTY_TITLE_URL);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`should use default metadata because of lacking of media session`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+ }
+);
+
+add_task(async function testDefaultMetadataForPageUsingEmptyMetadata() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create empty media metadata`);
+ await setMediaMetadata(tab, {
+ title: "",
+ artist: "",
+ album: "",
+ artwork: [],
+ });
+
+ info(`should use default metadata because of empty media metadata`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testDefaultMetadataForPageUsingNullMetadata() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create empty media metadata`);
+ await setNullMediaMetadata(tab);
+
+ info(`should use default metadata because of lacking of media metadata`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testMetadataWithEmptyTitleAndArtwork() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create media metadata with empty title and artwork`);
+ await setMediaMetadata(tab, {
+ title: "",
+ artist: "foo",
+ album: "bar",
+ artwork: [],
+ });
+
+ info(`should use default metadata because of empty title and artwork`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testMetadataWithoutTitleAndArtwork() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create media metadata with empty title and artwork`);
+ await setMediaMetadata(tab, {
+ artist: "foo",
+ album: "bar",
+ });
+
+ info(`should use default metadata because of lacking of title and artwork`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testMetadataInPrivateBrowsing() {
+ info(`create a private window`);
+ const inputWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, { inputWindow });
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`set metadata`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+
+ info(`should use default metadata because of in private browsing mode`);
+ await isGivenTabUsingDefaultMetadata(tab, { isPrivateBrowsing: true });
+
+ info(`remove tab`);
+ await tab.close();
+
+ info(`close private window`);
+ await BrowserTestUtils.closeWindow(inputWindow);
+});
+
+add_task(async function testSetMetadataFromMediaSessionAPI() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`set metadata`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+
+ info(`check if current active metadata is equal to what we've set before`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`set metadata again to see if current metadata would change`);
+ metadata = {
+ title: "foo2",
+ artist: "bar2",
+ album: "foo2",
+ artwork: [{ src: "bar2.jpg", sizes: "129x129", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+
+ info(`check if current active metadata is equal to what we've set before`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSetMetadataBeforeMediaStarts() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, {
+ needCheck: false,
+ });
+
+ info(`set metadata`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata, { notExpectChange: true });
+
+ info(`current media metadata should be empty before media starts`);
+ isCurrentMetadataEmpty();
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSetMetadataAfterMediaPaused() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media in order to let this tab be controlled`);
+ await playMedia(tab, testVideoId);
+
+ info(`pause media`);
+ await pauseMedia(tab, testVideoId);
+
+ info(`set metadata after media is paused`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+
+ info(`check if current active metadata is equal to what we've set before`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSetMetadataAmongMultipleTabs() {
+ info(`open media page in tab1`);
+ const tab1 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media in tab1`);
+ await playMedia(tab1, testVideoId);
+
+ info(`set metadata for tab1`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab1, metadata);
+
+ info(`check if current active metadata is equal to what we've set before`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`open another page in tab2`);
+ const tab2 = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media in tab2`);
+ await playMedia(tab2, testVideoId);
+
+ info(`set metadata for tab2`);
+ metadata = {
+ title: "foo2",
+ artist: "bar2",
+ album: "foo2",
+ artwork: [{ src: "bar2.jpg", sizes: "129x129", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab2, metadata);
+
+ info(`current active metadata should become metadata from tab2`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(
+ `update metadata for tab1, which should not affect current metadata ` +
+ `because media session in tab2 is the one we're controlling right now`
+ );
+ await setMediaMetadata(tab1, {
+ title: "foo3",
+ artist: "bar3",
+ album: "foo3",
+ artwork: [{ src: "bar3.jpg", sizes: "130x130", type: "image/jpeg" }],
+ });
+
+ info(`current active metadata should still be metadata from tab2`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`remove tabs`);
+ await Promise.all([tab1.close(), tab2.close()]);
+});
+
+add_task(async function testMetadataAfterTabNavigation() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`set metadata`);
+ let metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+
+ info(`check if current active metadata is equal to what we've set before`);
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`navigate tab to blank page`);
+ await Promise.all([
+ new Promise(r => (tab.controller.ondeactivated = r)),
+ BrowserTestUtils.loadURI(tab.linkedBrowser, "about:blank"),
+ ]);
+
+ info(`current media metadata should be reset`);
+ isCurrentMetadataEmpty();
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testUpdateDefaultMetadataWhenPageTitleChanges() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`should use default metadata because of lacking of media session`);
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`default metadata should be updated after page title changes`);
+ await changePageTitle(tab, { shouldAffectMetadata: true });
+ await isGivenTabUsingDefaultMetadata(tab);
+
+ info(`after setting metadata, title change won't affect current metadata`);
+ const metadata = {
+ title: "foo",
+ artist: "bar",
+ album: "foo",
+ artwork: [{ src: "bar.jpg", sizes: "128x128", type: "image/jpeg" }],
+ };
+ await setMediaMetadata(tab, metadata);
+ await changePageTitle(tab, { shouldAffectMetadata: false });
+ await isCurrentMetadataEqualTo(metadata);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function setMediaMetadata(tab, metadata, { notExpectChange } = {}) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ const metadatachangePromise = notExpectChange
+ ? Promise.resolve()
+ : new Promise(r => (controller.onmetadatachange = r));
+ return Promise.all([
+ metadatachangePromise,
+ SpecialPowers.spawn(tab.linkedBrowser, [metadata], data => {
+ content.navigator.mediaSession.metadata = new content.MediaMetadata(data);
+ }),
+ ]);
+}
+
+function setNullMediaMetadata(tab) {
+ const promise = SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.navigator.mediaSession.metadata = null;
+ });
+ return Promise.all([promise, waitUntilControllerMetadataChanged()]);
+}
+
+async function changePageTitle(tab, { shouldAffectMetadata } = {}) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ const shouldWaitMetadataChangePromise = shouldAffectMetadata
+ ? new Promise(r => (controller.onmetadatachange = r))
+ : Promise.resolve();
+ await Promise.all([
+ shouldWaitMetadataChangePromise,
+ SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.document.title = "new title";
+ }),
+ ]);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js b/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js
new file mode 100644
index 0000000000..8a2b2822c5
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js
@@ -0,0 +1,205 @@
+const PAGE_NON_ELIGIBLE_MEDIA =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html";
+
+// Import this in order to use `triggerPictureInPicture()`.
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js",
+ this
+);
+
+// Bug 1673509 - This test requests a lot of fullscreen for media elements,
+// which sometime Gecko would take longer time to fulfill.
+requestLongerTimeout(2);
+
+// This array contains the elements' id in `file_non_eligible_media.html`.
+const gNonEligibleElementIds = [
+ "muted",
+ "volume-0",
+ "silent-audio-track",
+ "no-audio-track",
+ "short-duration",
+ "inaudible-captured-media",
+];
+
+/**
+ * This test is used to test couples of things about what kinds of media is
+ * eligible for being controlled by media control keys.
+ * (1) If media is inaudible all the time, then we would not control it.
+ * (2) If media starts inaudibly, we would not try to control it. But once it
+ * becomes audible later, we would keep controlling it until it's destroyed.
+ * (3) If media's duration is too short (<3s), then we would not control it.
+ */
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+add_task(
+ async function testNonAudibleMediaCantActivateControllerButAudibleMediaCan() {
+ for (const elementId of gNonEligibleElementIds) {
+ info(`open new tab with non eligible media elements`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA, {
+ needCheck: couldElementBecomeEligible(elementId),
+ });
+
+ info(`although media is playing but it won't activate controller`);
+ await Promise.all([
+ startNonEligibleMedia(tab, elementId),
+ checkIfMediaIsStillPlaying(tab, elementId),
+ ]);
+ ok(!tab.controller.isActive, "controller is still inactive");
+
+ if (couldElementBecomeEligible(elementId)) {
+ info(`make element ${elementId} audible would activate controller`);
+ await Promise.all([
+ makeElementEligible(tab, elementId),
+ checkOrWaitUntilControllerBecomeActive(tab),
+ ]);
+ }
+
+ info(`remove tab`);
+ await tab.close();
+ }
+ }
+);
+
+/**
+ * Normally those media are not able to being controlled, however, once they
+ * enter fullsceen or Picture-in-Picture mode, then they can be controlled.
+ */
+add_task(async function testNonEligibleMediaEnterFullscreen() {
+ info(`open new tab with non eligible media elements`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA);
+
+ for (const elementId of gNonEligibleElementIds) {
+ await startNonEligibleMedia(tab, elementId);
+
+ info(`entering fullscreen should activate the media controller`);
+ await enterFullScreen(tab, elementId);
+ await checkOrWaitUntilControllerBecomeActive(tab);
+ ok(true, `fullscreen ${elementId} media is able to being controlled`);
+
+ info(`leave fullscreen`);
+ await leaveFullScreen(tab);
+ }
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testNonEligibleMediaEnterPIPMode() {
+ info(`open new tab with non eligible media elements`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_ELIGIBLE_MEDIA);
+
+ for (const elementId of gNonEligibleElementIds) {
+ await startNonEligibleMedia(tab, elementId);
+
+ info(`media entering PIP mode should activate the media controller`);
+ const winPIP = await triggerPictureInPicture(tab.linkedBrowser, elementId);
+ await checkOrWaitUntilControllerBecomeActive(tab);
+ ok(true, `PIP ${elementId} media is able to being controlled`);
+
+ info(`stop PIP mode`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ }
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function startNonEligibleMedia(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ if (Id == "volume-0") {
+ video.volume = 0.0;
+ }
+ if (Id == "inaudible-captured-media") {
+ const context = new content.AudioContext();
+ context.createMediaElementSource(video);
+ }
+ info(`start non eligible media ${Id}`);
+ return video.play();
+ });
+}
+
+function checkIfMediaIsStillPlaying(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ return new Promise(r => {
+ // In order to test "media isn't affected by media control", we would not
+ // only check `mPaused`, we would also oberve "timeupdate" event multiple
+ // times to ensure that video is still playing continually.
+ let timeUpdateCount = 0;
+ ok(!video.paused);
+ video.ontimeupdate = () => {
+ if (++timeUpdateCount == 3) {
+ video.ontimeupdate = null;
+ r();
+ }
+ };
+ });
+ });
+}
+
+function couldElementBecomeEligible(elementId) {
+ return elementId == "muted" || elementId == "volume-0";
+}
+
+function makeElementEligible(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ // to turn inaudible media become audible in order to be controlled.
+ video.volume = 1.0;
+ video.muted = false;
+ });
+}
+
+function waitUntilMediaPaused(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ if (video.paused) {
+ ok(true, "media has been paused");
+ return Promise.resolve();
+ }
+ return new Promise(r => (video.onpaused = r));
+ });
+}
+
+function enterFullScreen(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ return new Promise(r => {
+ const element = content.document.getElementById(Id);
+ element.requestFullscreen();
+ element.onfullscreenchange = () => {
+ element.onfullscreenchange = null;
+ element.onfullscreenerror = null;
+ r();
+ };
+ element.onfullscreenerror = () => {
+ // Retry until the element successfully enters fullscreen.
+ element.requestFullscreen();
+ };
+ });
+ });
+}
+
+function leaveFullScreen(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ return content.document.exitFullscreen();
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js b/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js
new file mode 100644
index 0000000000..4d30566e0a
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_playback_state.js
@@ -0,0 +1,116 @@
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test is used to check the actual playback state [1] of the main media
+ * controller. The declared playback state is the playback state from the active
+ * media session, and the guessed playback state is determined by the media's
+ * playback state. Both the declared playback and the guessed playback state
+ * would be used to decide the final result of the actual playback state.
+ *
+ * [1] https://w3c.github.io/mediasession/#actual-playback-state
+ */
+add_task(async function testDefaultPlaybackStateBeforeAnyMediaStart() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY, {
+ needCheck: false,
+ });
+
+ info(`before media starts, playback state should be 'none'`);
+ await isActualPlaybackStateEqualTo(tab, "none");
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testGuessedPlaybackState() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(
+ `Now declared='none', guessed='playing', so actual playback state should be 'playing'`
+ );
+ await setGuessedPlaybackState(tab, "playing");
+ await isActualPlaybackStateEqualTo(tab, "playing");
+
+ info(
+ `Now declared='none', guessed='paused', so actual playback state should be 'paused'`
+ );
+ await setGuessedPlaybackState(tab, "paused");
+ await isActualPlaybackStateEqualTo(tab, "paused");
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testBothGuessedAndDeclaredPlaybackState() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(
+ `Now declared='paused', guessed='playing', so actual playback state should be 'playing'`
+ );
+ await setDeclaredPlaybackState(tab, "paused");
+ await setGuessedPlaybackState(tab, "playing");
+ await isActualPlaybackStateEqualTo(tab, "playing");
+
+ info(
+ `Now declared='paused', guessed='paused', so actual playback state should be 'paused'`
+ );
+ await setGuessedPlaybackState(tab, "paused");
+ await isActualPlaybackStateEqualTo(tab, "paused");
+
+ info(
+ `Now declared='playing', guessed='paused', so actual playback state should be 'playing'`
+ );
+ await setDeclaredPlaybackState(tab, "playing");
+ await isActualPlaybackStateEqualTo(tab, "playing");
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function setGuessedPlaybackState(tab, state) {
+ if (state == "playing") {
+ return playMedia(tab, testVideoId);
+ } else if (state == "paused") {
+ return pauseMedia(tab, testVideoId);
+ }
+ // We won't set the state `stopped`, which would only happen if no any media
+ // has ever been started in the page.
+ ok(false, `should only set 'playing' or 'paused' state`);
+ return Promise.resolve();
+}
+
+async function isActualPlaybackStateEqualTo(tab, expectedState) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ if (controller.playbackState != expectedState) {
+ await new Promise(r => (controller.onplaybackstatechange = r));
+ }
+ is(
+ controller.playbackState,
+ expectedState,
+ `current state '${controller.playbackState}' is equal to '${expectedState}'`
+ );
+}
+
+function setDeclaredPlaybackState(tab, state) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [state], playbackState => {
+ info(`set declared playback state to '${playbackState}'`);
+ content.navigator.mediaSession.playbackState = playbackState;
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js b/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js
new file mode 100644
index 0000000000..f32ce26063
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_position_state.js
@@ -0,0 +1,150 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const IFRAME_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_iframe_media.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test is used to check if we can receive correct position state change,
+ * when we set the position state to the media session.
+ */
+add_task(async function testSetPositionState() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`set duration only`);
+ await setPositionState(tab, {
+ duration: 60,
+ });
+
+ info(`set duration and playback rate`);
+ await setPositionState(tab, {
+ duration: 50,
+ playbackRate: 2.0,
+ });
+
+ info(`set duration, playback rate and position`);
+ await setPositionState(tab, {
+ duration: 40,
+ playbackRate: 3.0,
+ position: 10,
+ });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSetPositionStateFromInactiveMediaSession() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(
+ `add an event listener to measure how many times the position state changes`
+ );
+ let positionChangedNum = 0;
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ controller.onpositionstatechange = () => positionChangedNum++;
+
+ info(`set position state on the main page which has an active media session`);
+ await setPositionState(tab, {
+ duration: 60,
+ });
+
+ info(`set position state on the iframe which has an inactive media session`);
+ await setPositionStateOnInactiveMediaSession(tab);
+
+ info(`set position state on the main page again`);
+ await setPositionState(tab, {
+ duration: 60,
+ });
+ is(
+ positionChangedNum,
+ 2,
+ `We should only receive two times of position change, because ` +
+ `the second one which performs on inactive media session is effectless`
+ );
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+async function setPositionState(tab, positionState) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ const positionStateChanged = new Promise(r => {
+ controller.addEventListener(
+ "positionstatechange",
+ event => {
+ const { duration, playbackRate, position } = positionState;
+ // duration is mandatory.
+ is(
+ event.duration,
+ duration,
+ `expected duration ${event.duration} is equal to ${duration}`
+ );
+
+ // Playback rate is optional, if it's not present, default should be 1.0
+ if (playbackRate) {
+ is(
+ event.playbackRate,
+ playbackRate,
+ `expected playbackRate ${event.playbackRate} is equal to ${playbackRate}`
+ );
+ } else {
+ is(event.playbackRate, 1.0, `expected default playbackRate is 1.0`);
+ }
+
+ // Position state is optional, if it's not present, default should be 0.0
+ if (position) {
+ is(
+ event.position,
+ position,
+ `expected position ${event.position} is equal to ${position}`
+ );
+ } else {
+ is(event.position, 0.0, `expected default position is 0.0`);
+ }
+ r();
+ },
+ { once: true }
+ );
+ });
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [positionState],
+ positionState => {
+ content.navigator.mediaSession.setPositionState(positionState);
+ }
+ );
+ await positionStateChanged;
+}
+
+async function setPositionStateOnInactiveMediaSession(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [IFRAME_URL], async url => {
+ info(`create iframe and wait until it finishes loading`);
+ const iframe = content.document.getElementById("iframe");
+ iframe.src = url;
+ await new Promise(r => (iframe.onload = r));
+
+ info(`trigger media in iframe entering into fullscreen`);
+ iframe.contentWindow.postMessage("setPositionState", "*");
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js b/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js
new file mode 100644
index 0000000000..70b75841b6
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_seekto.js
@@ -0,0 +1,89 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * This test is used to check if the `seekto` action can be sent when calling
+ * media controller's `seekTo()`. In addition, the seeking related properties
+ * which would be sent to the action handler should also be correct as what we
+ * set in `seekTo()`.
+ */
+add_task(async function testSetPositionState() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ const seektime = 0;
+ info(`seek to ${seektime} seconds.`);
+ await PerformSeekTo(tab, {
+ seekTime: seektime,
+ });
+
+ info(`seek to ${seektime} seconds and set fastseek to boolean`);
+ await PerformSeekTo(tab, {
+ seekTime: seektime,
+ fastSeek: true,
+ });
+
+ info(`seek to ${seektime} seconds and set fastseek to false`);
+ await PerformSeekTo(tab, {
+ seekTime: seektime,
+ fastSeek: false,
+ });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following is helper function.
+ */
+async function PerformSeekTo(tab, seekDetails) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [seekDetails, testVideoId],
+ (seekDetails, Id) => {
+ const { seekTime, fastSeek } = seekDetails;
+ content.navigator.mediaSession.setActionHandler("seekto", details => {
+ ok(details.seekTime != undefined, "Seektime must be presented");
+ is(seekTime, details.seekTime, "Get correct seektime");
+ if (fastSeek) {
+ is(fastSeek, details.fastSeek, "Get correct fastSeek");
+ } else {
+ ok(
+ details.fastSeek === undefined,
+ "Details should not contain fastSeek"
+ );
+ }
+ // We use `onseek` as a hint to know if the `seekto` has been received
+ // or not. The reason we don't return a resolved promise instead is
+ // because if we do so, it can't guarantees that the `seekto` action
+ // handler has been set before calling `mediaController.seekTo`.
+ content.document.getElementById(Id).currentTime = seekTime;
+ });
+ }
+ );
+ const seekPromise = SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [testVideoId],
+ Id => {
+ const video = content.document.getElementById(Id);
+ return new Promise(r => (video.onseeking = r()));
+ }
+ );
+ const { seekTime, fastSeek } = seekDetails;
+ tab.linkedBrowser.browsingContext.mediaController.seekTo(seekTime, fastSeek);
+ await seekPromise;
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js b/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js
new file mode 100644
index 0000000000..563b5dd666
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js
@@ -0,0 +1,80 @@
+// Import this in order to use `triggerPictureInPicture()`.
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js",
+ this
+);
+
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["media.mediacontrol.stopcontrol.timer", true],
+ ["media.mediacontrol.stopcontrol.timer.ms", 0],
+ ],
+ });
+});
+
+/**
+ * This test is used to check the stop timer for media element, which would stop
+ * media control for the specific element when the element has been paused over
+ * certain length of time. (That is controlled by the value of the pref
+ * `media.mediacontrol.stopcontrol.timer.ms`) In this test, we set the pref to 0
+ * which means the stop timer would be triggered after the media is paused.
+ * However, if the media is being used in PIP mode, we won't start the stop
+ * timer for it.
+ */
+add_task(async function testStopMediaControlAfterPausingMedia() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`pause media and the stop timer would stop media control`);
+ await pauseMediaAndMediaControlShouldBeStopped(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testNotToStopMediaControlForPIPVideo() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`trigger PIP mode`);
+ const winPIP = await triggerPictureInPicture(tab.linkedBrowser, testVideoId);
+
+ info(`pause media and the stop timer would not stop media control`);
+ await pauseMedia(tab, testVideoId);
+
+ info(`pressing 'play' key should start PIP video again`);
+ await generateMediaControlKeyEvent("play");
+ await checkOrWaitUntilMediaStartedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await BrowserTestUtils.closeWindow(winPIP);
+ await tab.close();
+});
+
+/**
+ * The following is helper function.
+ */
+function pauseMediaAndMediaControlShouldBeStopped(tab, elementId) {
+ // After pausing media, the stop timer would be triggered and stop the media
+ // control.
+ return Promise.all([
+ new Promise(r => (tab.controller.ondeactivated = r)),
+ SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ content.document.getElementById(Id).pause();
+ }),
+ ]);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js b/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js
new file mode 100644
index 0000000000..2312e90c88
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_media_control_supported_keys.js
@@ -0,0 +1,130 @@
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+const sDefaultSupportedKeys = ["focus", "play", "pause", "playpause", "stop"];
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.media.mediasession.enabled", true],
+ ],
+ });
+});
+
+/**
+ * Supported media keys are used for indicating what UI button should be shown
+ * on the virtual control interface. All supported media keys are listed in
+ * `MediaKey` in `MediaController.webidl`. Some media keys are defined as
+ * default media keys which are always supported. Otherwise, other media keys
+ * have to have corresponding action handler on the active media session in
+ * order to be added to the supported keys.
+ */
+add_task(async function testDefaultSupportedKeys() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`should use default supported keys`);
+ await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testNoActionHandlerBeingSet() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create media session but not set any action handler`);
+ await setMediaSessionSupportedAction(tab, []);
+
+ info(
+ `should use default supported keys even if ` +
+ `media session doesn't have any action handler`
+ );
+ await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSettingActionsWhichAreAlreadyDefaultKeys() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create media session but not set any action handler`);
+ await setMediaSessionSupportedAction(tab, ["play", "pause", "stop"]);
+
+ info(
+ `those actions has already been included in default supported keys, so ` +
+ `the result should still be default supported keys`
+ );
+ await supportedKeysShouldEqualTo(tab, sDefaultSupportedKeys);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testSettingActionsWhichAreNotDefaultKeys() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY);
+
+ info(`start media`);
+ await playMedia(tab, testVideoId);
+
+ info(`create media session but not set any action handler`);
+ let nonDefaultActions = [
+ "seekbackward",
+ "seekforward",
+ "previoustrack",
+ "nexttrack",
+ ];
+ await setMediaSessionSupportedAction(tab, nonDefaultActions);
+
+ info(
+ `supported keys should include those actions which are not default supported keys`
+ );
+ let expectedKeys = sDefaultSupportedKeys.concat(nonDefaultActions);
+ await supportedKeysShouldEqualTo(tab, expectedKeys);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+async function supportedKeysShouldEqualTo(tab, expectedKeys) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ const supportedKeys = controller.supportedKeys;
+ while (JSON.stringify(expectedKeys) != JSON.stringify(supportedKeys)) {
+ await new Promise(r => (controller.onsupportedkeyschange = r));
+ }
+ for (let idx = 0; idx < expectedKeys.length; idx++) {
+ is(
+ supportedKeys[idx],
+ expectedKeys[idx],
+ `'${supportedKeys[idx]}' should equal to '${expectedKeys[idx]}'`
+ );
+ }
+}
+
+function setMediaSessionSupportedAction(tab, actions) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [actions], actionArr => {
+ for (let action of actionArr) {
+ content.navigator.mediaSession.setActionHandler(action, () => {
+ info(`set '${action}' action handler`);
+ });
+ }
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js b/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js
new file mode 100644
index 0000000000..5bd9d03c43
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js
@@ -0,0 +1,103 @@
+// Import this in order to use `triggerPictureInPicture()`.
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js",
+ this
+);
+
+const PAGE_NOSRC_MEDIA =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_no_src_media.html";
+const PAGE_ERROR_MEDIA =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_error_media.html";
+const PAGES = [PAGE_NOSRC_MEDIA, PAGE_ERROR_MEDIA];
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * To ensure the no src media and media with error won't activate the media
+ * controller even if they enters PIP mode or fullscreen.
+ */
+add_task(async function testNoSrcOrErrorMediaEntersPIPMode() {
+ for (let page of PAGES) {
+ info(`open media page ${page}`);
+ const tab = await createLoadedTabWrapper(page, { needCheck: false });
+
+ info(`controller should always inactive`);
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ controller.onactivated = () => {
+ ok(false, "should not get activated!");
+ };
+
+ info(`enter PIP mode which would not affect controller`);
+ const winPIP = await triggerPictureInPicture(
+ tab.linkedBrowser,
+ testVideoId
+ );
+ info(`leave PIP mode`);
+ await ensureMessageAndClosePiP(
+ tab.linkedBrowser,
+ testVideoId,
+ winPIP,
+ false
+ );
+ ok(!controller.isActive, "controller is still inactive");
+
+ info(`remove tab`);
+ await tab.close();
+ }
+});
+
+add_task(async function testNoSrcOrErrorMediaEntersFullscreen() {
+ for (let page of PAGES) {
+ info(`open media page ${page}`);
+ const tab = await createLoadedTabWrapper(page, { needCheck: false });
+
+ info(`controller should always inactive`);
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ controller.onactivated = () => {
+ ok(false, "should not get activated!");
+ };
+
+ info(`enter and leave fullscreen which would not affect controller`);
+ await enterAndLeaveFullScreen(tab, testVideoId);
+ ok(!controller.isActive, "controller is still inactive");
+
+ info(`remove tab`);
+ await tab.close();
+ }
+});
+
+/**
+ * The following is helper function.
+ */
+async function enterAndLeaveFullScreen(tab, elementId) {
+ await new Promise(resolve =>
+ SimpleTest.waitForFocus(resolve, tab.linkedBrowser.ownerGlobal)
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [elementId], elementId => {
+ return new Promise(r => {
+ const element = content.document.getElementById(elementId);
+ ok(!content.document.fullscreenElement, "no fullscreen element");
+ element.requestFullscreen();
+ element.onfullscreenchange = () => {
+ if (content.document.fullscreenElement) {
+ element.onfullscreenerror = null;
+ content.document.exitFullscreen();
+ } else {
+ element.onfullscreenchange = null;
+ element.onfullscreenerror = null;
+ r();
+ }
+ };
+ element.onfullscreenerror = () => {
+ // Retry until the element successfully enters fullscreen.
+ element.requestFullscreen();
+ };
+ });
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js b/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js
new file mode 100644
index 0000000000..76cf8b0ffd
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_only_control_non_real_time_media.js
@@ -0,0 +1,76 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_empty_title.html";
+
+/**
+ * This test is used to ensure that real-time media won't be affected by the
+ * media control. Only non-real-time media would.
+ */
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+add_task(async function testOnlyControlNonRealTimeMedia() {
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ await StartRealTimeMedia(tab);
+ ok(
+ !controller.isActive,
+ "starting a real-time media won't acivate controller"
+ );
+
+ info(`playing a non-real-time media would activate controller`);
+ await Promise.all([
+ new Promise(r => (controller.onactivated = r)),
+ startNonRealTimeMedia(tab),
+ ]);
+
+ info(`'pause' action should only pause non-real-time media`);
+ MediaControlService.generateMediaControlKey("pause");
+ await new Promise(r => (controller.onplaybackstatechange = r));
+ await checkIfMediaAreAffectedByMediaControl(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+async function startNonRealTimeMedia(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ let video = content.document.getElementById("video");
+ if (!video) {
+ ok(false, `can not get the video element!`);
+ return;
+ }
+ await video.play();
+ });
+}
+
+async function StartRealTimeMedia(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ let videoRealTime = content.document.createElement("video");
+ content.document.body.appendChild(videoRealTime);
+ videoRealTime.srcObject = await content.navigator.mediaDevices.getUserMedia(
+ { audio: true, fake: true }
+ );
+ // We want to ensure that the checking of should the media be controlled by
+ // media control would be performed after the element finishes loading the
+ // media stream. Using `autoplay` would trigger the play invocation only
+ // after the element get enough data.
+ videoRealTime.autoplay = true;
+ await new Promise(r => (videoRealTime.onplaying = r));
+ });
+}
+
+async function checkIfMediaAreAffectedByMediaControl(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ const vids = content.document.getElementsByTagName("video");
+ for (let vid of vids) {
+ if (!vid.srcObject) {
+ ok(vid.paused, "non-real-time media should be paused");
+ } else {
+ ok(!vid.paused, "real-time media should not be affected");
+ }
+ }
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js b/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js
new file mode 100644
index 0000000000..a0aa5af4e9
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_remove_controllable_media_for_active_controller.js
@@ -0,0 +1,108 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * If an active controller has an active media session, then it can still be
+ * controlled via media key even if there is no controllable media presents.
+ * As active media session could still create other controllable media in its
+ * action handler, it should still receive media key. However, if a controller
+ * doesn't have an active media session, then it won't be controlled via media
+ * key when no controllable media presents.
+ */
+add_task(
+ async function testControllerWithActiveMediaSessionShouldStillBeActiveWhenNoControllableMediaPresents() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`play media would activate controller and media session`);
+ await setupMediaSession(tab);
+ await playMedia(tab, testVideoId);
+ await checkOrWaitControllerBecomesActive(tab);
+
+ info(`remove playing media so we don't have any controllable media now`);
+ await Promise.all([
+ new Promise(r => (tab.controller.onplaybackstatechange = r)),
+ removePlayingMedia(tab),
+ ]);
+
+ info(`despite that, controller should still be active`);
+ await checkOrWaitControllerBecomesActive(tab);
+
+ info(`active media session can still receive media key`);
+ await ensureActiveMediaSessionReceivedMediaKey(tab);
+
+ info(`remove tab`);
+ await tab.close();
+ }
+);
+
+add_task(
+ async function testControllerWithoutActiveMediaSessionShouldBecomeInactiveWhenNoControllableMediaPresents() {
+ info(`open media page`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+
+ info(`play media would activate controller`);
+ await playMedia(tab, testVideoId);
+ await checkOrWaitControllerBecomesActive(tab);
+
+ info(
+ `remove playing media so we don't have any controllable media, which would deactivate controller`
+ );
+ await Promise.all([
+ new Promise(r => (tab.controller.ondeactivated = r)),
+ removePlayingMedia(tab),
+ ]);
+
+ info(`remove tab`);
+ await tab.close();
+ }
+);
+
+/**
+ * The following are helper functions.
+ */
+function setupMediaSession(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ // except `play/pause/stop`, set an action handler for arbitrary key in
+ // order to later verify if the session receives that media key by listening
+ // to session's `onpositionstatechange`.
+ content.navigator.mediaSession.setActionHandler("seekforward", _ => {
+ content.navigator.mediaSession.setPositionState({
+ duration: 60,
+ });
+ });
+ });
+}
+
+async function ensureActiveMediaSessionReceivedMediaKey(tab) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ const positionChangePromise = new Promise(
+ r => (controller.onpositionstatechange = r)
+ );
+ MediaControlService.generateMediaControlKey("seekforward");
+ await positionChangePromise;
+ ok(true, "active media session received media key");
+}
+
+function removePlayingMedia(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [testVideoId], Id => {
+ content.document.getElementById(Id).remove();
+ });
+}
+
+async function checkOrWaitControllerBecomesActive(tab) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ if (!controller.isActive) {
+ info(`wait until controller gets activated`);
+ await new Promise(r => (controller.onactivated = r));
+ }
+ ok(controller.isActive, `controller is active`);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js b/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js
new file mode 100644
index 0000000000..58cd3f5a0f
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_resume_latest_paused_media.js
@@ -0,0 +1,189 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * This test is used to check when resuming media, we would only resume latest
+ * paused media, not all media in the page.
+ */
+add_task(async function testResumingLatestPausedMedias() {
+ info(`open media page and play all media`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ await playAllMedia(tab);
+
+ /**
+ * Pressing `pause` key would pause video1, video2, video3
+ * So resuming from media control key would affect those three media
+ */
+ info(`pressing 'pause' should pause all media`);
+ await generateMediaControlKeyEvent("pause");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`all media are latest paused, pressing 'play' should resume all`);
+ await generateMediaControlKeyEvent("play");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: false,
+ shouldVideo3BePaused: false,
+ });
+
+ info(`pause only one playing video by calling its webidl method`);
+ await pauseMedia(tab, "video3");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: false,
+ shouldVideo3BePaused: true,
+ });
+
+ /**
+ * Pressing `pause` key would pause video1, video2
+ * So resuming from media control key would affect those two media
+ */
+ info(`pressing 'pause' should pause two playing media`);
+ await generateMediaControlKeyEvent("pause");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`two media are latest paused, pressing 'play' should only affect them`);
+ await generateMediaControlKeyEvent("play");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: false,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`pause only one playing video by calling its webidl method`);
+ await pauseMedia(tab, "video2");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ /**
+ * Pressing `pause` key would pause video1
+ * So resuming from media control key would only affect one media
+ */
+ info(`pressing 'pause' should pause one playing media`);
+ await generateMediaControlKeyEvent("pause");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`one media is latest paused, pressing 'play' should only affect it`);
+ await generateMediaControlKeyEvent("play");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ /**
+ * Only one media is playing, so pausing it should not stop controlling media.
+ * We should still be able to resume it later.
+ */
+ info(`pause only playing video by calling its webidl method`);
+ await pauseMedia(tab, "video1");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: true,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`pressing 'pause' for already paused media, nothing would happen`);
+ // All media are already paused, so no need to wait for playback state change,
+ // call the method directly.
+ MediaControlService.generateMediaControlKey("pause");
+
+ info(`pressing 'play' would still affect on latest paused media`);
+ await generateMediaControlKeyEvent("play");
+ await checkMediaPausedState(tab, {
+ shouldVideo1BePaused: false,
+ shouldVideo2BePaused: true,
+ shouldVideo3BePaused: true,
+ });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+async function playAllMedia(tab) {
+ const playbackStateChangedPromise = waitUntilDisplayedPlaybackChanged();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ return new Promise(r => {
+ const videos = content.document.getElementsByTagName("video");
+ let mediaCount = 0;
+ docShell.chromeEventHandler.addEventListener(
+ "MozStartMediaControl",
+ () => {
+ if (++mediaCount == videos.length) {
+ info(`all media have started media control`);
+ r();
+ }
+ }
+ );
+ for (let video of videos) {
+ info(`play ${video.id} video`);
+ video.play();
+ }
+ });
+ });
+ await playbackStateChangedPromise;
+}
+
+async function pauseMedia(tab, videoId) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [videoId], videoId => {
+ const video = content.document.getElementById(videoId);
+ if (!video) {
+ ok(false, `can not find ${videoId}!`);
+ }
+ video.pause();
+ });
+}
+
+function checkMediaPausedState(
+ tab,
+ { shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused }
+) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused],
+ (shouldVideo1BePaused, shouldVideo2BePaused, shouldVideo3BePaused) => {
+ const video1 = content.document.getElementById("video1");
+ const video2 = content.document.getElementById("video2");
+ const video3 = content.document.getElementById("video3");
+ is(
+ video1.paused,
+ shouldVideo1BePaused,
+ "Correct paused state for video1"
+ );
+ is(
+ video2.paused,
+ shouldVideo2BePaused,
+ "Correct paused state for video2"
+ );
+ is(
+ video3.paused,
+ shouldVideo3BePaused,
+ "Correct paused state for video3"
+ );
+ }
+ );
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js b/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js
new file mode 100644
index 0000000000..3dbbee065e
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_seek_captured_audio.js
@@ -0,0 +1,59 @@
+const PAGE_NON_AUTOPLAY_MEDIA =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+
+const testVideoId = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.testingevents.enabled", true]],
+ });
+});
+
+/**
+ * Seeking a captured audio media before it starts, and it should still be able
+ * to be controlled via media key after it starts playing.
+ */
+add_task(async function testSeekAudibleCapturedMedia() {
+ info(`open new non autoplay media page`);
+ const tab = await createLoadedTabWrapper(PAGE_NON_AUTOPLAY_MEDIA);
+
+ info(`perform seek on the captured media before it starts`);
+ await captureAudio(tab, testVideoId);
+ await seekAudio(tab, testVideoId);
+
+ info(`start captured media`);
+ await playMedia(tab, testVideoId);
+
+ info(`pressing 'pause' key, captured media should be paused`);
+ await generateMediaControlKeyEvent("pause");
+ await checkOrWaitUntilMediaStoppedPlaying(tab, testVideoId);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function captureAudio(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ const context = new content.AudioContext();
+ // Capture audio from the media element to a MediaElementAudioSourceNode.
+ context.createMediaElementSource(video);
+ });
+}
+
+function seekAudio(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], async Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ video.currentTime = 0.0;
+ await new Promise(r => (video.onseeked = r));
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js b/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js
new file mode 100644
index 0000000000..cc8ccf270a
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_stop_control_after_media_reaches_to_end.js
@@ -0,0 +1,108 @@
+const PAGE_URL =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_looping_media.html";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.mediacontrol.stopcontrol.aftermediaends", true]],
+ });
+});
+
+/**
+ * This test is used to ensure that we would stop controlling media after it
+ * reaches to the end when a controller doesn't have an active media session.
+ * If a controller has an active media session, it would keep active despite
+ * media reaches to the end.
+ */
+add_task(async function testControllerShouldStopAfterMediaReachesToTheEnd() {
+ info(`open media page and play media until the end`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ await Promise.all([
+ checkIfMediaControllerBecomeInactiveAfterMediaEnds(tab),
+ playMediaUntilItReachesToTheEnd(tab),
+ ]);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testControllerWontStopAfterMediaReachesToTheEnd() {
+ info(`open media page and create media session`);
+ const tab = await createLoadedTabWrapper(PAGE_URL);
+ await createMediaSession(tab);
+
+ info(`play media until the end`);
+ await playMediaUntilItReachesToTheEnd(tab);
+
+ info(`controller is still active because of having active media session`);
+ await checkControllerIsActive(tab);
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+/**
+ * The following are helper functions.
+ */
+function checkIfMediaControllerBecomeInactiveAfterMediaEnds(tab) {
+ return new Promise(r => {
+ let activeChangedNums = 0;
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ controller.onactivated = () => {
+ is(
+ ++activeChangedNums,
+ 1,
+ `Receive ${activeChangedNums} times 'onactivechange'`
+ );
+ // We activate controller when it becomes playing, which doesn't guarantee
+ // it's already audible, so we won't check audible state here.
+ ok(controller.isActive, "controller should be active");
+ ok(controller.isPlaying, "controller should be playing");
+ };
+ controller.ondeactivated = () => {
+ is(
+ ++activeChangedNums,
+ 2,
+ `Receive ${activeChangedNums} times 'onactivechange'`
+ );
+ ok(!controller.isActive, "controller should be inactive");
+ ok(!controller.isAudible, "controller should be inaudible");
+ ok(!controller.isPlaying, "controller should be paused");
+ r();
+ };
+ });
+}
+
+function playMediaUntilItReachesToTheEnd(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const video = content.document.getElementById("video");
+ if (!video) {
+ ok(false, "can't get video");
+ }
+
+ if (video.readyState < video.HAVE_METADATA) {
+ info(`load media to get its duration`);
+ video.load();
+ await new Promise(r => (video.loadedmetadata = r));
+ }
+
+ info(`adjust the start position to faster reach to the end`);
+ ok(video.duration > 1.0, "video's duration is larger than 1.0s");
+ video.currentTime = video.duration - 1.0;
+
+ info(`play ${video.id} video`);
+ video.play();
+ await new Promise(r => (video.onended = r));
+ });
+}
+
+function createMediaSession(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ // simply create a media session, which would become the active media session later.
+ content.navigator.mediaSession;
+ });
+}
+
+function checkControllerIsActive(tab) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ ok(controller.isActive, `controller is active`);
+}
diff --git a/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js b/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js
new file mode 100644
index 0000000000..334717a2f2
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/browser_suspend_inactive_tab.js
@@ -0,0 +1,131 @@
+const PAGE_NON_AUTOPLAY =
+ "https://example.com/browser/dom/media/mediacontrol/tests/browser/file_non_autoplay.html";
+const VIDEO_ID = "video";
+
+add_task(async function setupTestingPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.mediacontrol.testingevents.enabled", true],
+ ["dom.suspend_inactive.enabled", true],
+ ["dom.audiocontext.testing", true],
+ ],
+ });
+});
+
+/**
+ * This test to used to test the feature that would suspend the inactive tab,
+ * which currently is only used on Android.
+ *
+ * Normally when tab becomes inactive, we would suspend it and stop its script
+ * from running. However, if a tab has a main controller, which indicates it
+ * might have playng media, or waiting media keys to control media, then it
+ * would not be suspended event if it's inactive.
+ *
+ * In addition, Note that, on Android, audio focus management is enabled by
+ * default, so there is only one tab being able to play at a time, which means
+ * the tab playing media always has main controller.
+ */
+add_task(async function testInactiveTabWouldBeSuspended() {
+ info(`open a tab`);
+ const tab = await createTab(PAGE_NON_AUTOPLAY);
+ await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false });
+
+ info(`tab should be suspended when it becomes inactive`);
+ setTabActive(tab, false);
+ await assertIfWindowGetSuspended(tab, { shouldBeSuspended: true });
+
+ info(`remove tab`);
+ await tab.close();
+});
+
+add_task(async function testInactiveTabEverStartPlayingWontBeSuspended() {
+ info(`open tab1 and play media`);
+ const tab1 = await createTab(PAGE_NON_AUTOPLAY, { needCheck: true });
+ await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false });
+ await playMedia(tab1, VIDEO_ID);
+
+ info(`tab with playing media won't be suspended when it becomes inactive`);
+ setTabActive(tab1, false);
+ await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false });
+
+ info(
+ `even if media is paused, keep tab running so that it could listen to media keys to control media in the future`
+ );
+ await pauseMedia(tab1, VIDEO_ID);
+ await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: false });
+
+ info(`open tab2 and play media`);
+ const tab2 = await createTab(PAGE_NON_AUTOPLAY, { needCheck: true });
+ await assertIfWindowGetSuspended(tab2, { shouldBeSuspended: false });
+ await playMedia(tab2, VIDEO_ID);
+
+ info(
+ `as inactive tab1 doesn't own main controller, it should be suspended again`
+ );
+ await assertIfWindowGetSuspended(tab1, { shouldBeSuspended: true });
+
+ info(`remove tabs`);
+ await Promise.all([tab1.close(), tab2.close()]);
+});
+
+add_task(
+ async function testInactiveTabWithRunningAudioContextWontBeSuspended() {
+ info(`open tab and start an audio context (AC)`);
+ const tab = await createTab("about:blank");
+ await startAudioContext(tab);
+ await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false });
+
+ info(`tab with running AC won't be suspended when it becomes inactive`);
+ setTabActive(tab, false);
+ await assertIfWindowGetSuspended(tab, { shouldBeSuspended: false });
+
+ info(`if AC has been suspended, then inactive tab should be suspended`);
+ await suspendAudioContext(tab);
+ await assertIfWindowGetSuspended(tab, { shouldBeSuspended: true });
+
+ info(`remove tab`);
+ await tab.close();
+ }
+);
+
+/**
+ * The following are helper functions.
+ */
+async function createTab(url, needCheck = false) {
+ const tab = await createLoadedTabWrapper(url, { needCheck });
+ return tab;
+}
+
+function assertIfWindowGetSuspended(tab, { shouldBeSuspended }) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [shouldBeSuspended],
+ expectedSuspend => {
+ const isSuspended = content.windowUtils.suspendedByBrowsingContextGroup;
+ is(
+ expectedSuspend,
+ isSuspended,
+ `window suspended state (${isSuspended}) is equal to the expected`
+ );
+ }
+ );
+}
+
+function setTabActive(tab, isActive) {
+ tab.linkedBrowser.docShellIsActive = isActive;
+}
+
+function startAudioContext(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ content.ac = new content.AudioContext();
+ await new Promise(r => (content.ac.onstatechange = r));
+ ok(content.ac.state == "running", `Audio context started running`);
+ });
+}
+
+function suspendAudioContext(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ await content.ac.suspend();
+ ok(content.ac.state == "suspended", `Audio context is suspended`);
+ });
+}
diff --git a/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html b/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html
new file mode 100644
index 0000000000..e16d5dee26
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_audio_and_inaudible_media.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>page with audible and inaudible media</title>
+</head>
+<body>
+<video id="video1" src="gizmo.mp4" loop></video>
+<video id="video2" src="gizmo.mp4" loop muted></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_autoplay.html b/dom/media/mediacontrol/tests/browser/file_autoplay.html
new file mode 100644
index 0000000000..97a58ec2a2
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_autoplay.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Autoplay page</title>
+</head>
+<body>
+<video id="autoplay" src="gizmo.mp4" autoplay loop></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_empty_title.html b/dom/media/mediacontrol/tests/browser/file_empty_title.html
new file mode 100644
index 0000000000..516c16036f
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_empty_title.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title></title>
+</head>
+<body>
+<video id="video" src="gizmo.mp4" loop></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_error_media.html b/dom/media/mediacontrol/tests/browser/file_error_media.html
new file mode 100644
index 0000000000..7f54340dd1
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_error_media.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Error media</title>
+</head>
+<body>
+<video id="video" src="bogus.ogv"></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_iframe_media.html b/dom/media/mediacontrol/tests/browser/file_iframe_media.html
new file mode 100644
index 0000000000..2d2c4fd122
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_iframe_media.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+<body>
+<video id="video" src="gizmo.mp4" loop></video>
+<script type="text/javascript">
+
+const video = document.getElementById("video");
+const w = window.opener || window.parent;
+
+window.onmessage = async event => {
+ if (event.data == "fullscreen") {
+ video.requestFullscreen();
+ video.onfullscreenchange = () => {
+ video.onfullscreenchange = null;
+ video.onfullscreenerror = null;
+ w.postMessage("entered-fullscreen", "*");
+ }
+ video.onfullscreenerror = () => {
+ // Retry until the element successfully enters fullscreen.
+ video.requestFullscreen();
+ }
+ } else if (event.data == "check-playing") {
+ if (!video.paused) {
+ w.postMessage("checked-playing", "*");
+ } else {
+ video.onplaying = () => {
+ video.onplaying = null;
+ w.postMessage("checked-playing", "*");
+ }
+ }
+ } else if (event.data == "check-pause") {
+ if (video.paused) {
+ w.postMessage("checked-pause", "*");
+ } else {
+ video.onpause = () => {
+ video.onpause = null;
+ w.postMessage("checked-pause", "*");
+ }
+ }
+ } else if (event.data == "play") {
+ await video.play();
+ w.postMessage("played", "*");
+ } else if (event.data == "pause") {
+ video.pause();
+ w.postMessage("paused", "*");
+ } else if (event.data == "setMetadata") {
+ const metadata = {
+ title: document.title,
+ artist: document.title,
+ album: document.title,
+ artwork: [{ src: document.title, sizes: "128x128", type: "image/jpeg" }],
+ };
+ navigator.mediaSession.metadata = new window.MediaMetadata(metadata);
+ w.postMessage(metadata, "*");
+ } else if (event.data == "setPositionState") {
+ navigator.mediaSession.setPositionState({
+ duration: 60, // The value doesn't matter
+ });
+ } else if (event.data.cmd == "setActionHandler") {
+ if (window.triggeredActionHandler === undefined) {
+ window.triggeredActionHandler = {};
+ }
+ const action = event.data.action;
+ window.triggeredActionHandler[action] = new Promise(r => {
+ navigator.mediaSession.setActionHandler(action, async () => {
+ if (action == "stop" || action == "pause") {
+ video.pause();
+ } else if (action == "play") {
+ await video.play();
+ }
+ r();
+ });
+ });
+ w.postMessage("setActionHandler-done", "*");
+ } else if (event.data.cmd == "checkActionHandler") {
+ const action = event.data.action;
+ if (!window.triggeredActionHandler[action]) {
+ w.postMessage("checkActionHandler-fail", "*");
+ } else {
+ await window.triggeredActionHandler[action];
+ w.postMessage("checkActionHandler-done", "*");
+ }
+ } else if (event.data == "create-media-session") {
+ // simply calling a media session would create an instance.
+ navigator.mediaSession;
+ w.postMessage("created-media-session", "*");
+ }
+}
+
+</script>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html b/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html
new file mode 100644
index 0000000000..f8e7aa9afe
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_main_frame_with_multiple_child_session_frames.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Media control page with multiple iframes which contain media session</title>
+</head>
+<body>
+<video id="video" src="gizmo.mp4" loop></video>
+<iframe id="frame1"></iframe>
+<iframe id="frame2"></iframe>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html b/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html
new file mode 100644
index 0000000000..e78fabe7fa
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_multiple_audible_media.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>mutiple audible media</title>
+</head>
+<body>
+<video id="video1" src="gizmo.mp4" loop></video>
+<video id="video2" src="gizmo.mp4" loop></video>
+<video id="video3" src="gizmo.mp4" loop></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html b/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html
new file mode 100644
index 0000000000..f64d537a46
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_muted_autoplay.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Muted autoplay page</title>
+</head>
+<body>
+<video id="autoplay" src="gizmo.mp4" autoplay loop muted></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_no_src_media.html b/dom/media/mediacontrol/tests/browser/file_no_src_media.html
new file mode 100644
index 0000000000..e1318e863c
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_no_src_media.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>No src media</title>
+</head>
+<body>
+<video id="video"></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_non_autoplay.html b/dom/media/mediacontrol/tests/browser/file_non_autoplay.html
new file mode 100644
index 0000000000..06daa7e2d8
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_non_autoplay.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Non-Autoplay page</title>
+</head>
+<body>
+<video id="video" src="gizmo.mp4" loop></video>
+<image id="image" src="data:image/svg+xml;base64,PCEtLSBUaGlzIFNvdXJjZSBDb2RlIEZvcm0gaXMgc3ViamVjdCB0byB0aGUgdGVybXMgb2YgdGhlIE1vemlsbGEgUHVibGljCiAgIC0gTGljZW5zZSwgdi4gMi4wLiBJZiBhIGNvcHkgb2YgdGhlIE1QTCB3YXMgbm90IGRpc3RyaWJ1dGVkIHdpdGggdGhpcwogICAtIGZpbGUsIFlvdSBjYW4gb2J0YWluIG9uZSBhdCBodHRwOi8vbW96aWxsYS5vcmcvTVBMLzIuMC8uIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGZpbGw9ImNvbnRleHQtZmlsbCIgZmlsbC1vcGFjaXR5PSJjb250ZXh0LWZpbGwtb3BhY2l0eSIgZD0iTTggMGE4IDggMCAxIDAgOCA4IDguMDA5IDguMDA5IDAgMCAwLTgtOHptNS4xNjMgNC45NThoLTEuNTUyYTcuNyA3LjcgMCAwIDAtMS4wNTEtMi4zNzYgNi4wMyA2LjAzIDAgMCAxIDIuNjAzIDIuMzc2ek0xNCA4YTUuOTYzIDUuOTYzIDAgMCAxLS4zMzUgMS45NThoLTEuODIxQTEyLjMyNyAxMi4zMjcgMCAwIDAgMTIgOGExMi4zMjcgMTIuMzI3IDAgMCAwLS4xNTYtMS45NThoMS44MjFBNS45NjMgNS45NjMgMCAwIDEgMTQgOHptLTYgNmMtMS4wNzUgMC0yLjAzNy0xLjItMi41NjctMi45NThoNS4xMzVDMTAuMDM3IDEyLjggOS4wNzUgMTQgOCAxNHpNNS4xNzQgOS45NThhMTEuMDg0IDExLjA4NCAwIDAgMSAwLTMuOTE2aDUuNjUxQTExLjExNCAxMS4xMTQgMCAwIDEgMTEgOGExMS4xMTQgMTEuMTE0IDAgMCAxLS4xNzQgMS45NTh6TTIgOGE1Ljk2MyA1Ljk2MyAwIDAgMSAuMzM1LTEuOTU4aDEuODIxYTEyLjM2MSAxMi4zNjEgMCAwIDAgMCAzLjkxNkgyLjMzNUE1Ljk2MyA1Ljk2MyAwIDAgMSAyIDh6bTYtNmMxLjA3NSAwIDIuMDM3IDEuMiAyLjU2NyAyLjk1OEg1LjQzM0M1Ljk2MyAzLjIgNi45MjUgMiA4IDJ6bS0yLjU2LjU4MmE3LjcgNy43IDAgMCAwLTEuMDUxIDIuMzc2SDIuODM3QTYuMDMgNi4wMyAwIDAgMSA1LjQ0IDIuNTgyem0tMi42IDguNDZoMS41NDlhNy43IDcuNyAwIDAgMCAxLjA1MSAyLjM3NiA2LjAzIDYuMDMgMCAwIDEtMi42MDMtMi4zNzZ6bTcuNzIzIDIuMzc2YTcuNyA3LjcgMCAwIDAgMS4wNTEtMi4zNzZoMS41NTJhNi4wMyA2LjAzIDAgMCAxLTIuNjA2IDIuMzc2eiIvPgo8L3N2Zz4K">
+<iframe id="iframe" allow="fullscreen *" allowfullscreen></iframe>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html b/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html
new file mode 100644
index 0000000000..bf27943fce
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_non_eligible_media.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Media are not eligible to be controlled</title>
+</head>
+<body>
+<video id="muted" src="gizmo.mp4" controls muted loop></video>
+<video id="volume-0" src="gizmo.mp4" controls loop></video>
+<video id="no-audio-track" src="gizmo-noaudio.webm" controls loop></video>
+<video id="silent-audio-track" src="silentAudioTrack.webm" controls loop></video>
+<video id="short-duration" src="gizmo-short.mp4" controls loop></video>
+<video id="inaudible-captured-media" src="gizmo.mp4" muted controls loop></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/file_non_looping_media.html b/dom/media/mediacontrol/tests/browser/file_non_looping_media.html
new file mode 100644
index 0000000000..41e049645c
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/file_non_looping_media.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Non looping media page</title>
+</head>
+<body>
+<video id="video" src="gizmo.mp4"></video>
+</body>
+</html>
diff --git a/dom/media/mediacontrol/tests/browser/head.js b/dom/media/mediacontrol/tests/browser/head.js
new file mode 100644
index 0000000000..ecfbc5c213
--- /dev/null
+++ b/dom/media/mediacontrol/tests/browser/head.js
@@ -0,0 +1,401 @@
+/**
+ * This function would create a new foreround tab and load the url for it. In
+ * addition, instead of returning a tab element, we return a tab wrapper that
+ * helps us to automatically detect if the media controller of that tab
+ * dispatches the first (activated) and the last event (deactivated) correctly.
+ * @ param url
+ * the page url which tab would load
+ * @ param input window (optional)
+ * if it exists, the tab would be created from the input window. If not,
+ * then the tab would be created in current window.
+ * @ param needCheck (optional)
+ * it decides if we would perform the check for the first and last event
+ * on the media controller. It's default true.
+ */
+async function createLoadedTabWrapper(
+ url,
+ { inputWindow = window, needCheck = true } = {}
+) {
+ class tabWrapper {
+ constructor(tab, needCheck) {
+ this._tab = tab;
+ this._controller = tab.linkedBrowser.browsingContext.mediaController;
+ this._firstEvent = "";
+ this._lastEvent = "";
+ this._events = [
+ "activated",
+ "deactivated",
+ "metadatachange",
+ "playbackstatechange",
+ "positionstatechange",
+ "supportedkeyschange",
+ ];
+ this._needCheck = needCheck;
+ if (this._needCheck) {
+ this._registerAllEvents();
+ }
+ }
+ _registerAllEvents() {
+ for (let event of this._events) {
+ this._controller.addEventListener(event, this._handleEvent.bind(this));
+ }
+ }
+ _unregisterAllEvents() {
+ for (let event of this._events) {
+ this._controller.removeEventListener(
+ event,
+ this._handleEvent.bind(this)
+ );
+ }
+ }
+ _handleEvent(event) {
+ info(`handle event=${event.type}`);
+ if (this._firstEvent === "") {
+ this._firstEvent = event.type;
+ }
+ this._lastEvent = event.type;
+ }
+ get linkedBrowser() {
+ return this._tab.linkedBrowser;
+ }
+ get controller() {
+ return this._controller;
+ }
+ get tabElement() {
+ return this._tab;
+ }
+ async close() {
+ info(`wait until finishing close tab wrapper`);
+ const deactivationPromise = this._controller.isActive
+ ? new Promise(r => (this._controller.ondeactivated = r))
+ : Promise.resolve();
+ BrowserTestUtils.removeTab(this._tab);
+ await deactivationPromise;
+ if (this._needCheck) {
+ is(this._firstEvent, "activated", "First event should be 'activated'");
+ is(
+ this._lastEvent,
+ "deactivated",
+ "Last event should be 'deactivated'"
+ );
+ this._unregisterAllEvents();
+ }
+ }
+ }
+ const browser = inputWindow ? inputWindow.gBrowser : window.gBrowser;
+ let tab = await BrowserTestUtils.openNewForegroundTab(browser, url);
+ return new tabWrapper(tab, needCheck);
+}
+
+/**
+ * Returns a promise that resolves when generated media control keys has
+ * triggered the main media controller's corresponding method and changes its
+ * playback state.
+ *
+ * @param {string} event
+ * The event name of the media control key
+ * @return {Promise}
+ * Resolve when the main controller receives the media control key event
+ * and change its playback state.
+ */
+function generateMediaControlKeyEvent(event) {
+ const playbackStateChanged = waitUntilDisplayedPlaybackChanged();
+ MediaControlService.generateMediaControlKey(event);
+ return playbackStateChanged;
+}
+
+/**
+ * Play the specific media and wait until it plays successfully and the main
+ * controller has been updated.
+ *
+ * @param {tab} tab
+ * The tab that contains the media which we would play
+ * @param {string} elementId
+ * The element Id of the media which we would play
+ * @return {Promise}
+ * Resolve when the media has been starting playing and the main
+ * controller has been updated.
+ */
+async function playMedia(tab, elementId) {
+ const playbackStatePromise = waitUntilDisplayedPlaybackChanged();
+ await SpecialPowers.spawn(tab.linkedBrowser, [elementId], async Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ ok(
+ await video.play().then(
+ _ => true,
+ _ => false
+ ),
+ "video started playing"
+ );
+ });
+ return playbackStatePromise;
+}
+
+/**
+ * Pause the specific media and wait until it pauses successfully and the main
+ * controller has been updated.
+ *
+ * @param {tab} tab
+ * The tab that contains the media which we would pause
+ * @param {string} elementId
+ * The element Id of the media which we would pause
+ * @return {Promise}
+ * Resolve when the media has been paused and the main controller has
+ * been updated.
+ */
+function pauseMedia(tab, elementId) {
+ const pausePromise = SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [elementId],
+ Id => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ ok(!video.paused, `video is playing before calling pause`);
+ video.pause();
+ }
+ );
+ return Promise.all([pausePromise, waitUntilDisplayedPlaybackChanged()]);
+}
+
+/**
+ * Returns a promise that resolves when the specific media starts playing.
+ *
+ * @param {tab} tab
+ * The tab that contains the media which we would check
+ * @param {string} elementId
+ * The element Id of the media which we would check
+ * @return {Promise}
+ * Resolve when the media has been starting playing.
+ */
+function checkOrWaitUntilMediaStartedPlaying(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ return new Promise(resolve => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ if (!video.paused) {
+ ok(true, `media started playing`);
+ resolve();
+ } else {
+ info(`wait until media starts playing`);
+ video.onplaying = () => {
+ video.onplaying = null;
+ ok(true, `media started playing`);
+ resolve();
+ };
+ }
+ });
+ });
+}
+
+/**
+ * Returns a promise that resolves when the specific media stops playing.
+ *
+ * @param {tab} tab
+ * The tab that contains the media which we would check
+ * @param {string} elementId
+ * The element Id of the media which we would check
+ * @return {Promise}
+ * Resolve when the media has been stopped playing.
+ */
+function checkOrWaitUntilMediaStoppedPlaying(tab, elementId) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [elementId], Id => {
+ return new Promise(resolve => {
+ const video = content.document.getElementById(Id);
+ if (!video) {
+ ok(false, `can't get the media element!`);
+ }
+ if (video.paused) {
+ ok(true, `media stopped playing`);
+ resolve();
+ } else {
+ info(`wait until media stops playing`);
+ video.onpause = () => {
+ video.onpause = null;
+ ok(true, `media stopped playing`);
+ resolve();
+ };
+ }
+ });
+ });
+}
+
+/**
+ * Check if the active metadata is empty.
+ */
+function isCurrentMetadataEmpty() {
+ const current = MediaControlService.getCurrentActiveMediaMetadata();
+ is(current.title, "", `current title should be empty`);
+ is(current.artist, "", `current title should be empty`);
+ is(current.album, "", `current album should be empty`);
+ is(current.artwork.length, 0, `current artwork should be empty`);
+}
+
+/**
+ * Check if the active metadata is equal to the given metadata.artwork
+ *
+ * @param {object} metadata
+ * The metadata that would be compared with the active metadata
+ */
+function isCurrentMetadataEqualTo(metadata) {
+ const current = MediaControlService.getCurrentActiveMediaMetadata();
+ is(
+ current.title,
+ metadata.title,
+ `tile '${current.title}' is equal to ${metadata.title}`
+ );
+ is(
+ current.artist,
+ metadata.artist,
+ `artist '${current.artist}' is equal to ${metadata.artist}`
+ );
+ is(
+ current.album,
+ metadata.album,
+ `album '${current.album}' is equal to ${metadata.album}`
+ );
+ is(
+ current.artwork.length,
+ metadata.artwork.length,
+ `artwork length '${current.artwork.length}' is equal to ${metadata.artwork.length}`
+ );
+ for (let idx = 0; idx < metadata.artwork.length; idx++) {
+ // the current src we got would be a completed path of the image, so we do
+ // not check if they are equal, we check if the current src includes the
+ // metadata's file name. Eg. "http://foo/bar.jpg" v.s. "bar.jpg"
+ ok(
+ current.artwork[idx].src.includes(metadata.artwork[idx].src),
+ `artwork src '${current.artwork[idx].src}' includes ${metadata.artwork[idx].src}`
+ );
+ is(
+ current.artwork[idx].sizes,
+ metadata.artwork[idx].sizes,
+ `artwork sizes '${current.artwork[idx].sizes}' is equal to ${metadata.artwork[idx].sizes}`
+ );
+ is(
+ current.artwork[idx].type,
+ metadata.artwork[idx].type,
+ `artwork type '${current.artwork[idx].type}' is equal to ${metadata.artwork[idx].type}`
+ );
+ }
+}
+
+/**
+ * Check if the given tab is using the default metadata. If the tab is being
+ * used in the private browsing mode, `isPrivateBrowsing` should be definded in
+ * the `options`.
+ */
+async function isGivenTabUsingDefaultMetadata(tab, options = {}) {
+ const localization = new Localization([
+ "branding/brand.ftl",
+ "dom/media.ftl",
+ ]);
+ const fallbackTitle = await localization.formatValue(
+ "mediastatus-fallback-title"
+ );
+ ok(fallbackTitle.length, "l10n fallback title is not empty");
+
+ const metadata = tab.linkedBrowser.browsingContext.mediaController.getMetadata();
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [metadata.title, fallbackTitle, options.isPrivateBrowsing],
+ (title, fallbackTitle, isPrivateBrowsing) => {
+ if (isPrivateBrowsing || !content.document.title.length) {
+ is(title, fallbackTitle, "Using a generic default fallback title");
+ } else {
+ is(
+ title,
+ content.document.title,
+ "Using website title as a default title"
+ );
+ }
+ }
+ );
+ is(metadata.artwork.length, 1, "Default metada contains one artwork");
+ ok(
+ metadata.artwork[0].src.includes("defaultFavicon.svg"),
+ "Using default favicon as a default art work"
+ );
+}
+
+/**
+ * Wait until the main media controller changes its playback state, we would
+ * observe that by listening for `media-displayed-playback-changed`
+ * notification.
+ *
+ * @return {Promise}
+ * Resolve when observing `media-displayed-playback-changed`
+ */
+function waitUntilDisplayedPlaybackChanged() {
+ return BrowserUtils.promiseObserved("media-displayed-playback-changed");
+}
+
+/**
+ * Wait until the metadata that would be displayed on the virtual control
+ * interface changes. we would observe that by listening for
+ * `media-displayed-metadata-changed` notification.
+ *
+ * @return {Promise}
+ * Resolve when observing `media-displayed-metadata-changed`
+ */
+function waitUntilDisplayedMetadataChanged() {
+ return BrowserUtils.promiseObserved("media-displayed-metadata-changed");
+}
+
+/**
+ * Wait until the main media controller has been changed, we would observe that
+ * by listening for the `main-media-controller-changed` notification.
+ *
+ * @return {Promise}
+ * Resolve when observing `main-media-controller-changed`
+ */
+function waitUntilMainMediaControllerChanged() {
+ return BrowserUtils.promiseObserved("main-media-controller-changed");
+}
+
+/**
+ * Wait until any media controller updates its metadata even if it's not the
+ * main controller. The difference between this function and
+ * `waitUntilDisplayedMetadataChanged()` is that the changed metadata might come
+ * from non-main controller so it won't be show on the virtual control
+ * interface. we would observe that by listening for
+ * `media-session-controller-metadata-changed` notification.
+ *
+ * @return {Promise}
+ * Resolve when observing `media-session-controller-metadata-changed`
+ */
+function waitUntilControllerMetadataChanged() {
+ return BrowserUtils.promiseObserved(
+ "media-session-controller-metadata-changed"
+ );
+}
+
+/**
+ * Wait until media controller amount changes, we would observe that by
+ * listening for `media-controller-amount-changed` notification.
+ *
+ * @return {Promise}
+ * Resolve when observing `media-controller-amount-changed`
+ */
+function waitUntilMediaControllerAmountChanged() {
+ return BrowserUtils.promiseObserved("media-controller-amount-changed");
+}
+
+/**
+ * check if the media controll from given tab is active. If not, return a
+ * promise and resolve it when controller become active.
+ */
+async function checkOrWaitUntilControllerBecomeActive(tab) {
+ const controller = tab.linkedBrowser.browsingContext.mediaController;
+ if (controller.isActive) {
+ return;
+ }
+ await new Promise(r => (controller.onactivated = r));
+}
diff --git a/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h b/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h
new file mode 100644
index 0000000000..5145eb7dbb
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/MediaKeyListenerTest.h
@@ -0,0 +1,39 @@
+/* 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_MEDIAKEYLISTENERTEST_H_
+#define DOM_MEDIA_MEDIAKEYLISTENERTEST_H_
+
+#include "MediaControlKeySource.h"
+#include "mozilla/Maybe.h"
+
+namespace mozilla {
+namespace dom {
+
+class MediaKeyListenerTest : public MediaControlKeyListener {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(MediaKeyListenerTest, override)
+
+ void Clear() { mReceivedKey = mozilla::Nothing(); }
+
+ void OnActionPerformed(const MediaControlAction& aAction) override {
+ mReceivedKey = mozilla::Some(aAction.mKey);
+ }
+ bool IsResultEqualTo(MediaControlKey aResult) const {
+ if (mReceivedKey) {
+ return *mReceivedKey == aResult;
+ }
+ return false;
+ }
+ bool IsReceivedResult() const { return mReceivedKey.isSome(); }
+
+ private:
+ ~MediaKeyListenerTest() = default;
+ mozilla::Maybe<MediaControlKey> mReceivedKey;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // DOM_MEDIA_MEDIAKEYLISTENERTEST_H_
diff --git a/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp b/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp
new file mode 100644
index 0000000000..df1bb288a4
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestAudioFocusManager.cpp
@@ -0,0 +1,163 @@
+/* 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 "gtest/gtest.h"
+#include "AudioFocusManager.h"
+#include "MediaControlService.h"
+#include "mozilla/Preferences.h"
+
+using namespace mozilla::dom;
+
+#define FIRST_CONTROLLER_ID 0
+#define SECOND_CONTROLLER_ID 1
+
+// This RAII class is used to set the audio focus management pref within a test
+// and automatically revert the change when a test ends, in order not to
+// interfere other tests unexpectedly.
+class AudioFocusManagmentPrefSetterRAII {
+ public:
+ explicit AudioFocusManagmentPrefSetterRAII(bool aPrefValue) {
+ mOriginalValue = mozilla::Preferences::GetBool(mPrefName, false);
+ mozilla::Preferences::SetBool(mPrefName, aPrefValue);
+ }
+ ~AudioFocusManagmentPrefSetterRAII() {
+ mozilla::Preferences::SetBool(mPrefName, mOriginalValue);
+ }
+
+ private:
+ const char* mPrefName = "media.audioFocus.management";
+ bool mOriginalValue;
+};
+
+TEST(AudioFocusManager, TestRequestAudioFocus)
+{
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RevokeAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+}
+
+TEST(AudioFocusManager, TestAudioFocusNumsWhenEnableAudioFocusManagement)
+{
+ // When enabling audio focus management, we only allow one controller owing
+ // audio focus at a time when the audio competing occurs. As the mechanism of
+ // handling the audio competing involves multiple components, we can't test it
+ // simply by using the APIs from AudioFocusManager.
+ AudioFocusManagmentPrefSetterRAII prefSetter(true);
+
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller1 =
+ new MediaController(FIRST_CONTROLLER_ID);
+
+ RefPtr<MediaController> controller2 =
+ new MediaController(SECOND_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller1);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ // When controller2 starts, it would win the audio focus from controller1. So
+ // only one audio focus would exist.
+ manager.RequestAudioFocus(controller2);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RevokeAudioFocus(controller2);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+}
+
+TEST(AudioFocusManager, TestAudioFocusNumsWhenDisableAudioFocusManagement)
+{
+ // When disabling audio focus management, we won't handle the audio competing,
+ // so we allow multiple audio focus existing at the same time.
+ AudioFocusManagmentPrefSetterRAII prefSetter(false);
+
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller1 =
+ new MediaController(FIRST_CONTROLLER_ID);
+
+ RefPtr<MediaController> controller2 =
+ new MediaController(SECOND_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller1);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RequestAudioFocus(controller2);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 2);
+
+ manager.RevokeAudioFocus(controller1);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RevokeAudioFocus(controller2);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+}
+
+TEST(AudioFocusManager, TestRequestAudioFocusRepeatedly)
+{
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RequestAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+}
+
+TEST(AudioFocusManager, TestRevokeAudioFocusRepeatedly)
+{
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RevokeAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ manager.RevokeAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+}
+
+TEST(AudioFocusManager, TestRevokeAudioFocusWithoutRequestAudioFocus)
+{
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID);
+
+ manager.RevokeAudioFocus(controller);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+}
+
+TEST(AudioFocusManager,
+ TestRevokeAudioFocusForControllerWithoutOwningAudioFocus)
+{
+ AudioFocusManager manager;
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 0);
+
+ RefPtr<MediaController> controller1 =
+ new MediaController(FIRST_CONTROLLER_ID);
+
+ RefPtr<MediaController> controller2 =
+ new MediaController(SECOND_CONTROLLER_ID);
+
+ manager.RequestAudioFocus(controller1);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+
+ manager.RevokeAudioFocus(controller2);
+ ASSERT_TRUE(manager.GetAudioFocusNums() == 1);
+}
diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp
new file mode 100644
index 0000000000..ab5b0cb8c7
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestMediaControlService.cpp
@@ -0,0 +1,64 @@
+/* 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 "gtest/gtest.h"
+#include "MediaControlService.h"
+#include "MediaController.h"
+
+using namespace mozilla::dom;
+
+#define FIRST_CONTROLLER_ID 0
+#define SECOND_CONTROLLER_ID 1
+
+TEST(MediaControlService, TestAddOrRemoveControllers)
+{
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+
+ RefPtr<MediaController> controller1 =
+ new MediaController(FIRST_CONTROLLER_ID);
+ RefPtr<MediaController> controller2 =
+ new MediaController(SECOND_CONTROLLER_ID);
+
+ service->RegisterActiveMediaController(controller1);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 1);
+
+ service->RegisterActiveMediaController(controller2);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 2);
+
+ service->UnregisterActiveMediaController(controller1);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 1);
+
+ service->UnregisterActiveMediaController(controller2);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+}
+
+TEST(MediaControlService, TestMainController)
+{
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+
+ RefPtr<MediaController> controller1 =
+ new MediaController(FIRST_CONTROLLER_ID);
+ service->RegisterActiveMediaController(controller1);
+
+ RefPtr<MediaController> mainController = service->GetMainController();
+ ASSERT_TRUE(mainController->Id() == FIRST_CONTROLLER_ID);
+
+ RefPtr<MediaController> controller2 =
+ new MediaController(SECOND_CONTROLLER_ID);
+ service->RegisterActiveMediaController(controller2);
+
+ mainController = service->GetMainController();
+ ASSERT_TRUE(mainController->Id() == SECOND_CONTROLLER_ID);
+
+ service->UnregisterActiveMediaController(controller2);
+ mainController = service->GetMainController();
+ ASSERT_TRUE(mainController->Id() == FIRST_CONTROLLER_ID);
+
+ service->UnregisterActiveMediaController(controller1);
+ mainController = service->GetMainController();
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+ ASSERT_TRUE(!mainController);
+}
diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp
new file mode 100644
index 0000000000..5ba8d2a7d5
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestMediaController.cpp
@@ -0,0 +1,204 @@
+/* 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 "gtest/gtest.h"
+#include "MediaControlService.h"
+#include "MediaController.h"
+#include "mozilla/dom/MediaSessionBinding.h"
+
+using namespace mozilla::dom;
+
+#define CONTROLLER_ID 0
+#define FAKE_CONTEXT_ID 0
+
+#define FIRST_CONTROLLER_ID 0
+
+TEST(MediaController, DefaultValueCheck)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+ ASSERT_TRUE(!controller->IsAnyMediaBeingControlled());
+ ASSERT_TRUE(controller->Id() == CONTROLLER_ID);
+ ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None);
+ ASSERT_TRUE(!controller->IsAudible());
+}
+
+TEST(MediaController, IsAnyMediaBeingControlled)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+ ASSERT_TRUE(!controller->IsAnyMediaBeingControlled());
+
+ controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStarted);
+ ASSERT_TRUE(controller->IsAnyMediaBeingControlled());
+
+ controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStarted);
+ ASSERT_TRUE(controller->IsAnyMediaBeingControlled());
+
+ controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStopped);
+ ASSERT_TRUE(controller->IsAnyMediaBeingControlled());
+
+ controller->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStopped);
+ ASSERT_TRUE(!controller->IsAnyMediaBeingControlled());
+}
+
+class FakeControlledMedia final {
+ public:
+ explicit FakeControlledMedia(MediaController* aController)
+ : mController(aController) {
+ mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStarted);
+ }
+
+ void SetPlaying(MediaPlaybackState aState) {
+ if (mPlaybackState == aState) {
+ return;
+ }
+ mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID, aState);
+ mPlaybackState = aState;
+ }
+
+ void SetAudible(MediaAudibleState aState) {
+ if (mAudibleState == aState) {
+ return;
+ }
+ mController->NotifyMediaAudibleChanged(FAKE_CONTEXT_ID, aState);
+ mAudibleState = aState;
+ }
+
+ ~FakeControlledMedia() {
+ if (mPlaybackState == MediaPlaybackState::ePlayed) {
+ mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::ePaused);
+ }
+ mController->NotifyMediaPlaybackChanged(FAKE_CONTEXT_ID,
+ MediaPlaybackState::eStopped);
+ }
+
+ private:
+ MediaPlaybackState mPlaybackState = MediaPlaybackState::eStopped;
+ MediaAudibleState mAudibleState = MediaAudibleState::eInaudible;
+ RefPtr<MediaController> mController;
+};
+
+TEST(MediaController, ActiveAndDeactiveController)
+{
+ RefPtr<MediaControlService> service = MediaControlService::GetService();
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+
+ RefPtr<MediaController> controller = new MediaController(FIRST_CONTROLLER_ID);
+
+ // In order to check active control number after FakeControlledMedia
+ // destroyed.
+ {
+ FakeControlledMedia fakeMedia(controller);
+ fakeMedia.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 1);
+
+ fakeMedia.SetAudible(MediaAudibleState::eAudible);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 1);
+
+ fakeMedia.SetAudible(MediaAudibleState::eInaudible);
+ ASSERT_TRUE(service->GetActiveControllersNum() == 1);
+ }
+
+ ASSERT_TRUE(service->GetActiveControllersNum() == 0);
+}
+
+TEST(MediaController, AudibleChanged)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+
+ FakeControlledMedia fakeMedia(controller);
+ fakeMedia.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(!controller->IsAudible());
+
+ fakeMedia.SetAudible(MediaAudibleState::eAudible);
+ ASSERT_TRUE(controller->IsAudible());
+
+ fakeMedia.SetAudible(MediaAudibleState::eInaudible);
+ ASSERT_TRUE(!controller->IsAudible());
+}
+
+TEST(MediaController, PlayingStateChangeViaControlledMedia)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+
+ // In order to check playing state after FakeControlledMedia destroyed.
+ {
+ FakeControlledMedia foo(controller);
+ ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None);
+
+ foo.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+
+ foo.SetPlaying(MediaPlaybackState::ePaused);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Paused);
+
+ foo.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+ }
+
+ // FakeControlledMedia has been destroyed, no playing media exists.
+ ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::Paused);
+}
+
+TEST(MediaController, ControllerShouldRemainPlayingIfAnyPlayingMediaExists)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+
+ {
+ FakeControlledMedia foo(controller);
+ ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::None);
+
+ foo.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+
+ // foo is playing, so controller is in `playing` state.
+ FakeControlledMedia bar(controller);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+
+ bar.SetPlaying(MediaPlaybackState::ePlayed);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+
+ // Although we paused bar, but foo is still playing, so the controller would
+ // still be in `playing`.
+ bar.SetPlaying(MediaPlaybackState::ePaused);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Playing);
+
+ foo.SetPlaying(MediaPlaybackState::ePaused);
+ ASSERT_TRUE(controller->PlaybackState() ==
+ MediaSessionPlaybackState::Paused);
+ }
+
+ // both foo and bar have been destroyed, no playing media exists.
+ ASSERT_TRUE(controller->PlaybackState() == MediaSessionPlaybackState::Paused);
+}
+
+TEST(MediaController, PictureInPictureModeOrFullscreen)
+{
+ RefPtr<MediaController> controller = new MediaController(CONTROLLER_ID);
+ ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen());
+
+ controller->SetIsInPictureInPictureMode(FAKE_CONTEXT_ID, true);
+ ASSERT_TRUE(controller->IsBeingUsedInPIPModeOrFullscreen());
+
+ controller->SetIsInPictureInPictureMode(FAKE_CONTEXT_ID, false);
+ ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen());
+
+ controller->NotifyMediaFullScreenState(FAKE_CONTEXT_ID, true);
+ ASSERT_TRUE(controller->IsBeingUsedInPIPModeOrFullscreen());
+
+ controller->NotifyMediaFullScreenState(FAKE_CONTEXT_ID, false);
+ ASSERT_TRUE(!controller->IsBeingUsedInPIPModeOrFullscreen());
+}
diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp
new file mode 100644
index 0000000000..a73ceb8592
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEvent.cpp
@@ -0,0 +1,49 @@
+/* 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 "gtest/gtest.h"
+#include "MediaController.h"
+#include "MediaControlKeySource.h"
+
+using namespace mozilla::dom;
+
+class MediaControlKeySourceTestImpl : public MediaControlKeySource {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(MediaControlKeySourceTestImpl, override)
+ bool Open() override { return true; }
+ bool IsOpened() const override { return true; }
+ void SetSupportedMediaKeys(const MediaKeysArray& aSupportedKeys) override {}
+
+ private:
+ ~MediaControlKeySourceTestImpl() = default;
+};
+
+TEST(MediaControlKey, TestAddOrRemoveListener)
+{
+ RefPtr<MediaControlKeySource> source = new MediaControlKeySourceTestImpl();
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaControlKeyListener> listener = new MediaControlKeyHandler();
+
+ source->AddListener(listener);
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+
+ source->RemoveListener(listener);
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+}
+
+TEST(MediaControlKey, SetSourcePlaybackState)
+{
+ RefPtr<MediaControlKeySource> source = new MediaControlKeySourceTestImpl();
+ ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::None);
+
+ source->SetPlaybackState(MediaSessionPlaybackState::Playing);
+ ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::Playing);
+
+ source->SetPlaybackState(MediaSessionPlaybackState::Paused);
+ ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::Paused);
+
+ source->SetPlaybackState(MediaSessionPlaybackState::None);
+ ASSERT_TRUE(source->GetPlaybackState() == MediaSessionPlaybackState::None);
+}
diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm
new file mode 100644
index 0000000000..3c8ce8cd9e
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMac.mm
@@ -0,0 +1,136 @@
+/* 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/. */
+
+#import <AppKit/AppKit.h>
+#import <AppKit/NSEvent.h>
+#import <ApplicationServices/ApplicationServices.h>
+#import <CoreFoundation/CoreFoundation.h>
+#import <IOKit/hidsystem/ev_keymap.h>
+
+#include "gtest/gtest.h"
+#include "MediaHardwareKeysEventSourceMac.h"
+#include "MediaKeyListenerTest.h"
+#include "mozilla/Maybe.h"
+
+using namespace mozilla::dom;
+using namespace mozilla::widget;
+
+static const int kSystemDefinedEventMediaKeysSubtype = 8;
+
+static void SendFakeEvent(RefPtr<MediaHardwareKeysEventSourceMac>& aSource, int aKeyData) {
+ NSEvent* event = [NSEvent otherEventWithType:NSEventTypeSystemDefined
+ location:NSZeroPoint
+ modifierFlags:0
+ timestamp:0
+ windowNumber:0
+ context:nil
+ subtype:kSystemDefinedEventMediaKeysSubtype
+ data1:aKeyData
+ data2:0];
+ aSource->EventTapCallback(nullptr, static_cast<CGEventType>(0), [event CGEvent], aSource.get());
+}
+
+static void NotifyFakeNonMediaKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource,
+ bool aIsKeyPressed) {
+ int keyData = 0 | ((aIsKeyPressed ? 0xA : 0xB) << 8);
+ SendFakeEvent(aSource, keyData);
+}
+
+static void NotifyFakeMediaControlKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource,
+ MediaControlKey aEvent, bool aIsKeyPressed) {
+ int keyData = 0;
+ if (aEvent == MediaControlKey::Playpause) {
+ keyData = NX_KEYTYPE_PLAY << 16;
+ } else if (aEvent == MediaControlKey::Nexttrack) {
+ keyData = NX_KEYTYPE_NEXT << 16;
+ } else if (aEvent == MediaControlKey::Previoustrack) {
+ keyData = NX_KEYTYPE_PREVIOUS << 16;
+ }
+ keyData |= ((aIsKeyPressed ? 0xA : 0xB) << 8);
+ SendFakeEvent(aSource, keyData);
+}
+
+static void NotifyKeyPressedMediaKey(RefPtr<MediaHardwareKeysEventSourceMac>& aSource,
+ MediaControlKey aEvent) {
+ NotifyFakeMediaControlKey(aSource, aEvent, true /* key pressed */);
+}
+
+static void NotifyKeyReleasedMediaKeysEvent(RefPtr<MediaHardwareKeysEventSourceMac>& aSource,
+ MediaControlKey aEvent) {
+ NotifyFakeMediaControlKey(aSource, aEvent, false /* key released */);
+}
+
+static void NotifyKeyPressedNonMediaKeysEvents(RefPtr<MediaHardwareKeysEventSourceMac>& aSource) {
+ NotifyFakeNonMediaKey(aSource, true /* key pressed */);
+}
+
+static void NotifyKeyReleasedNonMediaKeysEvents(RefPtr<MediaHardwareKeysEventSourceMac>& aSource) {
+ NotifyFakeNonMediaKey(aSource, false /* key released */);
+}
+
+TEST(MediaHardwareKeysEventSourceMac, TestKeyPressedMediaKeysEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac();
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+ source->AddListener(listener.get());
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyPressedMediaKey(source, MediaControlKey::Playpause);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause));
+
+ NotifyKeyPressedMediaKey(source, MediaControlKey::Nexttrack);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Nexttrack));
+
+ NotifyKeyPressedMediaKey(source, MediaControlKey::Previoustrack);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Previoustrack));
+
+ source->RemoveListener(listener);
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+}
+
+TEST(MediaHardwareKeysEventSourceMac, TestKeyReleasedMediaKeysEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac();
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+ source->AddListener(listener.get());
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Playpause);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Nexttrack);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyReleasedMediaKeysEvent(source, MediaControlKey::Previoustrack);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ source->RemoveListener(listener);
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+}
+
+TEST(MediaHardwareKeysEventSourceMac, TestNonMediaKeysEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMac> source = new MediaHardwareKeysEventSourceMac();
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+ source->AddListener(listener.get());
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyPressedNonMediaKeysEvents(source);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ NotifyKeyReleasedNonMediaKeysEvents(source);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+
+ source->RemoveListener(listener);
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+}
diff --git a/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm
new file mode 100644
index 0000000000..d71490d00e
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm
@@ -0,0 +1,164 @@
+/* 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/. */
+#import <MediaPlayer/MediaPlayer.h>
+
+#include "MediaHardwareKeysEventSourceMacMediaCenter.h"
+#include "MediaKeyListenerTest.h"
+#include "nsCocoaFeatures.h"
+#include "nsCocoaUtils.h"
+#include "prinrval.h"
+#include "prthread.h"
+
+using namespace mozilla::dom;
+
+NS_ASSUME_NONNULL_BEGIN
+
+TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPlayPauseEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source =
+ new MediaHardwareKeysEventSourceMacMediaCenter();
+
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+
+ MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter];
+
+ source->AddListener(listener.get());
+
+ ASSERT_TRUE(source->Open());
+
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying);
+
+ MediaCenterEventHandler playPauseHandler = source->CreatePlayPauseHandler();
+ playPauseHandler(nil);
+
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePaused);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause));
+
+ listener->Clear(); // Reset stored media key
+
+ playPauseHandler(nil);
+
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Playpause));
+}
+
+TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPlayEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source =
+ new MediaHardwareKeysEventSourceMacMediaCenter();
+
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+
+ MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter];
+
+ source->AddListener(listener.get());
+
+ ASSERT_TRUE(source->Open());
+
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying);
+
+ MediaCenterEventHandler playHandler = source->CreatePlayHandler();
+
+ center.playbackState = MPNowPlayingPlaybackStatePaused;
+
+ playHandler(nil);
+
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Play));
+}
+
+TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPauseEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source =
+ new MediaHardwareKeysEventSourceMacMediaCenter();
+
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+
+ MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter];
+
+ source->AddListener(listener.get());
+
+ ASSERT_TRUE(source->Open());
+
+ ASSERT_TRUE(source->GetListenersNum() == 1);
+ ASSERT_TRUE(!listener->IsReceivedResult());
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePlaying);
+
+ MediaCenterEventHandler pauseHandler = source->CreatePauseHandler();
+
+ pauseHandler(nil);
+
+ ASSERT_TRUE(center.playbackState == MPNowPlayingPlaybackStatePaused);
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Pause));
+}
+
+TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestMediaCenterPrevNextEvent)
+{
+ RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source =
+ new MediaHardwareKeysEventSourceMacMediaCenter();
+
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+
+ source->AddListener(listener.get());
+
+ ASSERT_TRUE(source->Open());
+
+ MediaCenterEventHandler nextHandler = source->CreateNextTrackHandler();
+
+ nextHandler(nil);
+
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Nexttrack));
+
+ MediaCenterEventHandler previousHandler = source->CreatePreviousTrackHandler();
+
+ previousHandler(nil);
+
+ ASSERT_TRUE(listener->IsResultEqualTo(MediaControlKey::Previoustrack));
+}
+
+TEST(MediaHardwareKeysEventSourceMacMediaCenter, TestSetMetadata)
+{
+ RefPtr<MediaHardwareKeysEventSourceMacMediaCenter> source =
+ new MediaHardwareKeysEventSourceMacMediaCenter();
+
+ ASSERT_TRUE(source->GetListenersNum() == 0);
+
+ RefPtr<MediaKeyListenerTest> listener = new MediaKeyListenerTest();
+
+ source->AddListener(listener.get());
+
+ ASSERT_TRUE(source->Open());
+
+ MediaMetadataBase metadata;
+ metadata.mTitle = u"MediaPlayback";
+ metadata.mArtist = u"Firefox";
+ metadata.mAlbum = u"Mozilla";
+ source->SetMediaMetadata(metadata);
+
+ // The update procedure of nowPlayingInfo is async, so wait for a second
+ // before checking the result.
+ PR_Sleep(PR_SecondsToInterval(1));
+ MPNowPlayingInfoCenter* center = [MPNowPlayingInfoCenter defaultCenter];
+ ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyTitle] isEqualToString:@"MediaPlayback"]);
+ ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyArtist] isEqualToString:@"Firefox"]);
+ ASSERT_TRUE([center.nowPlayingInfo[MPMediaItemPropertyAlbumTitle] isEqualToString:@"Mozilla"]);
+
+ source->Close();
+ PR_Sleep(PR_SecondsToInterval(1));
+ ASSERT_TRUE(center.nowPlayingInfo == nil);
+}
+
+NS_ASSUME_NONNULL_END
diff --git a/dom/media/mediacontrol/tests/gtest/moz.build b/dom/media/mediacontrol/tests/gtest/moz.build
new file mode 100644
index 0000000000..90bb9a70c9
--- /dev/null
+++ b/dom/media/mediacontrol/tests/gtest/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/.
+
+UNIFIED_SOURCES += [
+ "TestAudioFocusManager.cpp",
+ "TestMediaController.cpp",
+ "TestMediaControlService.cpp",
+ "TestMediaKeysEvent.cpp",
+]
+
+if CONFIG["MOZ_APPLEMEDIA"]:
+ REQUIRES_UNIFIED_BUILD = True
+ UNIFIED_SOURCES += ["TestMediaKeysEventMac.mm", "TestMediaKeysEventMediaCenter.mm"]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+LOCAL_INCLUDES += [
+ "/dom/media/mediacontrol",
+]
+
+FINAL_LIBRARY = "xul-gtest"