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