/* -*- 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 bc = BrowsingContext::Get(Id()); return bc; } JSObject* MediaController::WrapObject(JSContext* aCx, JS::Handle aGivenProto) { return MediaController_Binding::Wrap(aCx, this, aGivenProto); } void MediaController::GetSupportedKeys( nsTArray& 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& 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 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 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 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 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 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 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 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 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& aSupportedAction) { // Convert actions to keys, some of them have been included in the supported // keys, such as "play", "pause" and "stop". nsTArray 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 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 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 = 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 asyncDispatcher = new AsyncEventDispatcher(this, aEvent); asyncDispatcher->PostDOMEvent(); } CopyableTArray MediaController::GetSupportedMediaKeys() const { return mSupportedKeys; } void MediaController::Select() const { if (RefPtr bc = BrowsingContext::Get(Id())) { bc->Canonical()->AddPageAwakeRequest(); } } void MediaController::Unselect() const { if (RefPtr bc = BrowsingContext::Get(Id())) { bc->Canonical()->RemovePageAwakeRequest(); } } } // namespace mozilla::dom