diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /dom/media/mediacontrol | |
parent | Initial commit. (diff) | |
download | firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/media/mediacontrol')
69 files changed, 9868 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..292c2f521f --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_before_media_starts.js @@ -0,0 +1,205 @@ +// Import this in order to use `triggerPictureInPicture()`. +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..9ddd3d7a18 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_main_controller.js @@ -0,0 +1,341 @@ +// Import this in order to use `triggerPictureInPicture()`. +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..75a0d80ac9 --- /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.loadURIString(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..ab18eab634 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_non_eligible_media.js @@ -0,0 +1,204 @@ +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()`. +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..34fc10badd --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_media_control_stop_timer.js @@ -0,0 +1,79 @@ +// Import this in order to use `triggerPictureInPicture()`. +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..837b0ecda6 --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/browser_nosrc_and_error_media.js @@ -0,0 +1,102 @@ +// Import this in order to use `triggerPictureInPicture()`. +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..cac96c0bff --- /dev/null +++ b/dom/media/mediacontrol/tests/browser/head.js @@ -0,0 +1,402 @@ +/** + * 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..1d32960d2f --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/TestMediaKeysEventMediaCenter.mm @@ -0,0 +1,166 @@ +/* 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 "gtest/gtest.h" +#include "MediaHardwareKeysEventSourceMacMediaCenter.h" +#include "MediaKeyListenerTest.h" +#include "nsCocoaFeatures.h" +#include "nsCocoaUtils.h" +#include "prinrval.h" +#include "prthread.h" + +using namespace mozilla::dom; +using namespace mozilla::widget; + +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..7043bfcd5e --- /dev/null +++ b/dom/media/mediacontrol/tests/gtest/moz.build @@ -0,0 +1,23 @@ +# -*- 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"]: + UNIFIED_SOURCES += ["TestMediaKeysEventMac.mm", "TestMediaKeysEventMediaCenter.mm"] + +include("/ipc/chromium/chromium-config.mozbuild") + +LOCAL_INCLUDES += [ + "/dom/media/mediacontrol", +] + +FINAL_LIBRARY = "xul-gtest" |