| // Copyright 2015 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "content/browser/media/session/media_session_impl.h" |
| |
| #include <algorithm> |
| #include <memory> |
| #include <utility> |
| |
| #include "base/containers/contains.h" |
| #include "base/functional/bind.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/time/time.h" |
| #include "base/timer/timer.h" |
| #include "build/build_config.h" |
| #include "components/url_formatter/url_formatter.h" |
| #include "content/browser/media/session/audio_focus_delegate.h" |
| #include "content/browser/media/session/media_players_callback_aggregator.h" |
| #include "content/browser/media/session/media_session_controller.h" |
| #include "content/browser/media/session/media_session_player_observer.h" |
| #include "content/browser/media/session/media_session_service_impl.h" |
| #include "content/browser/picture_in_picture/video_picture_in_picture_window_controller_impl.h" |
| #include "content/browser/renderer_host/back_forward_cache_disable.h" |
| #include "content/browser/renderer_host/back_forward_cache_impl.h" |
| #include "content/browser/web_contents/web_contents_impl.h" |
| #include "content/public/browser/browser_context.h" |
| #include "content/public/browser/media_session.h" |
| #include "content/public/browser/media_session_client.h" |
| #include "content/public/browser/navigation_handle.h" |
| #include "content/public/browser/web_contents.h" |
| #include "content/public/browser/web_contents_delegate.h" |
| #include "content/public/common/content_client.h" |
| #include "media/audio/audio_device_description.h" |
| #include "media/base/media_content_type.h" |
| #include "media/base/media_switches.h" |
| #include "media/base/picture_in_picture_events_info.h" |
| #include "mojo/public/cpp/bindings/callback_helpers.h" |
| #include "services/media_session/public/cpp/media_image_manager.h" |
| #include "services/media_session/public/mojom/audio_focus.mojom.h" |
| #include "third_party/blink/public/common/features.h" |
| #include "third_party/blink/public/mojom/favicon/favicon_url.mojom.h" |
| #include "third_party/blink/public/strings/grit/blink_strings.h" |
| #include "ui/gfx/favicon_size.h" |
| |
| #if BUILDFLAG(IS_ANDROID) |
| #include "content/browser/media/session/media_session_android.h" |
| #endif // BUILDFLAG(IS_ANDROID) |
| |
| #if BUILDFLAG(IS_WIN) |
| #include "content/public/common/content_features.h" |
| #endif // BUILDFLAG(IS_WIN) |
| |
| namespace content { |
| |
| using blink::mojom::MediaSessionPlaybackState; |
| using media_session::mojom::MediaAudioVideoState; |
| using media_session::mojom::MediaPlaybackState; |
| using media_session::mojom::MediaSessionImageType; |
| using media_session::mojom::MediaSessionInfo; |
| |
| namespace { |
| |
| const double kUnduckedVolumeMultiplier = 1.0; |
| const double kDefaultDuckingVolumeMultiplier = 0.2; |
| |
| const char kDebugInfoOwnerSeparator[] = " - "; |
| |
| using MapRenderFrameHostToDepth = std::map<RenderFrameHost*, size_t>; |
| |
| using media_session::mojom::AudioFocusType; |
| |
| const char kMediaSessionDataName[] = "MediaSessionDataName"; |
| |
| class MediaSessionData : public base::SupportsUserData::Data { |
| public: |
| MediaSessionData() = default; |
| |
| MediaSessionData(const MediaSessionData&) = delete; |
| MediaSessionData& operator=(const MediaSessionData&) = delete; |
| |
| static MediaSessionData* GetOrCreate(BrowserContext* context) { |
| auto* data = static_cast<MediaSessionData*>( |
| context->GetUserData(kMediaSessionDataName)); |
| |
| if (!data) { |
| auto new_data = std::make_unique<MediaSessionData>(); |
| data = new_data.get(); |
| context->SetUserData(kMediaSessionDataName, std::move(new_data)); |
| } |
| |
| return data; |
| } |
| |
| static MediaSessionData* Get(BrowserContext* context) { |
| return static_cast<MediaSessionData*>( |
| context->GetUserData(kMediaSessionDataName)); |
| } |
| |
| const base::UnguessableToken& source_id() const { return source_id_; } |
| |
| private: |
| base::UnguessableToken source_id_ = base::UnguessableToken::Create(); |
| }; |
| |
| size_t ComputeFrameDepth(RenderFrameHost* rfh, |
| MapRenderFrameHostToDepth* map_rfh_to_depth) { |
| DCHECK(rfh); |
| size_t depth = 0; |
| RenderFrameHost* current_frame = rfh; |
| while (current_frame) { |
| auto it = map_rfh_to_depth->find(current_frame); |
| if (it != map_rfh_to_depth->end()) { |
| depth += it->second; |
| break; |
| } |
| ++depth; |
| current_frame = current_frame->GetParentOrOuterDocument(); |
| } |
| (*map_rfh_to_depth)[rfh] = depth; |
| return depth; |
| } |
| |
| // If the string is not empty then push it to the back of a vector. |
| void MaybePushBackString(std::vector<std::string>& vector, |
| const std::string& str) { |
| if (!str.empty()) |
| vector.push_back(str); |
| } |
| |
| bool IsSizeAtLeast(const gfx::Size& size, int min_size) { |
| return size.width() >= min_size || size.height() >= min_size; |
| } |
| |
| bool IsSizesAtLeast(const std::vector<gfx::Size>& sizes, int min_size) { |
| // If we haven't found an image based on size then we should check if there |
| // are any images that have no size data or have an "any" size which is |
| // denoted by a single empty gfx::Size value. |
| if (sizes.size() == 0 || (sizes.size() == 1 && sizes[0].IsEmpty())) |
| return true; |
| |
| bool check_size = false; |
| for (auto& size : sizes) |
| check_size = check_size || IsSizeAtLeast(size, min_size); |
| return check_size; |
| } |
| |
| std::u16string SanitizeMediaTitle(const std::u16string& title) { |
| std::u16string out; |
| base::TrimString(title, u" ", &out); |
| return out; |
| } |
| |
| } // anonymous namespace |
| |
| constexpr int MediaSessionImpl::kDurationUpdateMaxAllowance; |
| constexpr base::TimeDelta |
| MediaSessionImpl::kDurationUpdateAllowanceIncreaseInterval; |
| |
| MediaSessionImpl::PlayerIdentifier::PlayerIdentifier( |
| MediaSessionPlayerObserver* observer, |
| int player_id) |
| : observer(observer), player_id(player_id) {} |
| |
| // static |
| MediaSession* MediaSession::Get(WebContents* web_contents) { |
| return MediaSessionImpl::Get(web_contents); |
| } |
| |
| // static |
| MediaSession* MediaSession::GetIfExists(WebContents* contents) { |
| return MediaSessionImpl::FromWebContents(contents); |
| } |
| |
| // static |
| const base::UnguessableToken& MediaSession::GetSourceId( |
| BrowserContext* browser_context) { |
| return MediaSessionData::GetOrCreate(browser_context)->source_id(); |
| } |
| |
| const base::UnguessableToken* MediaSession::MaybeGetSourceId( |
| BrowserContext* browser_context) { |
| auto* data = MediaSessionData::Get(browser_context); |
| return data ? &data->source_id() : nullptr; |
| } |
| |
| // static |
| WebContents* MediaSession::GetWebContentsFromRequestId( |
| const base::UnguessableToken& request_id) { |
| DCHECK_NE(base::UnguessableToken::Null(), request_id); |
| for (WebContentsImpl* web_contents : WebContentsImpl::GetAllWebContents()) { |
| MediaSessionImpl* session = MediaSessionImpl::FromWebContents(web_contents); |
| if (!session) |
| continue; |
| if (session->GetRequestId() == request_id) |
| return web_contents; |
| } |
| return nullptr; |
| } |
| |
| // static |
| WebContents* MediaSession::GetWebContentsFromRequestId( |
| const std::string& request_id) { |
| for (WebContentsImpl* web_contents : WebContentsImpl::GetAllWebContents()) { |
| MediaSessionImpl* session = MediaSessionImpl::FromWebContents(web_contents); |
| if (!session) |
| continue; |
| if (session->GetRequestId().ToString() == request_id) |
| return web_contents; |
| } |
| return nullptr; |
| } |
| |
| // static |
| const base::UnguessableToken& MediaSession::GetRequestIdFromWebContents( |
| WebContents* web_contents) { |
| DCHECK(web_contents); |
| MediaSessionImpl* session = MediaSessionImpl::FromWebContents(web_contents); |
| return session ? session->GetRequestId() : base::UnguessableToken::Null(); |
| } |
| |
| // static |
| void MediaSession::FlushObserversForTesting(WebContents* web_contents) { |
| DCHECK(web_contents); |
| MediaSessionImpl* session = MediaSessionImpl::FromWebContents(web_contents); |
| session->flush_observers_for_testing(); // IN-TEST |
| } |
| |
| // static |
| MediaSessionImpl* MediaSessionImpl::Get(WebContents* web_contents) { |
| MediaSessionImpl* session = FromWebContents(web_contents); |
| if (!session) { |
| CreateForWebContents(web_contents); |
| session = FromWebContents(web_contents); |
| session->Initialize(); |
| static_cast<WebContentsImpl*>(web_contents)->MediaSessionCreated(session); |
| } |
| return session; |
| } |
| |
| MediaSessionImpl::~MediaSessionImpl() { |
| DCHECK(normal_players_.empty()); |
| DCHECK(one_shot_players_.empty()); |
| DCHECK(audio_focus_state_ == State::INACTIVE); |
| } |
| |
| #if BUILDFLAG(IS_ANDROID) |
| void MediaSessionImpl::ClearMediaSessionAndroid() { |
| session_android_.reset(); |
| } |
| |
| MediaSessionAndroid* MediaSessionImpl::GetMediaSessionAndroid() { |
| return session_android_.get(); |
| } |
| #endif |
| |
| void MediaSessionImpl::WebContentsDestroyed() { |
| delegate_->ReleaseRequestId(); |
| |
| // This should only work for tests. In production, all the players should have |
| // already been removed before WebContents is destroyed. |
| |
| // TODO(zqzhang): refactor MediaSessionImpl, maybe move the interface used to |
| // talk with AudioFocusManager out to a seperate class. The AudioFocusManager |
| // unit tests then could mock the interface and abandon audio focus when |
| // WebContents is destroyed. See https://crbug.com/651069 |
| normal_players_.clear(); |
| one_shot_players_.clear(); |
| ambient_players_.clear(); |
| |
| AbandonSystemAudioFocusIfNeeded(); |
| |
| GetContentClient()->browser()->RemovePresentationObserver(this, |
| web_contents()); |
| } |
| |
| void MediaSessionImpl::RenderFrameDeleted(RenderFrameHost* rfh) { |
| const auto rfh_id = rfh->GetGlobalId(); |
| if (services_.count(rfh_id)) |
| OnServiceDestroyed(services_[rfh_id]); |
| } |
| |
| void MediaSessionImpl::DidFinishNavigation( |
| NavigationHandle* navigation_handle) { |
| if (!navigation_handle->HasCommitted() || |
| navigation_handle->IsSameDocument()) { |
| return; |
| } |
| |
| auto new_origin = url::Origin::Create(navigation_handle->GetURL()); |
| if (navigation_handle->IsInPrimaryMainFrame() && |
| !new_origin.IsSameOriginWith(origin_)) { |
| audio_device_id_for_origin_.reset(); |
| origin_ = new_origin; |
| } |
| |
| const auto rfh_id = navigation_handle->GetRenderFrameHost()->GetGlobalId(); |
| if (services_.count(rfh_id)) |
| services_[rfh_id]->DidFinishNavigation(); |
| |
| RebuildAndNotifyMetadataChanged(); |
| } |
| |
| void MediaSessionImpl::OnWebContentsFocused(RenderWidgetHost*) { |
| focused_ = true; |
| |
| #if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_MAC) |
| // If we have just gained focus and we have audio focus we should re-request |
| // system audio focus. This will ensure this media session is towards the top |
| // of the stack if we have multiple sessions active at the same time. |
| if (audio_focus_state_ == State::ACTIVE) |
| RequestSystemAudioFocus(desired_audio_focus_type_); |
| #endif |
| } |
| |
| void MediaSessionImpl::OnWebContentsLostFocus(RenderWidgetHost*) { |
| focused_ = false; |
| } |
| |
| void MediaSessionImpl::TitleWasSet(NavigationEntry* entry) { |
| RebuildAndNotifyMetadataChanged(); |
| } |
| |
| void MediaSessionImpl::DidUpdateFaviconURL( |
| RenderFrameHost* rfh, |
| const std::vector<blink::mojom::FaviconURLPtr>& candidates) { |
| std::vector<media_session::MediaImage> icons; |
| |
| for (auto& icon : candidates) { |
| // We only want either favicons or the touch icons. There is another type of |
| // touch icon which is "precomposed". This means it might have rounded |
| // corners, etc. but it is not predictable so we cannot show them in the UI. |
| if (icon->icon_type != blink::mojom::FaviconIconType::kFavicon && |
| icon->icon_type != blink::mojom::FaviconIconType::kTouchIcon) { |
| continue; |
| } |
| |
| std::vector<gfx::Size> sizes = icon->icon_sizes; |
| |
| // If we are a favicon and we do not have a size then we should assume the |
| // default size for favicons. |
| if (icon->icon_type == blink::mojom::FaviconIconType::kFavicon && |
| sizes.empty()) |
| sizes.push_back(gfx::Size(gfx::kFaviconSize, gfx::kFaviconSize)); |
| |
| if (sizes.empty() || !icon->icon_url.is_valid()) |
| continue; |
| |
| media_session::MediaImage image; |
| image.src = icon->icon_url; |
| image.sizes = sizes; |
| icons.push_back(image); |
| } |
| |
| bool should_update_observers = true; |
| auto it = images_.find(MediaSessionImageType::kSourceIcon); |
| if (it != images_.end() && it->second == icons) { |
| should_update_observers = false; |
| } |
| |
| images_.insert_or_assign(MediaSessionImageType::kSourceIcon, icons); |
| |
| // Notify the VideoPictureInPictureWindowControllerImpl regardless of whether |
| // or not the images have actually changed, since there may or may not have |
| // been a picture-in-picture window last time we updated. |
| if (auto* pip_window_controller_ = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller_->MediaSessionImagesChanged(images_); |
| } |
| |
| if (!should_update_observers) { |
| return; |
| } |
| |
| for (auto& observer : observers_) |
| observer->MediaSessionImagesChanged(this->images_); |
| } |
| |
| void MediaSessionImpl::MediaPictureInPictureChanged( |
| bool is_picture_in_picture) { |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| uma_helper_.OnMediaPictureInPictureChanged(is_picture_in_picture); |
| } |
| |
| void MediaSessionImpl::RenderFrameHostStateChanged( |
| RenderFrameHost* host, |
| RenderFrameHost::LifecycleState old_state, |
| RenderFrameHost::LifecycleState new_state) { |
| // If the page goes to back-forward cache, hide the players. |
| if (new_state == RenderFrameHost::LifecycleState::kInBackForwardCache) { |
| // Checking the normal players is enough. One shot players are not related |
| // to media control UIs. |
| auto players = normal_players_; |
| for (auto player : players) { |
| if (player.first.observer->render_frame_host() != host) { |
| continue; |
| } |
| // RemovePlayer removes the player from not only |normal_players_| but |
| // also |hidden_players_|. Call RemovePlayer first. |
| RemovePlayer(player.first.observer, player.first.player_id); |
| hidden_players_.insert(player.first); |
| } |
| return; |
| } |
| |
| // If the page is restored from back-forward cache, show the players. |
| if (new_state == RenderFrameHost::LifecycleState::kActive) { |
| auto players = hidden_players_; |
| bool added_players = false; |
| for (auto player : players) { |
| if (player.observer->render_frame_host() != host) |
| continue; |
| hidden_players_.erase(player); |
| AddPlayer(player.observer, player.player_id); |
| added_players = true; |
| } |
| |
| // Just after adding a player, the state might be 'play'. Make sure that the |
| // state is 'pause'. |
| if (added_players) |
| OnSuspendInternal(SuspendType::kSystem, State::SUSPENDED); |
| |
| return; |
| } |
| } |
| |
| bool MediaSessionImpl::AddPlayer(MediaSessionPlayerObserver* observer, |
| int player_id) { |
| media::MediaContentType media_content_type = observer->GetMediaContentType(); |
| |
| if (media_content_type == media::MediaContentType::kOneShot) { |
| return AddOneShotPlayer(observer, player_id); |
| } else if (media_content_type == media::MediaContentType::kAmbient) { |
| return AddAmbientPlayer(observer, player_id); |
| } |
| |
| observer->OnSetVolumeMultiplier(player_id, GetVolumeMultiplier()); |
| if (audio_device_id_for_origin_) |
| observer->OnSetAudioSinkId(player_id, audio_device_id_for_origin_.value()); |
| |
| AudioFocusType required_audio_focus_type; |
| if (media_content_type == media::MediaContentType::kPersistent) |
| required_audio_focus_type = AudioFocusType::kGain; |
| else |
| required_audio_focus_type = AudioFocusType::kGainTransientMayDuck; |
| |
| PlayerIdentifier key(observer, player_id); |
| |
| // If the audio focus is already granted and is of type Content, there is |
| // nothing to do. If it is granted of type Transient the requested type is |
| // also transient, there is also nothing to do. Otherwise, the session needs |
| // to request audio focus again. |
| if (audio_focus_state_ == State::ACTIVE) { |
| std::optional<AudioFocusType> current_focus_type = |
| delegate_->GetCurrentFocusType(); |
| if (current_focus_type == AudioFocusType::kGain || |
| current_focus_type == required_audio_focus_type) { |
| auto iter = normal_players_.find(key); |
| if (iter == normal_players_.end()) |
| normal_players_.emplace(std::move(key), required_audio_focus_type); |
| else |
| iter->second = required_audio_focus_type; |
| |
| UpdateRoutedService(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| return true; |
| } |
| } |
| |
| // If this player is paused, then don't actually request audio focus on its |
| // behalf. Otherwise, we might take focus away from something else, even |
| // though we don't really need it right now. Note that we don't abandon focus |
| // when playback is paused; we will continue to hold it. However, if we have |
| // given it up, e.g. when playback is suspended, or lost it to some other |
| // request, then we don't want to take it back for a paused player. |
| // Otherwise, any random update to the player (e.g., metadata as in |
| // b/40946745) will re-request focus even while paused. |
| if (!observer->IsPaused(player_id)) { |
| State old_audio_focus_state = audio_focus_state_; |
| RequestSystemAudioFocus(required_audio_focus_type); |
| |
| if (audio_focus_state_ != State::ACTIVE) { |
| return false; |
| } |
| |
| // The session should be reset if a player is starting while all players |
| // are suspended. |
| if (old_audio_focus_state != State::ACTIVE) { |
| normal_players_.clear(); |
| } |
| } else if (audio_focus_state_ == State::INACTIVE) { |
| // We switch from `INACTIVE` to `SUSPENDED` to indicate that we want to have |
| // the focus, but don't right now. This makes the session controllable. |
| audio_focus_state_ = State::SUSPENDED; |
| } |
| |
| auto iter = normal_players_.find(key); |
| if (iter == normal_players_.end()) |
| normal_players_.emplace(std::move(key), required_audio_focus_type); |
| else |
| iter->second = required_audio_focus_type; |
| |
| UpdateRoutedService(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| |
| return true; |
| } |
| |
| void MediaSessionImpl::RemovePlayer(MediaSessionPlayerObserver* observer, |
| int player_id) { |
| const PlayerIdentifier identifier(observer, player_id); |
| normal_players_.erase(identifier); |
| one_shot_players_.erase(identifier); |
| ambient_players_.erase(identifier); |
| hidden_players_.erase(identifier); |
| |
| if (guarding_player_id_ && *guarding_player_id_ == identifier) |
| ResetDurationUpdateGuard(); |
| |
| AbandonSystemAudioFocusIfNeeded(); |
| UpdateRoutedService(); |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| } |
| |
| void MediaSessionImpl::RemovePlayers(MediaSessionPlayerObserver* observer) { |
| std::erase_if(normal_players_, [observer](const auto& player) { |
| return player.first.observer == observer; |
| }); |
| |
| base::EraseIf(one_shot_players_, [observer](const auto& player) { |
| return player.observer == observer; |
| }); |
| |
| base::EraseIf(ambient_players_, [observer](const auto& player) { |
| return player.observer == observer; |
| }); |
| |
| if (guarding_player_id_ && guarding_player_id_->observer == observer) |
| ResetDurationUpdateGuard(); |
| |
| AbandonSystemAudioFocusIfNeeded(); |
| UpdateRoutedService(); |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| } |
| |
| void MediaSessionImpl::OnPlayerPaused(MediaSessionPlayerObserver* observer, |
| int player_id) { |
| // If a playback is completed, BrowserMediaPlayerManager will call |
| // OnPlayerPaused() after RemovePlayer(). This is a workaround. |
| // Also, this method may be called when a player that is not added |
| // to this session (e.g. a silent video) is paused. MediaSessionImpl |
| // should ignore the paused player for this case. |
| PlayerIdentifier identifier(observer, player_id); |
| if (!normal_players_.count(identifier) && |
| !one_shot_players_.count(identifier) && |
| !ambient_players_.count(identifier)) { |
| return; |
| } |
| |
| // If there is more than one observer, remove the paused one from the session. |
| if (normal_players_.size() != 1) { |
| RemovePlayer(observer, player_id); |
| return; |
| } |
| |
| // If the player is a one-shot player, just remove it since it is not expected |
| // to resume a one-shot player via resuming MediaSession. |
| if (one_shot_players_.count(identifier)) { |
| RemovePlayer(observer, player_id); |
| return; |
| } |
| |
| // If the player is an ambient player, just remove it since it is not expected |
| // to resume an ambient player via resuming MediaSession. |
| if (ambient_players_.count(identifier)) { |
| RemovePlayer(observer, player_id); |
| return; |
| } |
| |
| // Otherwise, suspend the session. |
| // The session might not have audio focus if it was paused prior to being |
| // suspended, which is fine. |
| OnSuspendInternal(SuspendType::kContent, State::SUSPENDED); |
| } |
| |
| void MediaSessionImpl::RebuildAndNotifyMediaPositionChanged() { |
| std::optional<media_session::MediaPosition> position; |
| |
| // If there was a position specified from Blink then we should use that. |
| if (routed_service_ && routed_service_->position()) { |
| position = routed_service_->position(); |
| |
| // We do not throttle updates from media session API because there's |
| // no effective way to disdinguish updates from single player or |
| // different players. |
| ResetDurationUpdateGuard(); |
| } |
| |
| // Notify the VideoPictureInPictureWindowControllerImpl regardless of whether |
| // or not the position has actually changed, since there may or may not have |
| // been a picture-in-picture window last time we updated. We also must only |
| // give the VideoPictureInPictureWindowControllerImpl a position if it's been |
| // explicitly set by the website (and not calculated based on players like |
| // below), since the VideoPictureInPictureWindowControllerImpl has its own |
| // connection for its specific player which may be different. |
| if (auto* pip_window_controller = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller->MediaSessionPositionChanged(position); |
| } |
| |
| // If we only have a single player then we should use the position from that. |
| if (!position && normal_players_.size() == 1 && one_shot_players_.empty()) { |
| auto& first = normal_players_.begin()->first; |
| position = first.observer->GetPosition(first.player_id); |
| |
| if (should_throttle_duration_update_) { |
| if (!guarding_player_id_ || *guarding_player_id_ != first) { |
| ResetDurationUpdateGuard(); |
| guarding_player_id_ = first; |
| } |
| |
| position = MaybeGuardDurationUpdate(position); |
| } |
| } |
| |
| if (position == position_) |
| return; |
| |
| position_ = position; |
| |
| |
| for (auto& observer : observers_) |
| observer->MediaSessionPositionChanged(position_); |
| |
| const bool is_considered_live = |
| position_.has_value() && position_->duration().is_max(); |
| if (is_considered_live == is_considered_live_) { |
| return; |
| } |
| |
| // The available actions can be different depending on whether we're |
| // considered live or not, so if that has changed we must re-notify for the |
| // new state. |
| is_considered_live_ = is_considered_live; |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::Resume(SuspendType suspend_type) { |
| // If the site has registered an action handler for play, we should pass it to |
| // the site and let them handle it. |
| if (suspend_type == SuspendType::kUI && |
| ShouldRouteAction(media_session::mojom::MediaSessionAction::kPlay)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kPlay); |
| return; |
| } |
| |
| // When the resume requests comes from another source than system, audio focus |
| // must be requested. |
| if (suspend_type != SuspendType::kSystem) { |
| // Request audio focus again in case we lost it because another app started |
| // playing while the playback was paused. If the audio focus request is |
| // delayed we will resume the player when the request completes. |
| AudioFocusDelegate::AudioFocusResult result = |
| RequestSystemAudioFocus(desired_audio_focus_type_); |
| |
| SetAudioFocusState(result != AudioFocusDelegate::AudioFocusResult::kFailed |
| ? State::ACTIVE |
| : State::INACTIVE); |
| |
| if (audio_focus_state_ != State::ACTIVE) |
| return; |
| } else { |
| // System resume implies that we have the focus and should start playing if |
| // the system was what suspended us. Otherwise, we're suspended. |
| SetAudioFocusState((suspend_type_ == SuspendType::kSystem) |
| ? State::ACTIVE |
| : State::SUSPENDED); |
| } |
| |
| OnResumeInternal(suspend_type); |
| } |
| |
| void MediaSessionImpl::Suspend(SuspendType suspend_type) { |
| if (!IsActive()) |
| return; |
| |
| if (suspend_type == SuspendType::kUI) { |
| // If the site has registered an action handler for pause then we should |
| // pass it to the site and let them handle it. |
| if (ShouldRouteAction(media_session::mojom::MediaSessionAction::kPause)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kPause); |
| return; |
| } |
| } |
| |
| OnSuspendInternal(suspend_type, State::SUSPENDED); |
| } |
| |
| void MediaSessionImpl::Stop(SuspendType suspend_type) { |
| DCHECK(audio_focus_state_ != State::INACTIVE); |
| DCHECK(suspend_type != SuspendType::kContent); |
| |
| if (suspend_type == SuspendType::kUI) { |
| // If the site has registered an action handle for stop then we should |
| // notify the site but continue stopping the media session. |
| if (ShouldRouteAction(media_session::mojom::MediaSessionAction::kStop)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kStop); |
| } |
| } |
| |
| if (auto* pip_window_controller_ = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller_->Close(false /* should_pause_video */); |
| } |
| |
| // TODO(mlamouri): merge the logic between UI and SYSTEM. |
| if (suspend_type == SuspendType::kSystem) { |
| OnSuspendInternal(suspend_type, State::INACTIVE); |
| return; |
| } |
| |
| if (audio_focus_state_ != State::SUSPENDED) |
| OnSuspendInternal(suspend_type, State::SUSPENDED); |
| |
| DCHECK(audio_focus_state_ == State::SUSPENDED); |
| normal_players_.clear(); |
| |
| AbandonSystemAudioFocusIfNeeded(); |
| RebuildAndNotifyMediaPositionChanged(); |
| } |
| |
| void MediaSessionImpl::Seek(base::TimeDelta seek_time) { |
| DCHECK(!seek_time.is_zero()); |
| |
| if (seek_time.is_positive()) { |
| // If the site has registered an action handler for seek forward then we |
| // should pass it to the site and let them handle it. |
| if (ShouldRouteAction( |
| media_session::mojom::MediaSessionAction::kSeekForward)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kSeekForward); |
| return; |
| } |
| |
| for (const auto& it : normal_players_) |
| it.first.observer->OnSeekForward(it.first.player_id, seek_time); |
| } else if (seek_time.is_negative()) { |
| // If the site has registered an action handler for seek backward then we |
| // should pass it to the site and let them handle it. |
| if (ShouldRouteAction( |
| media_session::mojom::MediaSessionAction::kSeekBackward)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kSeekBackward); |
| return; |
| } |
| |
| for (const auto& it : normal_players_) |
| it.first.observer->OnSeekBackward(it.first.player_id, seek_time * -1); |
| } |
| } |
| |
| bool MediaSessionImpl::IsControllable() const { |
| if (audio_focus_state_ == State::INACTIVE || HasOnlyOneShotPlayers()) { |
| return false; |
| } |
| |
| #if !BUILDFLAG(IS_ANDROID) |
| if (routed_service_ && routed_service_->playback_state() != |
| blink::mojom::MediaSessionPlaybackState::NONE) { |
| return true; |
| } |
| #endif |
| |
| return desired_audio_focus_type_ == AudioFocusType::kGain; |
| } |
| |
| void MediaSessionImpl::SetDuckingVolumeMultiplier(double multiplier) { |
| ducking_volume_multiplier_ = std::clamp(multiplier, 0.0, 1.0); |
| } |
| |
| void MediaSessionImpl::SetAudioFocusGroupId( |
| const base::UnguessableToken& group_id) { |
| audio_focus_group_id_ = group_id; |
| } |
| |
| RenderFrameHost* MediaSessionImpl::GetRoutedFrame() { |
| if (routed_service_) { |
| return routed_service_->GetRenderFrameHost(); |
| } |
| return ComputeFrameForRouting(/*ensure_service=*/false); |
| } |
| |
| std::optional<media_session::MediaPosition> |
| MediaSessionImpl::GetMediaSessionPosition() { |
| return position_; |
| } |
| |
| const media_session::MediaMetadata& |
| MediaSessionImpl::GetMediaSessionMetadata() { |
| return metadata_; |
| } |
| |
| void MediaSessionImpl::StartDucking() { |
| should_unduck_on_focus_gained_ = false; |
| if (is_ducking_) |
| return; |
| is_ducking_ = true; |
| UpdateVolumeMultiplier(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::StopDucking() { |
| should_unduck_on_focus_gained_ = true; |
| if (!is_ducking_) |
| return; |
| is_ducking_ = false; |
| UpdateVolumeMultiplier(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::UpdateVolumeMultiplier() { |
| for (const auto& it : normal_players_) { |
| it.first.observer->OnSetVolumeMultiplier(it.first.player_id, |
| GetVolumeMultiplier()); |
| } |
| |
| for (const auto& it : one_shot_players_) { |
| it.observer->OnSetVolumeMultiplier(it.player_id, GetVolumeMultiplier()); |
| } |
| |
| for (const auto& it : ambient_players_) { |
| it.observer->OnSetVolumeMultiplier(it.player_id, GetVolumeMultiplier()); |
| } |
| } |
| |
| double MediaSessionImpl::GetVolumeMultiplier() const { |
| return is_ducking_ ? ducking_volume_multiplier_ : kUnduckedVolumeMultiplier; |
| } |
| |
| bool MediaSessionImpl::IsActive() const { |
| return audio_focus_state_ == State::ACTIVE; |
| } |
| |
| bool MediaSessionImpl::IsSuspended() const { |
| return audio_focus_state_ == State::SUSPENDED; |
| } |
| |
| bool MediaSessionImpl::HasOnlyOneShotPlayers() const { |
| return !one_shot_players_.empty() && normal_players_.empty(); |
| } |
| |
| void MediaSessionImpl::SetDelegateForTests( |
| std::unique_ptr<AudioFocusDelegate> delegate) { |
| delegate_ = std::move(delegate); |
| } |
| |
| MediaSessionUmaHelper* MediaSessionImpl::uma_helper_for_test() { |
| return &uma_helper_; |
| } |
| |
| void MediaSessionImpl::RemoveAllPlayersForTest() { |
| normal_players_.clear(); |
| one_shot_players_.clear(); |
| ambient_players_.clear(); |
| AbandonSystemAudioFocusIfNeeded(); |
| } |
| |
| void MediaSessionImpl::OnImageDownloadComplete( |
| GetMediaImageBitmapCallback callback, |
| int minimum_size_px, |
| int desired_size_px, |
| bool source_icon, |
| int id, |
| int http_status_code, |
| const GURL& image_url, |
| const std::vector<SkBitmap>& bitmaps, |
| const std::vector<gfx::Size>& sizes) { |
| DCHECK(bitmaps.size() == sizes.size()); |
| SkBitmap image; |
| double best_image_score = 0.0; |
| |
| // Rank |sizes| and |bitmaps| using MediaImageManager. |
| for (size_t i = 0; i < bitmaps.size(); i++) { |
| double image_score = media_session::MediaImageManager::GetImageSizeScore( |
| minimum_size_px, desired_size_px, sizes.at(i)); |
| |
| if (image_score > best_image_score) |
| image = bitmaps.at(i); |
| } |
| |
| // If the image is the wrong color type then we should convert it. |
| SkBitmap bitmap; |
| if (!image.isNull()) { |
| if (image.colorType() == kRGBA_8888_SkColorType) { |
| bitmap = image; |
| } else { |
| SkImageInfo info = image.info().makeColorType(kRGBA_8888_SkColorType); |
| if (bitmap.tryAllocPixels(info)) |
| image.readPixels(info, bitmap.getPixels(), bitmap.rowBytes(), 0, 0); |
| } |
| } |
| |
| if (source_icon) { |
| GetPageData(web_contents()->GetPrimaryPage()) |
| .AddImageCache(image_url, bitmap); |
| } |
| |
| std::move(callback).Run(bitmap); |
| } |
| |
| void MediaSessionImpl::OnSystemAudioFocusRequested(bool result) { |
| if (result && should_unduck_on_focus_gained_) { |
| StopDucking(); |
| } |
| } |
| |
| void MediaSessionImpl::OnSuspendInternal(SuspendType suspend_type, |
| State new_state) { |
| DCHECK(new_state == State::SUSPENDED || new_state == State::INACTIVE); |
| // UI suspend cannot use State::INACTIVE. |
| DCHECK(suspend_type == SuspendType::kSystem || new_state == State::SUSPENDED); |
| |
| if (HasOnlyOneShotPlayers()) { |
| return; |
| } |
| |
| if (audio_focus_state_ != State::ACTIVE) |
| return; |
| |
| SetAudioFocusState(new_state); |
| suspend_type_ = suspend_type; |
| |
| if (suspend_type != SuspendType::kContent) { |
| // SuspendType::CONTENT happens when the suspend action came from |
| // the page in which case the player is already paused. |
| // Otherwise, the players need to be paused. |
| for (const auto& it : normal_players_) |
| it.first.observer->OnSuspend(it.first.player_id); |
| } |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::OnResumeInternal(SuspendType suspend_type) { |
| if (suspend_type == SuspendType::kSystem && suspend_type_ != suspend_type) |
| return; |
| |
| for (const auto& it : normal_players_) |
| it.first.observer->OnResume(it.first.player_id); |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| MediaSessionImpl::MediaSessionImpl(WebContents* web_contents) |
| : WebContentsObserver(web_contents), |
| WebContentsUserData<MediaSessionImpl>(*web_contents), |
| audio_focus_state_(State::INACTIVE), |
| desired_audio_focus_type_(AudioFocusType::kGainTransientMayDuck), |
| is_ducking_(false), |
| ducking_volume_multiplier_(kDefaultDuckingVolumeMultiplier), |
| routed_service_(nullptr) { |
| #if BUILDFLAG(IS_ANDROID) |
| session_android_ = std::make_unique<MediaSessionAndroid>(this); |
| should_throttle_duration_update_ = true; |
| #else |
| if (base::FeatureList::IsEnabled(media::kAudioDucking)) { |
| ducking_volume_multiplier_ = |
| 1.0 - |
| (std::clamp(media::kAudioDuckingAttenuation.Get(), 0, 100) / 100.0); |
| } |
| #endif // BUILDFLAG(IS_ANDROID) |
| if (web_contents && web_contents->GetPrimaryMainFrame() && |
| web_contents->GetPrimaryMainFrame()->GetView()) { |
| focused_ = web_contents->GetPrimaryMainFrame()->GetView()->HasFocus(); |
| } |
| |
| RebuildAndNotifyMetadataChanged(); |
| } |
| |
| void MediaSessionImpl::Initialize() { |
| delegate_ = AudioFocusDelegate::Create(this); |
| delegate_->MediaSessionInfoChanged(GetMediaSessionInfoSync()); |
| |
| DCHECK(web_contents()); |
| DidUpdateFaviconURL(web_contents()->GetPrimaryMainFrame(), |
| web_contents()->GetFaviconURLs()); |
| |
| GetContentClient()->browser()->AddPresentationObserver(this, web_contents()); |
| } |
| |
| void MediaSessionImpl::OnPresentationsChanged(bool has_presentation) { |
| has_presentation_ = has_presentation; |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| AudioFocusDelegate::AudioFocusResult MediaSessionImpl::RequestSystemAudioFocus( |
| AudioFocusType audio_focus_type) { |
| // |kGainTransient| is not used in MediaSessionImpl. |
| DCHECK_NE(media_session::mojom::AudioFocusType::kGainTransient, |
| audio_focus_type); |
| |
| should_unduck_on_focus_gained_ = true; |
| |
| AudioFocusDelegate::AudioFocusResult result = |
| delegate_->RequestAudioFocus(audio_focus_type); |
| desired_audio_focus_type_ = audio_focus_type; |
| |
| bool success = result != AudioFocusDelegate::AudioFocusResult::kFailed; |
| SetAudioFocusState(success ? State::ACTIVE : State::INACTIVE); |
| |
| // If we are delayed then we should return now and wait for the response from |
| // the audio focus delegate. |
| if (result == AudioFocusDelegate::AudioFocusResult::kDelayed) |
| return result; |
| |
| OnSystemAudioFocusRequested(success); |
| return result; |
| } |
| |
| mojo::PendingRemote<media_session::mojom::MediaSession> |
| MediaSessionImpl::AddRemote() { |
| mojo::PendingRemote<media_session::mojom::MediaSession> remote; |
| receivers_.Add(this, remote.InitWithNewPipeAndPassReceiver()); |
| return remote; |
| } |
| |
| void MediaSessionImpl::GetDebugInfo(GetDebugInfoCallback callback) { |
| media_session::mojom::MediaSessionDebugInfoPtr info( |
| media_session::mojom::MediaSessionDebugInfo::New()); |
| |
| // Add the title and the url to the owner. |
| std::vector<std::string> owner_parts; |
| MaybePushBackString(owner_parts, |
| base::UTF16ToUTF8(web_contents()->GetTitle())); |
| MaybePushBackString(owner_parts, |
| web_contents()->GetLastCommittedURL().spec()); |
| info->owner = base::JoinString(owner_parts, kDebugInfoOwnerSeparator); |
| |
| std::move(callback).Run(std::move(info)); |
| } |
| |
| media_session::mojom::MediaSessionInfoPtr |
| MediaSessionImpl::GetMediaSessionInfoSync() { |
| media_session::mojom::MediaSessionInfoPtr info( |
| media_session::mojom::MediaSessionInfo::New()); |
| |
| switch (audio_focus_state_) { |
| case State::ACTIVE: |
| info->state = MediaSessionInfo::SessionState::kActive; |
| break; |
| case State::SUSPENDED: |
| info->state = MediaSessionInfo::SessionState::kSuspended; |
| break; |
| case State::INACTIVE: |
| info->state = MediaSessionInfo::SessionState::kInactive; |
| break; |
| } |
| |
| // The state should always be kDucked if we are ducked. |
| if (is_ducking_) |
| info->state = MediaSessionInfo::SessionState::kDucking; |
| |
| #if BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) |
| // If this is a webapp, and instanced media controls are on, mark this session |
| // as a pwa session so that the browser sessions can stay isolated. This is |
| // used to differentiate webapp sessions for different handling. |
| auto* web_contents_delegate = web_contents()->GetDelegate(); |
| info->ignore_for_active_session = |
| base::FeatureList::IsEnabled(features::kWebAppSystemMediaControls) && |
| web_contents_delegate && |
| web_contents_delegate->ShouldUseInstancedSystemMediaControls(); |
| #else |
| info->ignore_for_active_session = false; |
| #endif // BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) |
| |
| if (always_ignore_for_active_session_for_testing_) { |
| info->ignore_for_active_session = true; |
| } |
| |
| // The playback state should use |IsActive| to determine whether we are |
| // playing or not. However, if there is a |routed_service_| which is playing |
| // then we should force the playback state to be playing. |
| info->playback_state = |
| IsActive() ? MediaPlaybackState::kPlaying : MediaPlaybackState::kPaused; |
| if (routed_service_ && |
| routed_service_->playback_state() == MediaSessionPlaybackState::PLAYING) { |
| info->playback_state = MediaPlaybackState::kPlaying; |
| } |
| |
| info->audio_video_states = GetMediaAudioVideoStates(); |
| info->is_controllable = IsControllable(); |
| |
| info->picture_in_picture_state = |
| web_contents()->HasPictureInPictureVideo() || |
| web_contents()->HasPictureInPictureDocument() |
| ? media_session::mojom::MediaPictureInPictureState:: |
| kInPictureInPicture |
| : media_session::mojom::MediaPictureInPictureState:: |
| kNotInPictureInPicture; |
| |
| auto shared_audio_device_id = GetSharedAudioOutputDeviceId(); |
| // When the default audio device is in use, or this session's players are |
| // using different devices, the |audio_sink_id| attribute should remain unset. |
| if (shared_audio_device_id != media::AudioDeviceDescription::kDefaultDeviceId) |
| info->audio_sink_id = shared_audio_device_id; |
| |
| if (routed_service_) { |
| info->microphone_state = routed_service_->microphone_state(); |
| info->camera_state = routed_service_->camera_state(); |
| } else { |
| info->microphone_state = media_session::mojom::MicrophoneState::kUnknown; |
| info->camera_state = media_session::mojom::CameraState::kUnknown; |
| } |
| |
| info->muted = is_muted_; |
| info->has_presentation = has_presentation_; |
| |
| // Disable Remote Playback by passing empty RemotePlaybackMetadata when there |
| // are multiple media players. |
| info->remote_playback_metadata = remote_playback_metadata_.Clone(); |
| if (normal_players_.size() > 1u && info->remote_playback_metadata) { |
| info->remote_playback_metadata->remote_playback_disabled = true; |
| } |
| |
| MediaSessionClient* media_session_client = MediaSessionClient::Get(); |
| info->hide_metadata = media_session_client |
| ? media_session_client->ShouldHideMetadata( |
| web_contents()->GetBrowserContext()) |
| : false; |
| |
| info->meets_visibility_threshold = HasSufficientlyVisibleVideo(); |
| |
| return info; |
| } |
| |
| void MediaSessionImpl::GetMediaSessionInfo( |
| GetMediaSessionInfoCallback callback) { |
| std::move(callback).Run(GetMediaSessionInfoSync()); |
| } |
| |
| void MediaSessionImpl::AddObserver( |
| mojo::PendingRemote<media_session::mojom::MediaSessionObserver> observer) { |
| mojo::Remote<media_session::mojom::MediaSessionObserver> |
| media_session_observer(std::move(observer)); |
| media_session_observer->MediaSessionInfoChanged(GetMediaSessionInfoSync()); |
| media_session_observer->MediaSessionMetadataChanged(metadata_); |
| media_session_observer->MediaSessionImagesChanged(images_); |
| media_session_observer->MediaSessionPositionChanged(position_); |
| |
| std::vector<media_session::mojom::MediaSessionAction> actions( |
| actions_.begin(), actions_.end()); |
| media_session_observer->MediaSessionActionsChanged(actions); |
| |
| observers_.Add(std::move(media_session_observer)); |
| } |
| |
| void MediaSessionImpl::FinishSystemAudioFocusRequest( |
| AudioFocusType audio_focus_type, |
| bool result) { |
| // If the media session is not active then we do not need to enforce the |
| // result of the audio focus request. |
| if (audio_focus_state_ != State::ACTIVE) { |
| AbandonSystemAudioFocusIfNeeded(); |
| return; |
| } |
| |
| OnSystemAudioFocusRequested(result); |
| |
| if (!result) { |
| switch (audio_focus_type) { |
| case AudioFocusType::kGain: |
| // If the gain audio focus request failed then we should suspend the |
| // media session. |
| OnSuspendInternal(SuspendType::kSystem, State::SUSPENDED); |
| break; |
| case AudioFocusType::kAmbient: |
| // There's nothing to do if an ambient request fails. |
| break; |
| case AudioFocusType::kGainTransient: |
| // MediaSessionImpl does not use |kGainTransient|. |
| NOTREACHED(); |
| case AudioFocusType::kGainTransientMayDuck: |
| // The focus request failed, we should suspend any players that have |
| // the same audio focus type. |
| for (auto& player : normal_players_) { |
| if (audio_focus_type == player.second) |
| player.first.observer->OnSuspend(player.first.player_id); |
| } |
| break; |
| } |
| } |
| } |
| |
| void MediaSessionImpl::PreviousTrack() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kPreviousTrack); |
| } |
| |
| void MediaSessionImpl::NextTrack() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kNextTrack); |
| } |
| |
| void MediaSessionImpl::SkipAd() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kSkipAd); |
| } |
| |
| void MediaSessionImpl::PreviousSlide() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kPreviousSlide); |
| } |
| |
| void MediaSessionImpl::NextSlide() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kNextSlide); |
| } |
| |
| void MediaSessionImpl::SeekTo(base::TimeDelta seek_time) { |
| // If the site has registered an action handler for seek to then we |
| // should pass it to the site and let them handle it. |
| if (ShouldRouteAction(media_session::mojom::MediaSessionAction::kSeekTo)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kSeekTo, |
| blink::mojom::MediaSessionActionDetails::NewSeekTo( |
| blink::mojom::MediaSessionSeekToDetails::New( |
| seek_time, /*fast_seek=*/false))); |
| return; |
| } |
| |
| for (const auto& it : normal_players_) |
| it.first.observer->OnSeekTo(it.first.player_id, seek_time); |
| } |
| |
| void MediaSessionImpl::ScrubTo(base::TimeDelta seek_time) { |
| // If the site has registered an action handler for seek to then we |
| // should pass it to the site and let them handle it. |
| if (ShouldRouteAction(media_session::mojom::MediaSessionAction::kSeekTo)) { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kSeekTo, |
| blink::mojom::MediaSessionActionDetails::NewSeekTo( |
| blink::mojom::MediaSessionSeekToDetails::New( |
| seek_time, /*fast_seek=*/true))); |
| return; |
| } |
| |
| for (const auto& it : normal_players_) |
| it.first.observer->OnSeekTo(it.first.player_id, seek_time); |
| } |
| |
| void MediaSessionImpl::EnterPictureInPicture() { |
| if (base::FeatureList::IsEnabled( |
| blink::features::kMediaSessionEnterPictureInPicture) && |
| ShouldRouteAction( |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture)) { |
| DidReceiveAction( |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture); |
| uma_helper_.RecordEnterPictureInPicture( |
| MediaSessionUmaHelper::EnterPictureInPictureType::kRegisteredManual); |
| return; |
| } |
| |
| DCHECK_EQ(normal_players_.size(), 1u); |
| if (normal_players_.size() != 1u) { |
| // There should be one and only one player when we enter picture-in-picture. |
| return; |
| } |
| |
| normal_players_.begin()->first.observer->OnEnterPictureInPicture( |
| normal_players_.begin()->first.player_id); |
| uma_helper_.RecordEnterPictureInPicture( |
| MediaSessionUmaHelper::EnterPictureInPictureType::kDefaultHandler); |
| } |
| |
| void MediaSessionImpl::ExitPictureInPicture() { |
| static_cast<WebContentsImpl*>(web_contents())->ExitPictureInPicture(); |
| } |
| |
| void MediaSessionImpl::EnterAutoPictureInPicture() { |
| if (!base::FeatureList::IsEnabled( |
| blink::features::kMediaSessionEnterPictureInPicture)) { |
| return; |
| } |
| if (!ShouldRouteAction( |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture)) { |
| MaybeEnterBrowserInitiatedAutomaticPictureInPicture(); |
| return; |
| } |
| |
| DidReceiveAction( |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture); |
| uma_helper_.RecordEnterPictureInPicture( |
| MediaSessionUmaHelper::EnterPictureInPictureType::kRegisteredAutomatic); |
| ReportAutoPictureInPictureInfoChanged(); |
| } |
| |
| void MediaSessionImpl::SetAudioSinkId(const std::optional<std::string>& id) { |
| audio_device_id_for_origin_ = id; |
| |
| for (const auto& it : normal_players_) { |
| it.first.observer->OnSetAudioSinkId( |
| it.first.player_id, |
| id.value_or(media::AudioDeviceDescription::kDefaultDeviceId)); |
| } |
| } |
| |
| void MediaSessionImpl::ToggleMicrophone() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kToggleMicrophone); |
| } |
| |
| void MediaSessionImpl::ToggleCamera() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kToggleCamera); |
| } |
| |
| void MediaSessionImpl::HangUp() { |
| DidReceiveAction(media_session::mojom::MediaSessionAction::kHangUp); |
| } |
| |
| void MediaSessionImpl::Raise() { |
| content::WebContentsDelegate* delegate = web_contents()->GetDelegate(); |
| if (!delegate) |
| return; |
| |
| delegate->ActivateContents(web_contents()); |
| } |
| |
| void MediaSessionImpl::SetMute(bool mute) { |
| DCHECK_EQ(normal_players_.size(), 1u); |
| normal_players_.begin()->first.observer->OnSetMute( |
| normal_players_.begin()->first.player_id, mute); |
| } |
| |
| void MediaSessionImpl::RequestMediaRemoting() { |
| DCHECK_EQ(normal_players_.size(), 1u); |
| normal_players_.begin()->first.observer->OnRequestMediaRemoting( |
| normal_players_.begin()->first.player_id); |
| } |
| |
| void MediaSessionImpl::GetMediaImageBitmap( |
| const media_session::MediaImage& image, |
| int minimum_size_px, |
| int desired_size_px, |
| GetMediaImageBitmapCallback callback) { |
| // We want to hide the media image from ChromeOS' media controls. |
| #if BUILDFLAG(IS_CHROMEOS) |
| if (session_info_ && session_info_->hide_metadata) { |
| MediaSessionClient* media_session_client = MediaSessionClient::Get(); |
| CHECK(media_session_client); |
| std::move(callback).Run(media_session_client->GetThumbnailPlaceholder()); |
| return; |
| } |
| #endif |
| |
| // We should make sure `image` is in `images_`. |
| bool found = false; |
| bool source_icon = false; |
| for (auto& image_type : images_) { |
| if (base::Contains(image_type.second, image)) { |
| found = true; |
| |
| if (image_type.first == |
| media_session::mojom::MediaSessionImageType::kSourceIcon) { |
| source_icon = true; |
| } |
| break; |
| } |
| } |
| |
| // Or the `image` is in chapters. |
| if (!found) { |
| for (auto& chapter : metadata_.chapters) { |
| if (base::Contains(chapter.artwork(), image)) { |
| found = true; |
| break; |
| } |
| } |
| } |
| |
| if (!found || !IsSizesAtLeast(image.sizes, minimum_size_px)) { |
| std::move(callback).Run(SkBitmap()); |
| return; |
| } |
| |
| // Check the cache. |
| PageData& page_data = GetPageData(web_contents()->GetPrimaryPage()); |
| if (source_icon) { |
| if (auto* bitmap = page_data.GetImageCache(image.src)) { |
| std::move(callback).Run(*bitmap); |
| return; |
| } |
| } |
| |
| const gfx::Size preferred_size(desired_size_px, desired_size_px); |
| web_contents()->DownloadImage( |
| image.src, false /* is_favicon */, preferred_size, |
| desired_size_px /* max_bitmap_size */, false /* bypass_cache */, |
| base::BindOnce(&MediaSessionImpl::OnImageDownloadComplete, |
| base::Unretained(this), |
| mojo::WrapCallbackWithDefaultInvokeIfNotRun( |
| std::move(callback), SkBitmap()), |
| minimum_size_px, desired_size_px, source_icon)); |
| } |
| |
| void MediaSessionImpl::ReportAutoPictureInPictureInfoChanged() { |
| ContentClient* content_client = GetContentClient(); |
| const auto auto_picture_in_picture_info = |
| media::PictureInPictureEventsInfo::AutoPipInfo{ |
| content_client->browser()->GetAutoPipInfo(*web_contents())}; |
| |
| ForAllPlayers(base::BindRepeating( |
| [](const media::PictureInPictureEventsInfo::AutoPipInfo& |
| auto_picture_in_picture_info, |
| const PlayerIdentifier& player) { |
| player.observer->OnAutoPictureInPictureInfoChanged( |
| player.player_id, auto_picture_in_picture_info); |
| }, |
| auto_picture_in_picture_info)); |
| } |
| |
| void MediaSessionImpl::AbandonSystemAudioFocusIfNeeded() { |
| if (audio_focus_state_ == State::INACTIVE || !normal_players_.empty() || |
| !one_shot_players_.empty() || !ambient_players_.empty()) { |
| return; |
| } |
| delegate_->AbandonAudioFocus(); |
| is_ducking_ = false; |
| |
| SetAudioFocusState(State::INACTIVE); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::SetAudioFocusState(State audio_focus_state) { |
| if (audio_focus_state == audio_focus_state_) |
| return; |
| |
| audio_focus_state_ = audio_focus_state; |
| switch (audio_focus_state_) { |
| case State::ACTIVE: |
| uma_helper_.OnSessionActive(); |
| break; |
| case State::SUSPENDED: |
| uma_helper_.OnSessionSuspended(); |
| break; |
| case State::INACTIVE: |
| uma_helper_.OnSessionInactive(); |
| break; |
| } |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::FlushForTesting() { |
| observers_.FlushForTesting(); |
| } |
| |
| void MediaSessionImpl::RebuildAndNotifyMediaSessionInfoChanged() { |
| media_session::mojom::MediaSessionInfoPtr current_info = |
| GetMediaSessionInfoSync(); |
| |
| if (current_info == session_info_) |
| return; |
| |
| // Picture-in-Picture window controller needs to be updated on current media |
| // session info. |
| if (auto* pip_window_controller_ = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller_->MediaSessionInfoChanged(current_info); |
| } |
| |
| for (auto& observer : observers_) |
| observer->MediaSessionInfoChanged(current_info.Clone()); |
| |
| delegate_->MediaSessionInfoChanged(current_info); |
| |
| session_info_ = std::move(current_info); |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| // If we need to hide the metadata, then we need to notify the metadata |
| // observers with the hidden metadata. They might have received the metadata |
| // before the info has been updated. |
| if (session_info_->hide_metadata) { |
| RebuildAndNotifyMetadataChanged(); |
| } |
| #endif // BUILDFLAG(IS_CHROMEOS) |
| } |
| |
| bool MediaSessionImpl::AddOneShotPlayer(MediaSessionPlayerObserver* observer, |
| int player_id) { |
| AudioFocusDelegate::AudioFocusResult result = |
| RequestSystemAudioFocus(AudioFocusType::kGain); |
| |
| if (result == AudioFocusDelegate::AudioFocusResult::kFailed) |
| return false; |
| |
| one_shot_players_.insert(PlayerIdentifier(observer, player_id)); |
| |
| UpdateRoutedService(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| |
| return true; |
| } |
| |
| bool MediaSessionImpl::AddAmbientPlayer(MediaSessionPlayerObserver* observer, |
| int player_id) { |
| #if BUILDFLAG(IS_ANDROID) |
| // Ambient players are completely ignored for Android audio focus. |
| return true; |
| #else |
| // If we're currently ducking, ensure the new player is also ducked. |
| observer->OnSetVolumeMultiplier(player_id, GetVolumeMultiplier()); |
| |
| // Request audio focus only if we're entirely inactive (i.e. don't request to |
| // un-suspend for an ambient player). |
| if (audio_focus_state_ == State::INACTIVE) { |
| if (RequestSystemAudioFocus(AudioFocusType::kAmbient) == |
| AudioFocusDelegate::AudioFocusResult::kFailed) { |
| return false; |
| } |
| } |
| |
| // If we have audio focus, then add this to the list of ambient players, but |
| // we don't need to update any info or metadata as they are unaffected by |
| // ambient players. |
| ambient_players_.insert(PlayerIdentifier(observer, player_id)); |
| return true; |
| #endif // BUILDFLAG(IS_ANDROID) |
| } |
| |
| // MediaSessionService-related methods |
| |
| void MediaSessionImpl::OnServiceCreated(MediaSessionServiceImpl* service) { |
| const auto rfh_id = service->GetRenderFrameHostId(); |
| |
| services_[rfh_id] = service; |
| UpdateRoutedService(); |
| } |
| |
| void MediaSessionImpl::OnServiceDestroyed(MediaSessionServiceImpl* service) { |
| uma_helper_.OnServiceDestroyed(); |
| services_.erase(service->GetRenderFrameHostId()); |
| |
| if (routed_service_ == service) |
| UpdateRoutedService(); |
| } |
| |
| void MediaSessionImpl::OnMediaSessionPlaybackStateChanged( |
| MediaSessionServiceImpl* service) { |
| if (service != routed_service_) |
| return; |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::OnMediaSessionMetadataChanged( |
| MediaSessionServiceImpl* service) { |
| if (service != routed_service_) |
| return; |
| |
| RebuildAndNotifyMetadataChanged(); |
| } |
| |
| void MediaSessionImpl::OnMediaSessionActionsChanged( |
| MediaSessionServiceImpl* service) { |
| if (service != routed_service_) |
| return; |
| |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::OnMediaSessionInfoChanged( |
| MediaSessionServiceImpl* service) { |
| if (service != routed_service_) |
| return; |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::DidReceiveAction( |
| media_session::mojom::MediaSessionAction action) { |
| DidReceiveAction(action, nullptr /* details */); |
| } |
| |
| void MediaSessionImpl::DidReceiveAction( |
| media_session::mojom::MediaSessionAction action, |
| blink::mojom::MediaSessionActionDetailsPtr details) { |
| // Pause all players in non-routed frames if the action is PAUSE. |
| // |
| // This is the default PAUSE action handler per Media Session API spec. The |
| // reason for pausing all players in all other sessions is to avoid the |
| // players in other frames keep the session active so that the UI will always |
| // show the pause button but it does not pause anything (as the routed frame |
| // already pauses when responding to the PAUSE action while other frames does |
| // not). |
| // |
| // TODO(zqzhang): Currently, this might not work well on desktop as OneShot |
| // players are not really suspended, so that the session is still active after |
| // this. See https://crbug.com/619084 and https://crbug.com/596516. |
| if (media_session::mojom::MediaSessionAction::kPause == action) { |
| RenderFrameHost* rfh_of_routed_service = |
| routed_service_ ? routed_service_->GetRenderFrameHost() : nullptr; |
| for (const auto& player : normal_players_) { |
| if (player.first.observer->render_frame_host() != rfh_of_routed_service) |
| player.first.observer->OnSuspend(player.first.player_id); |
| } |
| for (const auto& player : one_shot_players_) { |
| if (player.observer->render_frame_host() != rfh_of_routed_service) |
| player.observer->OnSuspend(player.player_id); |
| } |
| } |
| |
| if (!routed_service_) |
| return; |
| |
| routed_service_->GetClient()->DidReceiveAction(action, std::move(details)); |
| } |
| |
| bool MediaSessionImpl::IsServiceActiveForRenderFrameHost(RenderFrameHost* rfh) { |
| return services_.find(rfh->GetGlobalId()) != services_.end(); |
| } |
| |
| void MediaSessionImpl::UpdateRoutedService() { |
| RenderFrameHost* rfh = ComputeFrameForRouting(/*ensure_service=*/true); |
| MediaSessionServiceImpl* new_service = |
| rfh ? services_[rfh->GetGlobalId()] : nullptr; |
| |
| if (new_service == routed_service_) |
| return; |
| |
| routed_service_ = new_service; |
| |
| RebuildAndNotifyMetadataChanged(); |
| RebuildAndNotifyActionsChanged(); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| RebuildAndNotifyMediaPositionChanged(); |
| } |
| |
| // Select a frame that has a playing or paused media player, or has a |
| // MediaSessionService created to handle media session APIs without having a |
| // media player. Select the top-most frame if multiple frames satisfy the |
| // criteria. If |ensure_service| is set to true, the selected frame must also |
| // have a corresponding MediaSessionService. |
| RenderFrameHost* MediaSessionImpl::ComputeFrameForRouting(bool ensure_service) { |
| // First collect all the frames that have a playing or paused media player. |
| std::set<RenderFrameHost*> frames; |
| for (const auto& player : normal_players_) { |
| RenderFrameHost* frame = player.first.observer->render_frame_host(); |
| if (frame) { |
| frames.insert(frame); |
| } |
| } |
| for (const auto& player : one_shot_players_) { |
| RenderFrameHost* frame = player.observer->render_frame_host(); |
| if (frame) { |
| frames.insert(frame); |
| } |
| } |
| |
| // Compute to find the frame with the minimum depth. |
| RenderFrameHost* best_frame = nullptr; |
| size_t min_depth = std::numeric_limits<size_t>::max(); |
| std::map<RenderFrameHost*, size_t> map_rfh_to_depth; |
| |
| for (RenderFrameHost* frame : frames) { |
| size_t depth = ComputeFrameDepth(frame, &map_rfh_to_depth); |
| if (depth >= min_depth) { |
| continue; |
| } |
| if (ensure_service && !IsServiceActiveForRenderFrameHost(frame)) { |
| continue; |
| } |
| best_frame = frame; |
| min_depth = depth; |
| } |
| |
| // If we cannot find a suitable frame, take the top-most frame with an active |
| // MediaSessionService. |
| if (!best_frame && base::FeatureList::IsEnabled( |
| blink::features::kMediaSessionEnterPictureInPicture)) { |
| // `FrameTree::Nodes()` iterates in breadth-first order, so this is |
| // guaranteed to find the topmost (or tied topmost) frame with an active |
| // MediaSessionService. |
| for (FrameTreeNode* node : static_cast<WebContentsImpl*>(web_contents()) |
| ->GetPrimaryFrameTree() |
| .Nodes()) { |
| RenderFrameHost* rfh = node->current_frame_host(); |
| if (IsServiceActiveForRenderFrameHost(rfh)) { |
| best_frame = rfh; |
| break; |
| } |
| } |
| } |
| |
| return best_frame; |
| } |
| |
| void MediaSessionImpl::OnMediaMutedStatusChanged(bool mute) { |
| is_muted_ = mute; |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::OnPictureInPictureAvailabilityChanged() { |
| if (normal_players_.size() != 1) |
| return; |
| |
| RebuildAndNotifyActionsChanged(); |
| } |
| |
| void MediaSessionImpl::OnAudioOutputSinkIdChanged() { |
| if (audio_device_id_for_origin_ && |
| audio_device_id_for_origin_ != GetSharedAudioOutputDeviceId()) { |
| audio_device_id_for_origin_.reset(); |
| } |
| |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::OnAudioOutputSinkChangingDisabled() { |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::OnVideoVisibilityChanged() { |
| if (normal_players_.size() == 0) { |
| return; |
| } |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| void MediaSessionImpl::SetRemotePlaybackMetadata( |
| media_session::mojom::RemotePlaybackMetadataPtr metadata) { |
| remote_playback_metadata_ = std::move(metadata); |
| RebuildAndNotifyMediaSessionInfoChanged(); |
| } |
| |
| bool MediaSessionImpl::ShouldRouteAction( |
| media_session::mojom::MediaSessionAction action) const { |
| return routed_service_ && base::Contains(routed_service_->actions(), action); |
| } |
| |
| const base::UnguessableToken& MediaSessionImpl::GetSourceId() const { |
| return MediaSessionData::GetOrCreate(web_contents()->GetBrowserContext()) |
| ->source_id(); |
| } |
| |
| const base::UnguessableToken& MediaSessionImpl::GetRequestId() const { |
| return delegate_->request_id(); |
| } |
| |
| void MediaSessionImpl::UpdateVideoPictureInPictureWindowController( |
| VideoPictureInPictureWindowControllerImpl* pip_controller) const { |
| pip_controller->MediaSessionActionsChanged(actions_); |
| pip_controller->MediaSessionImagesChanged(images_); |
| pip_controller->MediaSessionPositionChanged(position_); |
| pip_controller->MediaSessionInfoChanged(session_info_); |
| pip_controller->MediaSessionMetadataChanged(metadata_); |
| } |
| |
| base::WeakPtr<MediaSessionImpl> MediaSessionImpl::GetWeakPtr() { |
| return weak_factory_.GetWeakPtr(); |
| } |
| |
| void MediaSessionImpl::RebuildAndNotifyActionsChanged() { |
| std::set<media_session::mojom::MediaSessionAction> actions = |
| routed_service_ ? routed_service_->actions() |
| : std::set<media_session::mojom::MediaSessionAction>(); |
| |
| // Picture-in-Picture window controller needs to know only actions that are |
| // handled by the website. |
| if (auto* pip_window_controller_ = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller_->MediaSessionActionsChanged(actions); |
| } |
| |
| // If we are controllable then we should always add these actions as we can |
| // support them by directly interacting with the players underneath. |
| if (IsControllable()) { |
| actions.insert(media_session::mojom::MediaSessionAction::kPlay); |
| actions.insert(media_session::mojom::MediaSessionAction::kPause); |
| actions.insert(media_session::mojom::MediaSessionAction::kStop); |
| |
| // Support seeking as long as this isn't live media. |
| if (!is_considered_live_) { |
| actions.insert(media_session::mojom::MediaSessionAction::kSeekTo); |
| actions.insert(media_session::mojom::MediaSessionAction::kScrubTo); |
| actions.insert(media_session::mojom::MediaSessionAction::kSeekForward); |
| actions.insert(media_session::mojom::MediaSessionAction::kSeekBackward); |
| } |
| } |
| |
| // If the website has specified an action handler for 'enterpictureinpicture', |
| // then we should expose EnterAutoPictureInPicture as an available action. |
| if (base::FeatureList::IsEnabled( |
| blink::features::kMediaSessionEnterPictureInPicture) && |
| base::Contains( |
| actions, |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture)) { |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kEnterAutoPictureInPicture); |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kExitPictureInPicture); |
| } |
| |
| if (base::FeatureList::IsEnabled( |
| media::kGlobalMediaControlsPictureInPicture)) { |
| if (IsPictureInPictureAvailable()) { |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kEnterPictureInPicture); |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kExitPictureInPicture); |
| } else if (web_contents()->HasPictureInPictureVideo() || |
| web_contents()->HasPictureInPictureDocument()) { |
| // If the media is already in the picture-in-picture state, we allow the |
| // player to exit it. |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kExitPictureInPicture); |
| } |
| } |
| |
| // If the website could enter browser initiated automatic picture in picture, |
| // then we should expose EnterAutoPictureInPicture as an available action. |
| if (CouldEnterBrowserInitiatedAutomaticPictureInPicture()) { |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kEnterAutoPictureInPicture); |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kExitPictureInPicture); |
| } |
| |
| if (base::FeatureList::IsEnabled( |
| media::kGlobalMediaControlsSeamlessTransfer) && |
| IsAudioOutputDeviceSwitchingSupported()) { |
| actions.insert( |
| media_session::mojom::MediaSessionAction::kSwitchAudioDevice); |
| } |
| |
| if (actions_ == actions) |
| return; |
| |
| actions_ = actions; |
| |
| std::vector<media_session::mojom::MediaSessionAction> actions_vec( |
| actions.begin(), actions.end()); |
| for (auto& observer : observers_) |
| observer->MediaSessionActionsChanged(actions_vec); |
| } |
| |
| void MediaSessionImpl::RebuildAndNotifyMetadataChanged() { |
| std::vector<media_session::MediaImage> artwork; |
| media_session::MediaMetadata metadata; |
| BuildMetadata(metadata, artwork); |
| |
| // If we have no artwork in |images_| or the arwork has changed then we should |
| // update it with the latest artwork from the routed service. |
| auto it = images_.find(MediaSessionImageType::kArtwork); |
| bool images_changed = it == images_.end() || it->second != artwork; |
| if (images_changed) { |
| images_.insert_or_assign(MediaSessionImageType::kArtwork, artwork); |
| } |
| bool metadata_changed = metadata_ != metadata; |
| if (metadata_changed) { |
| metadata_ = metadata; |
| } |
| |
| if (!images_changed && !metadata_changed) { |
| return; |
| } |
| for (auto& observer : observers_) { |
| if (metadata_changed) { |
| observer->MediaSessionMetadataChanged(this->metadata_); |
| } |
| |
| if (images_changed) { |
| observer->MediaSessionImagesChanged(this->images_); |
| } |
| } |
| if (auto* pip_window_controller = |
| VideoPictureInPictureWindowControllerImpl::FromWebContents( |
| web_contents())) { |
| pip_window_controller->MediaSessionMetadataChanged(metadata_); |
| } |
| } |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| void MediaSessionImpl::BuildPlaceholderMetadata( |
| media_session::MediaMetadata& metadata, |
| std::vector<media_session::MediaImage>& artwork) { |
| if ((routed_service_ && routed_service_->metadata()) || |
| !metadata_.IsEmpty()) { |
| MediaSessionClient* media_session_client = MediaSessionClient::Get(); |
| CHECK(media_session_client); |
| |
| metadata.title = media_session_client->GetTitlePlaceholder(); |
| metadata.artist = media_session_client->GetArtistPlaceholder(); |
| metadata.album = media_session_client->GetAlbumPlaceholder(); |
| metadata.source_title = media_session_client->GetSourceTitlePlaceholder(); |
| |
| // Always make sure the metadata replacement is accompanied by the thumbnail |
| // replacement. |
| // An empty `MediaImage` so `GetMediaImageBitmap` is eventually triggered. |
| // That is where we replace the artwork with the placeholder `Bitmap`. |
| artwork.push_back(media_session::MediaImage()); |
| } |
| } |
| #endif |
| |
| void MediaSessionImpl::BuildMetadata( |
| media_session::MediaMetadata& metadata, |
| std::vector<media_session::MediaImage>& artwork) { |
| // We need to hide the metadata for ChromeOS here because the |
| // `MediaNotificationItem` lives in //components which cannot depend on |
| // //content. For other platforms, metadata is hidden in the |
| // `SystemMediaControlsNotifier`. |
| #if BUILDFLAG(IS_CHROMEOS) |
| if (session_info_ && session_info_->hide_metadata) { |
| BuildPlaceholderMetadata(metadata, artwork); |
| return; |
| } |
| #endif // BUILDFLAG(IS_CHROMEOS) |
| |
| if (routed_service_ && routed_service_->metadata()) { |
| metadata.title = routed_service_->metadata()->title; |
| metadata.artist = routed_service_->metadata()->artist; |
| metadata.album = routed_service_->metadata()->album; |
| metadata.chapters = routed_service_->metadata()->chapterInfo; |
| artwork = routed_service_->metadata()->artwork; |
| } |
| |
| if (metadata.title.empty()) { |
| metadata.title = SanitizeMediaTitle(web_contents()->GetTitle()); |
| } |
| |
| ContentClient* content_client = GetContentClient(); |
| const GURL& url = web_contents()->GetLastCommittedURL(); |
| |
| // If |url| wraps a chrome extension ID or System Web App, we can display |
| // the extension or app name instead, which is more human-readable. |
| std::u16string source_title; |
| WebContentsDelegate* delegate = web_contents()->GetDelegate(); |
| if (delegate) { |
| source_title = |
| base::UTF8ToUTF16(delegate->GetTitleForMediaControls(web_contents())); |
| } |
| |
| if (source_title.empty()) { |
| // If the url is a file then we should display a placeholder. |
| source_title = |
| url.SchemeIsFile() |
| ? content_client->GetLocalizedString(IDS_MEDIA_SESSION_FILE_SOURCE) |
| : url_formatter::FormatUrl( |
| url::Origin::Create(url).GetURL(), |
| url_formatter::kFormatUrlOmitDefaults | |
| url_formatter::kFormatUrlOmitHTTPS | |
| url_formatter::kFormatUrlOmitTrivialSubdomains, |
| base::UnescapeRule::SPACES, nullptr, nullptr, nullptr); |
| } |
| |
| metadata.source_title = source_title; |
| } |
| |
| bool MediaSessionImpl::IsPictureInPictureAvailable() const { |
| if (normal_players_.size() != 1) |
| return false; |
| |
| auto& first = normal_players_.begin()->first; |
| return first.observer->IsPictureInPictureAvailable(first.player_id); |
| } |
| |
| bool MediaSessionImpl::HasSufficientlyVisibleVideo() const { |
| for (const auto& player : normal_players_) { |
| if (player.first.observer->HasSufficientlyVisibleVideo( |
| player.first.player_id)) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| void MediaSessionImpl::GetVisibility( |
| GetVisibilityCallback get_visibility_callback) { |
| if (normal_players_.empty()) { |
| std::move(get_visibility_callback).Run(false); |
| return; |
| } |
| |
| scoped_refptr<MediaPlayersCallbackAggregator> aggregator = |
| MakeRefCounted<MediaPlayersCallbackAggregator>( |
| std::move(get_visibility_callback)); |
| for (const auto& player : normal_players_) { |
| if (player.first.observer->IsPaused(player.first.player_id)) { |
| continue; |
| } |
| player.first.observer->OnRequestVisibility( |
| player.first.player_id, aggregator->CreateVisibilityCallback()); |
| } |
| } |
| |
| std::string MediaSessionImpl::GetSharedAudioOutputDeviceId() const { |
| if (normal_players_.empty()) |
| return media::AudioDeviceDescription::kDefaultDeviceId; |
| |
| auto& first = normal_players_.begin()->first; |
| const auto& first_id = first.observer->GetAudioOutputSinkId(first.player_id); |
| if (std::ranges::all_of(normal_players_, [&first_id](const auto& player) { |
| return player.first.observer->GetAudioOutputSinkId( |
| player.first.player_id) == first_id; |
| })) { |
| return first_id; |
| } |
| |
| return media::AudioDeviceDescription::kDefaultDeviceId; |
| } |
| |
| bool MediaSessionImpl::IsAudioOutputDeviceSwitchingSupported() const { |
| if (normal_players_.empty()) |
| return false; |
| |
| return std::ranges::all_of(normal_players_, [](const auto& player) { |
| return player.first.observer->SupportsAudioOutputDeviceSwitching( |
| player.first.player_id); |
| }); |
| } |
| |
| std::vector<MediaAudioVideoState> MediaSessionImpl::GetMediaAudioVideoStates() { |
| RenderFrameHost* routed_rfh = |
| routed_service_ ? routed_service_->GetRenderFrameHost() : nullptr; |
| std::vector<MediaAudioVideoState> states; |
| |
| ForAllPlayers(base::BindRepeating( |
| [](RenderFrameHost* routed_rfh, std::vector<MediaAudioVideoState>* states, |
| const PlayerIdentifier& player) { |
| // If we have a routed frame then we should limit the players to the |
| // frame so it is aligned with the media metadata. |
| if (routed_rfh && player.observer->render_frame_host() != routed_rfh) |
| return; |
| |
| const bool has_audio = player.observer->HasAudio(player.player_id); |
| const bool has_video = player.observer->HasVideo(player.player_id); |
| if (has_audio && has_video) { |
| states->push_back(MediaAudioVideoState::kAudioVideo); |
| } else if (has_audio) { |
| states->push_back(MediaAudioVideoState::kAudioOnly); |
| } else if (has_video) { |
| states->push_back(MediaAudioVideoState::kVideoOnly); |
| } |
| }, |
| routed_rfh, &states)); |
| |
| return states; |
| } |
| |
| void MediaSessionImpl::ForAllPlayers( |
| base::RepeatingCallback<void(const PlayerIdentifier&)> callback) { |
| for (const auto& player : normal_players_) |
| callback.Run(player.first); |
| |
| for (const auto& player : one_shot_players_) |
| callback.Run(player); |
| } |
| |
| std::optional<media_session::MediaPosition> |
| MediaSessionImpl::MaybeGuardDurationUpdate( |
| std::optional<media_session::MediaPosition> position) { |
| if (!position) { |
| // |position| should never go back to unset state once it's |
| // set. Therefore it's safe to return it here when it's unset. |
| DCHECK(!is_throttling_); |
| return position; |
| } |
| |
| if (position_ && position_->duration() == position->duration()) |
| return position; |
| |
| if (duration_update_allowance_ == 0) { |
| is_throttling_ = true; |
| DCHECK(duration_update_allowance_timer_.IsRunning()); |
| |
| // Reset the timer so that we can keep the media as livestream |
| // until the time difference between two updates is greater |
| // than |kDurationUpdateAllowanceIncreaseInterval|. |
| duration_update_allowance_timer_.Reset(); |
| |
| return media_session::MediaPosition( |
| position->playback_rate(), base::TimeDelta::Max(), |
| position->GetPosition(), position->end_of_media()); |
| } |
| |
| --duration_update_allowance_; |
| DCHECK_GE(duration_update_allowance_, 0); |
| if (!duration_update_allowance_timer_.IsRunning()) { |
| duration_update_allowance_timer_.Start( |
| FROM_HERE, kDurationUpdateAllowanceIncreaseInterval, this, |
| &MediaSessionImpl::IncreaseDurationUpdateAllowance); |
| } |
| |
| return position; |
| } |
| |
| void MediaSessionImpl::IncreaseDurationUpdateAllowance() { |
| ++duration_update_allowance_; |
| |
| if (duration_update_allowance_ == kDurationUpdateMaxAllowance) |
| duration_update_allowance_timer_.Stop(); |
| |
| if (is_throttling_) { |
| is_throttling_ = false; |
| RebuildAndNotifyMediaPositionChanged(); |
| } |
| } |
| |
| void MediaSessionImpl::ResetDurationUpdateGuard() { |
| duration_update_allowance_timer_.Stop(); |
| duration_update_allowance_ = kDurationUpdateMaxAllowance; |
| is_throttling_ = false; |
| guarding_player_id_.reset(); |
| } |
| |
| void MediaSessionImpl::SetShouldThrottleDurationUpdateForTest( |
| bool should_throttle) { |
| should_throttle_duration_update_ = should_throttle; |
| } |
| |
| bool MediaSessionImpl::IsActivelyUsingCameraOrMicrophone() const { |
| if (!routed_service_) { |
| return false; |
| } |
| |
| return routed_service_->microphone_state() == |
| media_session::mojom::MicrophoneState::kUnmuted || |
| routed_service_->camera_state() == |
| media_session::mojom::CameraState::kTurnedOn; |
| } |
| |
| bool MediaSessionImpl::CouldEnterBrowserInitiatedAutomaticPictureInPicture() |
| const { |
| if (!base::FeatureList::IsEnabled( |
| blink::features::kBrowserInitiatedAutomaticPictureInPicture)) { |
| return false; |
| } |
| |
| if (!IsPictureInPictureAvailable()) { |
| return false; |
| } |
| |
| if (IsActivelyUsingCameraOrMicrophone()) { |
| return false; |
| } |
| |
| // There should be one and only one player when we could enter browser |
| // initiated automatic picture-in-picture. |
| CHECK_EQ(normal_players_.size(), 1u); |
| |
| auto& first = normal_players_.begin()->first; |
| if (first.observer->IsPaused(first.player_id)) { |
| return false; |
| } |
| |
| // Ensure that browser initiated automatic picture-in-picture is only allowed |
| // for the primary main frame. |
| RenderFrameHost* frame = first.observer->render_frame_host(); |
| if (!frame || !frame->IsInPrimaryMainFrame()) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| void MediaSessionImpl::MaybeEnterBrowserInitiatedAutomaticPictureInPicture() |
| const { |
| if (!CouldEnterBrowserInitiatedAutomaticPictureInPicture()) { |
| return; |
| } |
| |
| // There should be one and only one player when we could enter browser |
| // initiated automatic picture-in-picture. |
| CHECK_EQ(normal_players_.size(), 1u); |
| |
| auto& first = normal_players_.begin()->first; |
| first.observer->OnEnterPictureInPicture(first.player_id); |
| } |
| |
| bool MediaSessionImpl::HasImageCacheForTest(const GURL& image_url) const { |
| return GetPageData(web_contents()->GetPrimaryPage()).GetImageCache(image_url); |
| } |
| |
| MediaSessionImpl::PageData::PageData(content::Page& page) |
| : PageUserData(page) {} |
| |
| MediaSessionImpl::PageData::~PageData() = default; |
| |
| MediaSessionImpl::PageData& MediaSessionImpl::GetPageData( |
| content::Page& page) const { |
| return *PageData::GetOrCreateForPage(page); |
| } |
| |
| PAGE_USER_DATA_KEY_IMPL(MediaSessionImpl::PageData); |
| |
| WEB_CONTENTS_USER_DATA_KEY_IMPL(MediaSessionImpl); |
| |
| } // namespace content |