blob: 5368dd0b64c89ba820cb11dd2e258a0ce990cee3 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ui/global_media_controls/media_notification_service.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "chrome/browser/media/router/media_router_feature.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/browser_list.h"
#include "chrome/browser/ui/global_media_controls/media_dialog_delegate.h"
#include "chrome/browser/ui/global_media_controls/media_notification_container_impl.h"
#include "chrome/browser/ui/global_media_controls/media_notification_service_observer.h"
#include "chrome/browser/ui/global_media_controls/overlay_media_notification.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "components/media_message_center/media_notification_item.h"
#include "components/media_message_center/media_notification_util.h"
#include "components/media_message_center/media_session_notification_item.h"
#include "components/ukm/content/source_url_recorder.h"
#include "content/public/browser/media_session.h"
#include "content/public/browser/media_session_service.h"
#include "media/base/media_switches.h"
#include "services/media_session/public/mojom/media_session.mojom.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
namespace {
// The maximum number of actions we will record to UKM for a specific source.
constexpr int kMaxActionsRecordedToUKM = 100;
constexpr int kAutoDismissTimerInMinutesDefault = 60; // minutes
constexpr const char kAutoDismissTimerInMinutesParamName[] = "timer_in_minutes";
// Returns the time value to be used for the auto-dismissing of the
// notifications after they are inactive.
// If the feature (auto-dismiss) is disabled, the returned value will be
// TimeDelta::Max() which is the largest int64 possible.
base::TimeDelta GetAutoDismissTimerValue() {
if (!base::FeatureList::IsEnabled(media::kGlobalMediaControlsAutoDismiss))
return base::TimeDelta::Max();
return base::TimeDelta::FromMinutes(base::GetFieldTrialParamByFeatureAsInt(
media::kGlobalMediaControlsAutoDismiss,
kAutoDismissTimerInMinutesParamName, kAutoDismissTimerInMinutesDefault));
}
// Here we check to see if the WebContents is focused. Note that since Session
// is a WebContentsObserver, we could in theory listen for
// |OnWebContentsFocused()| and |OnWebContentsLostFocus()|. However, this won't
// actually work since focusing the MediaDialogView causes the WebContents to
// "lose focus", so we'd never be focused.
bool IsWebContentsFocused(content::WebContents* web_contents) {
DCHECK(web_contents);
Browser* browser = chrome::FindBrowserWithWebContents(web_contents);
if (!browser)
return false;
// If the given WebContents is not in the focused window, then it's not
// focused. Note that we know a Browser is focused because otherwise the user
// could not interact with the MediaDialogView.
if (BrowserList::GetInstance()->GetLastActive() != browser)
return false;
return browser->tab_strip_model()->GetActiveWebContents() == web_contents;
}
base::WeakPtr<media_router::WebContentsPresentationManager>
GetPresentationManager(content::WebContents* web_contents) {
return web_contents
? media_router::WebContentsPresentationManager::Get(web_contents)
: nullptr;
}
} // anonymous namespace
MediaNotificationService::Session::Session(
MediaNotificationService* owner,
const std::string& id,
std::unique_ptr<media_message_center::MediaSessionNotificationItem> item,
content::WebContents* web_contents,
mojo::Remote<media_session::mojom::MediaController> controller)
: content::WebContentsObserver(web_contents),
owner_(owner),
id_(id),
item_(std::move(item)),
presentation_manager_(GetPresentationManager(web_contents)) {
DCHECK(owner_);
DCHECK(item_);
SetController(std::move(controller));
if (presentation_manager_)
presentation_manager_->AddObserver(this);
}
MediaNotificationService::Session::~Session() {
if (presentation_manager_)
presentation_manager_->RemoveObserver(this);
// If we've been marked inactive, then we've already recorded inactivity as
// the dismiss reason.
if (is_marked_inactive_)
return;
RecordDismissReason(dismiss_reason_.value_or(
GlobalMediaControlsDismissReason::kMediaSessionStopped));
}
void MediaNotificationService::Session::WebContentsDestroyed() {
// If the WebContents is destroyed, then we should just remove the item
// instead of freezing it.
set_dismiss_reason(GlobalMediaControlsDismissReason::kTabClosed);
owner_->RemoveItem(id_);
}
void MediaNotificationService::Session::MediaSessionInfoChanged(
media_session::mojom::MediaSessionInfoPtr session_info) {
is_playing_ =
session_info && session_info->playback_state ==
media_session::mojom::MediaPlaybackState::kPlaying;
// If we've started playing, we don't want the inactive timer to be running.
if (is_playing_) {
if (inactive_timer_.IsRunning() || is_marked_inactive_) {
MarkActiveIfNecessary();
RecordInteractionDelayAfterPause();
inactive_timer_.Stop();
}
return;
}
// If we're in an overlay, then we don't want to count the session as
// inactive.
// TODO(https://crbug.com/1032841): This means we won't record interaction
// delays. Consider changing to record them.
if (is_in_overlay_)
return;
// If the timer is already running, we don't need to do anything.
if (inactive_timer_.IsRunning())
return;
last_interaction_time_ = base::TimeTicks::Now();
StartInactiveTimer();
}
void MediaNotificationService::Session::MediaSessionPositionChanged(
const base::Optional<media_session::MediaPosition>& position) {
OnSessionInteractedWith();
}
void MediaNotificationService::Session::OnMediaRoutesChanged(
const std::vector<media_router::MediaRoute>& routes) {
if (!routes.empty())
item_->Dismiss();
}
void MediaNotificationService::Session::SetController(
mojo::Remote<media_session::mojom::MediaController> controller) {
if (controller.is_bound()) {
observer_receiver_.reset();
controller->AddObserver(observer_receiver_.BindNewPipeAndPassRemote());
}
}
void MediaNotificationService::Session::set_dismiss_reason(
GlobalMediaControlsDismissReason reason) {
DCHECK(!dismiss_reason_.has_value());
dismiss_reason_ = reason;
}
void MediaNotificationService::Session::OnSessionInteractedWith() {
// If we're not currently tracking inactive time, then no action is needed.
if (!inactive_timer_.IsRunning() && !is_marked_inactive_)
return;
MarkActiveIfNecessary();
RecordInteractionDelayAfterPause();
last_interaction_time_ = base::TimeTicks::Now();
// Otherwise, reset the timer.
inactive_timer_.Stop();
StartInactiveTimer();
}
void MediaNotificationService::Session::OnSessionOverlayStateChanged(
bool is_in_overlay) {
is_in_overlay_ = is_in_overlay;
if (is_in_overlay_) {
// If we enter an overlay, then we don't want the session to be marked
// inactive.
if (inactive_timer_.IsRunning()) {
RecordInteractionDelayAfterPause();
inactive_timer_.Stop();
}
} else if (!is_playing_ && !inactive_timer_.IsRunning()) {
// If we exit an overlay and the session is paused, then the session is
// inactive.
StartInactiveTimer();
}
}
bool MediaNotificationService::Session::IsPlaying() {
return is_playing_;
}
// static
void MediaNotificationService::Session::RecordDismissReason(
GlobalMediaControlsDismissReason reason) {
base::UmaHistogramEnumeration("Media.GlobalMediaControls.DismissReason",
reason);
}
void MediaNotificationService::Session::StartInactiveTimer() {
DCHECK(!inactive_timer_.IsRunning());
// Using |base::Unretained()| here is okay since |this| owns
// |inactive_timer_|.
// If the feature is disabled, the timer will run forever, in order for the
// rest of the code to continue running as expected.
inactive_timer_.Start(
FROM_HERE, GetAutoDismissTimerValue(),
base::BindOnce(&MediaNotificationService::Session::OnInactiveTimerFired,
base::Unretained(this)));
}
void MediaNotificationService::Session::OnInactiveTimerFired() {
// Overlay notifications should never be marked as inactive.
DCHECK(!is_in_overlay_);
// If the session has been paused and inactive for long enough, then mark it
// as inactive.
is_marked_inactive_ = true;
RecordDismissReason(GlobalMediaControlsDismissReason::kInactiveTimeout);
owner_->OnSessionBecameInactive(id_);
}
void MediaNotificationService::Session::RecordInteractionDelayAfterPause() {
base::TimeDelta time_since_last_interaction =
base::TimeTicks::Now() - last_interaction_time_;
base::UmaHistogramCustomTimes(
"Media.GlobalMediaControls.InteractionDelayAfterPause",
time_since_last_interaction, base::TimeDelta::FromMinutes(1),
base::TimeDelta::FromDays(1), 100);
}
void MediaNotificationService::Session::MarkActiveIfNecessary() {
if (!is_marked_inactive_)
return;
is_marked_inactive_ = false;
owner_->OnSessionBecameActive(id_);
}
MediaNotificationService::MediaNotificationService(Profile* profile)
: overlay_media_notifications_manager_(this) {
if (base::FeatureList::IsEnabled(media::kGlobalMediaControlsForCast) &&
media_router::MediaRouterEnabled(profile)) {
cast_notification_provider_ =
std::make_unique<CastMediaNotificationProvider>(
profile, this,
base::BindRepeating(
&MediaNotificationService::OnCastNotificationsChanged,
base::Unretained(this)));
}
const base::UnguessableToken& source_id =
content::MediaSession::GetSourceId(profile);
// Connect to the controller manager so we can create media controllers for
// media sessions.
content::GetMediaSessionService().BindMediaControllerManager(
controller_manager_remote_.BindNewPipeAndPassReceiver());
// Connect to receive audio focus events.
content::GetMediaSessionService().BindAudioFocusManager(
audio_focus_remote_.BindNewPipeAndPassReceiver());
audio_focus_remote_->AddSourceObserver(
source_id, audio_focus_observer_receiver_.BindNewPipeAndPassRemote());
audio_focus_remote_->GetSourceFocusRequests(
source_id,
base::BindOnce(&MediaNotificationService::OnReceivedAudioFocusRequests,
weak_ptr_factory_.GetWeakPtr()));
}
MediaNotificationService::~MediaNotificationService() {
for (auto container_pair : observed_containers_)
container_pair.second->RemoveObserver(this);
}
void MediaNotificationService::AddObserver(
MediaNotificationServiceObserver* observer) {
observers_.AddObserver(observer);
}
void MediaNotificationService::RemoveObserver(
MediaNotificationServiceObserver* observer) {
observers_.RemoveObserver(observer);
}
void MediaNotificationService::OnFocusGained(
media_session::mojom::AudioFocusRequestStatePtr session) {
const std::string id = session->request_id->ToString();
// If we have an existing unfrozen item then this is a duplicate call and
// we should ignore it.
auto it = sessions_.find(id);
if (it != sessions_.end() && !it->second.item()->frozen())
return;
mojo::Remote<media_session::mojom::MediaController> item_controller;
mojo::Remote<media_session::mojom::MediaController> session_controller;
controller_manager_remote_->CreateMediaControllerForSession(
item_controller.BindNewPipeAndPassReceiver(), *session->request_id);
controller_manager_remote_->CreateMediaControllerForSession(
session_controller.BindNewPipeAndPassReceiver(), *session->request_id);
if (it != sessions_.end()) {
// If the notification was previously frozen then we should reset the
// controller because the mojo pipe would have been reset.
it->second.SetController(std::move(session_controller));
it->second.item()->SetController(std::move(item_controller),
std::move(session->session_info));
} else {
sessions_.emplace(
std::piecewise_construct, std::forward_as_tuple(id),
std::forward_as_tuple(
this, id,
std::make_unique<
media_message_center::MediaSessionNotificationItem>(
this, id, session->source_name.value_or(std::string()),
std::move(item_controller), std::move(session->session_info)),
content::MediaSession::GetWebContentsFromRequestId(
*session->request_id),
std::move(session_controller)));
}
}
void MediaNotificationService::OnFocusLost(
media_session::mojom::AudioFocusRequestStatePtr session) {
const std::string id = session->request_id->ToString();
auto it = sessions_.find(id);
if (it == sessions_.end())
return;
// If we're not currently showing this item, then we can just remove it.
if (!base::Contains(active_controllable_session_ids_, id) &&
!base::Contains(frozen_session_ids_, id) &&
!base::Contains(dragged_out_session_ids_, id)) {
RemoveItem(id);
return;
}
// Otherwise, freeze it in case it regains focus quickly.
it->second.item()->Freeze(base::BindOnce(
&MediaNotificationService::OnItemUnfrozen, base::Unretained(this), id));
active_controllable_session_ids_.erase(id);
frozen_session_ids_.insert(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
}
void MediaNotificationService::ShowNotification(const std::string& id) {
// If the notification is currently hidden because it's inactive or because
// it's in an overlay notification, then do nothing.
if (base::Contains(dragged_out_session_ids_, id) ||
base::Contains(inactive_session_ids_, id)) {
return;
}
active_controllable_session_ids_.insert(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
if (!dialog_delegate_)
return;
base::WeakPtr<media_message_center::MediaNotificationItem> item =
GetNotificationItem(id);
MediaNotificationContainerImpl* container =
dialog_delegate_->ShowMediaSession(id, item);
// Observe the container for dismissal.
if (container) {
container->AddObserver(this);
observed_containers_[id] = container;
}
}
void MediaNotificationService::HideNotification(const std::string& id) {
active_controllable_session_ids_.erase(id);
frozen_session_ids_.erase(id);
if (base::Contains(dragged_out_session_ids_, id)) {
overlay_media_notifications_manager_.CloseOverlayNotification(id);
dragged_out_session_ids_.erase(id);
}
for (auto& observer : observers_)
observer.OnNotificationListChanged();
if (!dialog_delegate_)
return;
dialog_delegate_->HideMediaSession(id);
}
scoped_refptr<base::SequencedTaskRunner>
MediaNotificationService::GetTaskRunner() const {
return nullptr;
}
void MediaNotificationService::RemoveItem(const std::string& id) {
active_controllable_session_ids_.erase(id);
frozen_session_ids_.erase(id);
inactive_session_ids_.erase(id);
if (base::Contains(dragged_out_session_ids_, id)) {
overlay_media_notifications_manager_.CloseOverlayNotification(id);
dragged_out_session_ids_.erase(id);
}
sessions_.erase(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
}
void MediaNotificationService::LogMediaSessionActionButtonPressed(
const std::string& id,
media_session::mojom::MediaSessionAction action) {
auto it = sessions_.find(id);
if (it == sessions_.end())
return;
content::WebContents* web_contents = it->second.web_contents();
if (!web_contents)
return;
base::UmaHistogramBoolean("Media.GlobalMediaControls.UserActionFocus",
IsWebContentsFocused(web_contents));
ukm::UkmRecorder* recorder = ukm::UkmRecorder::Get();
ukm::SourceId source_id =
ukm::GetSourceIdForWebContentsDocument(web_contents);
if (++actions_recorded_to_ukm_[source_id] > kMaxActionsRecordedToUKM)
return;
ukm::builders::Media_GlobalMediaControls_ActionButtonPressed(source_id)
.SetMediaSessionAction(static_cast<int64_t>(action))
.Record(recorder);
}
void MediaNotificationService::OnContainerClicked(const std::string& id) {
auto it = sessions_.find(id);
if (it == sessions_.end())
return;
it->second.OnSessionInteractedWith();
content::WebContents* web_contents = it->second.web_contents();
if (!web_contents)
return;
content::WebContentsDelegate* delegate = web_contents->GetDelegate();
if (!delegate)
return;
delegate->ActivateContents(web_contents);
}
void MediaNotificationService::OnContainerDismissed(const std::string& id) {
// If the notification is dragged out, then dismissing should just close the
// overlay notification.
if (base::Contains(dragged_out_session_ids_, id)) {
overlay_media_notifications_manager_.CloseOverlayNotification(id);
return;
}
auto it = sessions_.find(id);
if (it == sessions_.end()) {
if (!cast_notification_provider_)
return;
base::WeakPtr<media_message_center::MediaNotificationItem> cast_item =
cast_notification_provider_->GetNotificationItem(id);
if (cast_item)
cast_item->Dismiss();
return;
}
it->second.set_dismiss_reason(
GlobalMediaControlsDismissReason::kUserDismissedNotification);
it->second.item()->Dismiss();
}
void MediaNotificationService::OnContainerDestroyed(const std::string& id) {
auto iter = observed_containers_.find(id);
DCHECK(iter != observed_containers_.end());
iter->second->RemoveObserver(this);
observed_containers_.erase(iter);
}
void MediaNotificationService::OnContainerDraggedOut(const std::string& id,
gfx::Rect bounds) {
// If the session has been destroyed, no action is needed.
auto it = sessions_.find(id);
if (it == sessions_.end())
return;
// Inform the Session that it's in an overlay so should not timeout as
// inactive.
it->second.OnSessionOverlayStateChanged(/*is_in_overlay=*/true);
if (!dialog_delegate_)
return;
std::unique_ptr<OverlayMediaNotification> overlay_notification =
dialog_delegate_->PopOut(id, bounds);
if (!overlay_notification)
return;
overlay_media_notifications_manager_.ShowOverlayNotification(
id, std::move(overlay_notification));
active_controllable_session_ids_.erase(id);
dragged_out_session_ids_.insert(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
}
void MediaNotificationService::Shutdown() {
// |cast_notification_provider_| depends on MediaRouter, which is another
// keyed service.
cast_notification_provider_.reset();
}
void MediaNotificationService::OnOverlayNotificationClosed(
const std::string& id) {
// If the session has been destroyed, no action is needed.
auto it = sessions_.find(id);
if (it == sessions_.end())
return;
it->second.OnSessionOverlayStateChanged(/*is_in_overlay=*/false);
// Since the overlay is closing, we no longer need to observe the associated
// container.
auto observed_iter = observed_containers_.find(id);
if (observed_iter != observed_containers_.end()) {
observed_iter->second->RemoveObserver(this);
observed_containers_.erase(observed_iter);
}
// Otherwise, if it's a non-frozen item, then it's now an active one.
if (!base::Contains(frozen_session_ids_, id))
active_controllable_session_ids_.insert(id);
dragged_out_session_ids_.erase(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
// If there's a dialog currently open, then we should show the item in the
// dialog.
if (!dialog_delegate_)
return;
MediaNotificationContainerImpl* container =
dialog_delegate_->ShowMediaSession(id, it->second.item()->GetWeakPtr());
if (container) {
container->AddObserver(this);
observed_containers_[id] = container;
}
}
void MediaNotificationService::OnCastNotificationsChanged() {
for (auto& observer : observers_)
observer.OnNotificationListChanged();
}
void MediaNotificationService::SetDialogDelegate(
MediaDialogDelegate* delegate) {
DCHECK(!delegate || !dialog_delegate_);
dialog_delegate_ = delegate;
for (auto& observer : observers_)
observer.OnMediaDialogOpenedOrClosed();
if (!dialog_delegate_)
return;
std::list<std::string> sorted_session_ids;
for (const std::string& id : active_controllable_session_ids_) {
if (sessions_.find(id)->second.IsPlaying())
sorted_session_ids.push_front(id);
else
sorted_session_ids.push_back(id);
}
for (const std::string& id : sorted_session_ids) {
base::WeakPtr<media_message_center::MediaNotificationItem> item =
GetNotificationItem(id);
MediaNotificationContainerImpl* container =
dialog_delegate_->ShowMediaSession(id, item);
// Observe the container for dismissal.
if (container) {
container->AddObserver(this);
observed_containers_[id] = container;
}
}
media_message_center::RecordConcurrentNotificationCount(
active_controllable_session_ids_.size());
if (cast_notification_provider_) {
media_message_center::RecordConcurrentCastNotificationCount(
cast_notification_provider_->GetItemCount());
}
}
bool MediaNotificationService::HasActiveNotifications() const {
return !active_controllable_session_ids_.empty();
}
bool MediaNotificationService::HasFrozenNotifications() const {
return !frozen_session_ids_.empty();
}
bool MediaNotificationService::HasOpenDialog() const {
return !!dialog_delegate_;
}
void MediaNotificationService::OnSessionBecameActive(const std::string& id) {
DCHECK(base::Contains(inactive_session_ids_, id));
auto it = sessions_.find(id);
DCHECK(it != sessions_.end());
inactive_session_ids_.erase(id);
if (it->second.item()->frozen())
frozen_session_ids_.insert(id);
else
active_controllable_session_ids_.insert(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
// If there's a dialog currently open, then we should show the item in the
// dialog.
if (!dialog_delegate_)
return;
MediaNotificationContainerImpl* container =
dialog_delegate_->ShowMediaSession(id, it->second.item()->GetWeakPtr());
if (container) {
container->AddObserver(this);
observed_containers_[id] = container;
}
}
void MediaNotificationService::OnSessionBecameInactive(const std::string& id) {
// If this session is already marked inactive, then there's nothing to do.
if (base::Contains(inactive_session_ids_, id))
return;
inactive_session_ids_.insert(id);
HideNotification(id);
}
void MediaNotificationService::OnItemUnfrozen(const std::string& id) {
frozen_session_ids_.erase(id);
if (!base::Contains(dragged_out_session_ids_, id))
active_controllable_session_ids_.insert(id);
for (auto& observer : observers_)
observer.OnNotificationListChanged();
}
void MediaNotificationService::OnReceivedAudioFocusRequests(
std::vector<media_session::mojom::AudioFocusRequestStatePtr> sessions) {
for (auto& session : sessions)
OnFocusGained(std::move(session));
}
base::WeakPtr<media_message_center::MediaNotificationItem>
MediaNotificationService::GetNotificationItem(const std::string& id) {
auto it = sessions_.find(id);
if (it != sessions_.end()) {
return it->second.item()->GetWeakPtr();
} else if (cast_notification_provider_) {
return cast_notification_provider_->GetNotificationItem(id);
}
return nullptr;
}