0

Implemented cast devices selector on ash side

1, Created `MediaCastAudioSelectorView` on ash side which is a subclass
of the `global_media_controls::MediaItemUIDeviceSelector`
    - This view carries the cast list view and the audio list view
    - the audio list view will be added in a separate cl later

2, Created `MediaCastListView`.
    - Using the same device detecting logic as in the
`global_media_controls::MediaItemUIDeviceSelectorView`. It will only
detect the server casting devices, while in the cast detailed view
we detect the screen casting devices.
    - This class extends the `TrayDetailedView`, so that each cast
entry is in the same UI style as in the `CastDetailedView`

3, [browser/UI/ash] In the `MediaNotificationProviderImpl` returns
the ash cast device selector view if it's ash build and the
BackgroundListening flag is enabled.

4, [browser/UI/views] Refactored several helper functions to help
build the ash cast device selector view in .

5, Hide the separator line if it's ash build and BL flag is enabled
in the `global_media_controls::MediaItemUIDetailedView`


Bug: b/327507429
Change-Id: Ic699221e861addfa95ad3d248918f14eef80a3b0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5393298
Commit-Queue: Jiaming Cheng <jiamingc@chromium.org>
Reviewed-by: Andrew Xu <andrewxu@chromium.org>
Reviewed-by: Tommy Steimel <steimel@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1282326}
This commit is contained in:
Jiaming Cheng
2024-04-04 05:02:30 +00:00
committed by Chromium LUCI CQ
parent 7e85fafa51
commit ba0ef7c7e4
14 changed files with 1034 additions and 60 deletions

@ -1572,6 +1572,10 @@ component("ash") {
"system/cast/cast_notification_controller.h",
"system/cast/cast_zero_state_view.cc",
"system/cast/cast_zero_state_view.h",
"system/cast/media_cast_audio_selector_view.cc",
"system/cast/media_cast_audio_selector_view.h",
"system/cast/media_cast_list_view.cc",
"system/cast/media_cast_list_view.h",
"system/cast/unified_cast_detailed_view_controller.cc",
"system/cast/unified_cast_detailed_view_controller.h",
"system/channel_indicator/channel_indicator.cc",
@ -3778,6 +3782,8 @@ test("ash_unittests") {
"system/cast/cast_detailed_view_unittest.cc",
"system/cast/cast_feature_pod_controller_unittest.cc",
"system/cast/cast_notification_controller_unittest.cc",
"system/cast/media_cast_audio_selector_view_unittest.cc",
"system/cast/media_cast_list_view_unittest.cc",
"system/channel_indicator/channel_indicator_quick_settings_view_unittest.cc",
"system/channel_indicator/channel_indicator_unittest.cc",
"system/channel_indicator/channel_indicator_utils_unittest.cc",
@ -4299,6 +4305,7 @@ test("ash_unittests") {
"//components/feature_engagement/public",
"//components/feature_engagement/test:test_support",
"//components/global_media_controls",
"//components/global_media_controls:test_support",
"//components/language/core/browser:browser",
"//components/live_caption:constants",
"//components/media_message_center",

@ -5359,6 +5359,10 @@ No devices connected.
</message>
<!-- Media Tray -->
<message name="IDS_ASH_GLOBAL_MEDIA_CONTROLS_CAST_LIST_HEADER" desc="Label for the Global Media Controls cast list header.">
Cast to
</message>
<message name="IDS_ASH_GLOBAL_MEDIA_CONTROLS_BUTTON_TOOLTIP_TEXT" desc="Tooltip for the Global Media Controls icon, which appears in the toolbar. The tooltip appears on mouseover of the icon.">
Control your music, videos, and more
</message>

@ -0,0 +1 @@
3caf27ffa8df68484d25b27fc86197f35cc988cc

@ -0,0 +1,143 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/system/cast/media_cast_audio_selector_view.h"
#include <utility>
#include "ash/system/cast/media_cast_list_view.h"
#include "base/containers/contains.h"
#include "base/functional/bind.h"
#include "base/observer_list.h"
#include "base/ranges/algorithm.h"
#include "base/strings/utf_string_conversions.h"
#include "components/global_media_controls/public/views/media_item_ui_view.h"
#include "components/vector_icons/vector_icons.h"
#include "media/audio/audio_device_description.h"
#include "media/base/media_switches.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/views/background.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
namespace ash {
namespace {
// Constants for the `MediaCastAudioSelectorView`
// Using a fixed width for this view which will be CHECKed on the global media
// side (`media_item_ui_view`).
constexpr int kSelectorViewWidth = 400;
} // namespace
MediaCastAudioSelectorView::MediaCastAudioSelectorView(
mojo::PendingRemote<global_media_controls::mojom::DeviceListHost>
device_list_host,
mojo::PendingReceiver<global_media_controls::mojom::DeviceListClient>
receiver,
base::RepeatingClosure stop_casting_callback,
bool show_devices)
: device_list_host_(std::move(device_list_host)) {
views::Builder<views::View>(this)
.SetBackground(views::CreateSolidBackground(SK_ColorTRANSPARENT))
.SetLayoutManager(std::make_unique<views::BoxLayout>(
views::BoxLayout::Orientation::kVertical))
.SetPreferredSize(
gfx::Size(kSelectorViewWidth, GetHeightForWidth(kSelectorViewWidth)))
.AddChildren(
views::Builder<views::BoxLayoutView>()
.CopyAddressTo(&list_view_container_)
.SetID(kListViewContainerId)
.SetOrientation(views::BoxLayout::Orientation::kVertical)
.SetVisible(false)
.AddChildren(
views::Builder<views::View>(
std::make_unique<MediaCastListView>(
std::move(stop_casting_callback),
base::BindRepeating(
&MediaCastAudioSelectorView::OnCastDeviceSelected,
base::Unretained(this)),
base::BindRepeating(
&MediaCastAudioSelectorView::OnDevicesUpdated,
base::Unretained(this)),
std::move(receiver)))
.SetID(kMediaCastListViewId)))
.BuildChildren();
if (show_devices) {
ShowDevices();
}
DeprecatedLayoutImmediately();
}
MediaCastAudioSelectorView::~MediaCastAudioSelectorView() = default;
void MediaCastAudioSelectorView::SetMediaItemUIView(
global_media_controls::MediaItemUIView* view) {
media_item_ui_ = view;
}
// The colors are set with ash color ids, so we don't need to override this
// method.
void MediaCastAudioSelectorView::OnColorsChanged(SkColor foreground_color,
SkColor background_color) {}
// This audio device related feature (under the flag
// `media::kGlobalMediaControlsSeamlessTransfer`) were never launched, so no
// longer maintained. On ash side here we override it with an empty
// implementation, and we add our own output audio list to this view.
void MediaCastAudioSelectorView::UpdateCurrentAudioDevice(
const std::string& current_device_id) {}
void MediaCastAudioSelectorView::MediaCastAudioSelectorView::ShowDevices() {
DCHECK(!is_expanded_);
is_expanded_ = true;
NotifyAccessibilityEvent(ax::mojom::Event::kExpandedChanged, true);
list_view_container_->SetVisible(true);
// Focus the first available device when the device list is shown for
// accessibility.
// TODO(b/327507429): Revisit the logic here when the audio list view is
// added to see which view should be focused on.
if (const auto& children = list_view_container_->children();
!children.empty()) {
children[0]->RequestFocus();
}
}
void MediaCastAudioSelectorView::HideDevices() {
DCHECK(is_expanded_);
is_expanded_ = false;
NotifyAccessibilityEvent(ax::mojom::Event::kExpandedChanged, true);
list_view_container_->SetVisible(false);
PreferredSizeChanged();
}
bool MediaCastAudioSelectorView::IsDeviceSelectorExpanded() {
return is_expanded_;
}
void MediaCastAudioSelectorView::OnDevicesUpdated(bool has_devices) {
if (media_item_ui_) {
media_item_ui_->OnDeviceSelectorViewDevicesChanged(has_devices);
}
}
void MediaCastAudioSelectorView::OnCastDeviceSelected(
const std::string& device_id) {
if (device_list_host_) {
device_list_host_->SelectDevice(device_id);
}
}
BEGIN_METADATA(MediaCastAudioSelectorView)
END_METADATA
} // namespace ash

@ -0,0 +1,91 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef ASH_SYSTEM_CAST_MEDIA_CAST_AUDIO_SELECTOR_VIEW_H_
#define ASH_SYSTEM_CAST_MEDIA_CAST_AUDIO_SELECTOR_VIEW_H_
#include <string>
#include "ash/ash_export.h"
#include "ash/system/cast/media_cast_list_view.h"
#include "base/functional/callback_forward.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/weak_ptr.h"
#include "components/global_media_controls/public/mojom/device_service.mojom.h"
#include "components/global_media_controls/public/views/media_item_ui_device_selector.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "third_party/skia/include/core/SkColor.h"
#include "ui/base/metadata/metadata_header_macros.h"
namespace global_media_controls {
class MediaItemUIView;
} // namespace global_media_controls
namespace views {
class View;
} // namespace views
namespace ash {
// View ID's.
inline constexpr int kListViewContainerId = kMediaCastListViewMaxId + 1;
inline constexpr int kMediaCastListViewId = kListViewContainerId + 1;
// The device selector view on Ash side. It shows an audio list view and a cast
// list view. This view will show under the global `MediaUIView`.
// TODO(b/327507429): Add the audio list view.
class ASH_EXPORT MediaCastAudioSelectorView
: public global_media_controls::MediaItemUIDeviceSelector {
METADATA_HEADER(MediaCastAudioSelectorView,
global_media_controls::MediaItemUIDeviceSelector)
public:
MediaCastAudioSelectorView(
mojo::PendingRemote<global_media_controls::mojom::DeviceListHost>
device_list_host,
mojo::PendingReceiver<global_media_controls::mojom::DeviceListClient>
receiver,
base::RepeatingClosure stop_casting_callback,
bool show_devices);
MediaCastAudioSelectorView(const MediaCastAudioSelectorView&) = delete;
MediaCastAudioSelectorView& operator=(const MediaCastAudioSelectorView&) =
delete;
~MediaCastAudioSelectorView() override;
// global_media_controls::MediaItemUIDeviceSelector:
void SetMediaItemUIView(
global_media_controls::MediaItemUIView* view) override;
void OnColorsChanged(SkColor foreground_color,
SkColor background_color) override;
void UpdateCurrentAudioDevice(const std::string& current_device_id) override;
void ShowDevices() override;
void HideDevices() override;
bool IsDeviceSelectorExpanded() override;
private:
// The callback that is passed to the `MediaCastListView` to inform the
// panel that the devices is updated.
void OnDevicesUpdated(bool has_devices);
// The callback that is passed to the `MediaCastListView` when a cast item
// entry is pressed.
void OnCastDeviceSelected(const std::string& device_id);
bool is_expanded_ = false;
// The container that carries the audio list view and the cast list view.
raw_ptr<views::View> list_view_container_ = nullptr;
// The panel view of the media bubble.
raw_ptr<global_media_controls::MediaItemUIView> media_item_ui_ = nullptr;
// Provides access to devices on which the media can be casted.
mojo::Remote<global_media_controls::mojom::DeviceListHost> device_list_host_;
base::WeakPtrFactory<MediaCastAudioSelectorView> weak_ptr_factory_{this};
};
} // namespace ash
#endif // ASH_SYSTEM_CAST_MEDIA_CAST_AUDIO_SELECTOR_VIEW_H_

@ -0,0 +1,111 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/system/cast/media_cast_audio_selector_view.h"
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "ash/test/ash_test_base.h"
#include "base/memory/raw_ptr.h"
#include "base/test/scoped_feature_list.h"
#include "components/global_media_controls/public/test/mock_device_service.h"
#include "media/base/media_switches.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "ui/views/view.h"
#include "ui/views/widget/widget.h"
using global_media_controls::test::MockDeviceListHost;
namespace ash {
class MediaCastAudioSelectorViewTest : public AshTestBase {
public:
MediaCastAudioSelectorViewTest() = default;
// Mock callbacks:
MOCK_METHOD(void, OnStopCasting, (), ());
// AshTestBase:
void SetUp() override {
feature_list_.InitAndEnableFeature(media::kBackgroundListening);
AshTestBase::SetUp();
widget_ = CreateFramelessTestWidget();
widget_->SetFullscreen(true);
view_ =
widget_->SetContentsView(std::make_unique<MediaCastAudioSelectorView>(
/*device_list_host=*/device_list_host_.PassRemote(),
/*receiver=*/client_remote_.BindNewPipeAndPassReceiver(),
/*stop_casting_callback=*/
base::BindRepeating(&MediaCastAudioSelectorViewTest::OnStopCasting,
base::Unretained(this)),
/*show_devices=*/false));
}
void TearDown() override {
view_ = nullptr;
widget_.reset();
AshTestBase::TearDown();
}
MediaCastAudioSelectorView* GetSelectorView() { return view_; }
views::View* GetListViewContainer() {
return view_->GetViewByID(kListViewContainerId);
}
MediaCastListView* GetMediaCastListView() {
return static_cast<MediaCastListView*>(
view_->GetViewByID(kMediaCastListViewId));
}
std::vector<raw_ptr<views::View, VectorExperimental>>
GetContainerChildViews() {
return GetMediaCastListView()->item_container_->children();
}
// Adds 1 simulated cast device.
void AddCastDevices() {
std::vector<global_media_controls::mojom::DevicePtr> devices;
global_media_controls::mojom::DevicePtr device =
global_media_controls::mojom::Device::New(
/*id=*/"fake_sink_id_0",
/*name=*/"Sink Name 0",
/*status_text=*/"",
/*icon=*/global_media_controls::mojom::IconType::kTv);
devices.push_back(std::move(device));
GetMediaCastListView()->OnDevicesUpdated(std::move(devices));
}
base::test::ScopedFeatureList feature_list_;
std::unique_ptr<views::Widget> widget_;
raw_ptr<MediaCastAudioSelectorView> view_ = nullptr;
MockDeviceListHost device_list_host_;
mojo::Remote<global_media_controls::mojom::DeviceListClient> client_remote_;
};
TEST_F(MediaCastAudioSelectorViewTest, VisibilityChanges) {
// Adding cast devices creates views.
AddCastDevices();
EXPECT_EQ(GetContainerChildViews().size(), 2u);
// Seletor view is not visible before showing up the list views.
EXPECT_FALSE(GetListViewContainer()->GetVisible());
EXPECT_FALSE(GetSelectorView()->IsDeviceSelectorExpanded());
// Seletor view not visible after showing up the list views.
GetSelectorView()->ShowDevices();
EXPECT_TRUE(GetListViewContainer()->GetVisible());
EXPECT_TRUE(GetSelectorView()->IsDeviceSelectorExpanded());
// Seletor view is not visible after hiding the list views.
GetSelectorView()->HideDevices();
EXPECT_FALSE(GetListViewContainer()->GetVisible());
EXPECT_FALSE(GetSelectorView()->IsDeviceSelectorExpanded());
}
} // namespace ash

@ -0,0 +1,196 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/system/cast/media_cast_list_view.h"
#include <map>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "ash/public/cpp/system_tray_client.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_provider.h"
#include "ash/style/pill_button.h"
#include "ash/style/typography.h"
#include "ash/system/tray/hover_highlight_view.h"
#include "ash/system/tray/tray_constants.h"
#include "ash/system/tray/tray_detailed_view.h"
#include "ash/system/tray/tray_popup_utils.h"
#include "ash/system/tray/tri_view.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "components/global_media_controls/public/mojom/device_service.mojom-shared.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/throbber.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
namespace ash {
namespace {
DEFINE_OWNED_UI_CLASS_PROPERTY_KEY(std::string, kDeviceIdKey, nullptr)
// Extra spacing to add between cast stop buttons and the edge of `tri_view()`
// header entry.
constexpr gfx::Insets kItemTriViewPaddings =
gfx::Insets::TLBR(0, 0, 0, kTrayPopupLabelRightPadding);
// The paddings for the header entry and cast item entries.
constexpr gfx::Insets kHeaderInsets = gfx::Insets::TLBR(0, 0, 0, 8);
constexpr gfx::Insets kHighlightHoverViewInsets = gfx::Insets::VH(0, 16);
// Returns the correct vector icon for the icon type.
// TODO(b/327507429): Revisit the icons for each casting type.
const gfx::VectorIcon& GetVectorIcon(
global_media_controls::mojom::IconType icon) {
switch (icon) {
case global_media_controls::mojom::IconType::kInfo:
return kSystemMenuCastGenericIcon;
case global_media_controls::mojom::IconType::kInput:
return kSystemMenuCastGenericIcon;
case global_media_controls::mojom::IconType::kSpeaker:
return kSystemMenuCastAudioIcon;
case global_media_controls::mojom::IconType::kSpeakerGroup:
return kSystemMenuCastAudioGroupIcon;
case global_media_controls::mojom::IconType::kTv:
return kSystemMenuCastGenericIcon;
// In these cases the icon is a placeholder and doesn't actually get
// shown.
// TODO(b/327507429): Show the loading throbber in the
// `MediaCastListView`.
case global_media_controls::mojom::IconType::kThrobber:
case global_media_controls::mojom::IconType::kUnknown:
return kSystemMenuCastGenericIcon;
}
}
} // namespace
MediaCastListView::MediaCastListView(
base::RepeatingClosure stop_casting_callback,
base::RepeatingCallback<void(const std::string& device_id)>
start_casting_callback,
base::RepeatingCallback<void(const bool has_devices)>
on_devices_updated_callback,
mojo::PendingReceiver<global_media_controls::mojom::DeviceListClient>
receiver)
: TrayDetailedView(/*delegate=*/nullptr),
on_stop_casting_callback_(std::move(stop_casting_callback)),
on_start_casting_callback_(std::move(start_casting_callback)),
on_devices_updated_callback_(std::move(on_devices_updated_callback)),
receiver_(this, std::move(receiver)) {
// Creates the cast item container.
item_container_ =
AddChildView(views::Builder<views::BoxLayoutView>()
.SetOrientation(views::BoxLayout::Orientation::kVertical)
.Build());
}
MediaCastListView::~MediaCastListView() = default;
void MediaCastListView::OnDevicesUpdated(
std::vector<global_media_controls::mojom::DevicePtr> devices) {
// Update UI.
item_container_->RemoveAllChildViews();
if (!devices.empty()) {
CreateCastingHeader();
}
// Add a view for each receiver.
for (const auto& device : devices) {
HoverHighlightView* container =
AddScrollListItem(item_container_, GetVectorIcon(device->icon),
base::UTF8ToUTF16(device->name));
container->tri_view()->SetInsets(kHighlightHoverViewInsets);
container->SetProperty(kDeviceIdKey, device->id);
}
// Inform the Panel view on devices update.
on_devices_updated_callback_.Run(
/*has_devices=*/!devices.empty());
item_container_->InvalidateLayout();
}
void MediaCastListView::HandleViewClicked(views::View* view) {
if (view->GetProperty(kDeviceIdKey)) {
on_start_casting_callback_.Run(*view->GetProperty(kDeviceIdKey));
}
}
// TODO(b/327507429): Refactor this method to share it with the
// CastDetailedView.
std::unique_ptr<PillButton> MediaCastListView::CreateStopButton() {
auto stop_button = std::make_unique<PillButton>(
base::BindRepeating(
[](base::WeakPtr<MediaCastListView> view) {
view->on_stop_casting_callback_.Run();
},
weak_factory_.GetWeakPtr()),
l10n_util::GetStringUTF16(IDS_ASH_STATUS_TRAY_CAST_STOP_CASTING),
PillButton::kDefaultWithIconLeading, &kQuickSettingsCircleStopIcon);
stop_button->SetBackgroundColorId(cros_tokens::kCrosSysErrorContainer);
stop_button->SetIconColorId(cros_tokens::kCrosSysError);
stop_button->SetButtonTextColorId(cros_tokens::kCrosSysError);
stop_button->SetID(kStopCastingButtonId);
return stop_button;
}
void MediaCastListView::CreateCastingHeader() {
auto casting_header = base::WrapUnique(TrayPopupUtils::CreateDefaultRowView(
/*use_wide_layout=*/false));
TrayPopupUtils::ConfigureHeader(casting_header.get());
// Set casting icon on left side.
auto image_view = base::WrapUnique(
TrayPopupUtils::CreateMainImageView(/*use_wide_layout=*/false));
image_view->SetImage(gfx::CreateVectorIcon(
on_stop_casting_callback_.is_null() ? kQuickSettingsCastIcon
: kQuickSettingsCastConnectedIcon,
GetColorProvider()->GetColor(cros_tokens::kCrosSysOnSurface)));
image_view->SetBackground(views::CreateSolidBackground(SK_ColorTRANSPARENT));
casting_header->AddView(TriView::Container::START, image_view.release());
// Set message label in middle of row.
std::unique_ptr<views::Label> label =
base::WrapUnique(TrayPopupUtils::CreateDefaultLabel());
label->SetText(l10n_util::GetStringUTF16(
IDS_ASH_GLOBAL_MEDIA_CONTROLS_CAST_LIST_HEADER));
label->SetBackground(views::CreateSolidBackground(SK_ColorTRANSPARENT));
label->SetEnabledColorId(cros_tokens::kCrosSysOnSurface);
label->SetAutoColorReadabilityEnabled(false);
TypographyProvider::Get()->StyleLabel(TypographyToken::kCrosBody2, *label);
casting_header->AddView(TriView::Container::CENTER, std::move(label));
casting_header->SetContainerBorder(
TriView::Container::CENTER,
views::CreateEmptyBorder(kItemTriViewPaddings));
// Add stop button to the entry if it's casting.
if (!on_stop_casting_callback_.is_null()) {
std::unique_ptr<PillButton> stop_button = CreateStopButton();
casting_header->AddView(TriView::Container::END, stop_button.release());
casting_header->SetContainerVisible(TriView::Container::END, true);
} else {
// Nothing to the right of the text.
casting_header->SetContainerVisible(TriView::Container::END, false);
}
casting_header->SetInsets(kHeaderInsets);
item_container_->AddChildView(std::move(casting_header));
}
BEGIN_METADATA(MediaCastListView)
END_METADATA
} // namespace ash

@ -0,0 +1,91 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef ASH_SYSTEM_CAST_MEDIA_CAST_LIST_VIEW_H_
#define ASH_SYSTEM_CAST_MEDIA_CAST_LIST_VIEW_H_
#include <memory>
#include <string>
#include <vector>
#include "ash/ash_export.h"
#include "ash/system/tray/tray_detailed_view.h"
#include "base/functional/callback_forward.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/weak_ptr.h"
#include "components/global_media_controls/public/mojom/device_service.mojom.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "ui/base/metadata/metadata_header_macros.h"
namespace views {
class View;
} // namespace views
namespace ash {
class PillButton;
// View ID's.
inline constexpr int kStopCastingButtonId = 100;
inline constexpr int kMediaCastListViewMaxId = kStopCastingButtonId;
// This view displays a list of cast devices that can be clicked on and casted
// to. If it's currently casting, it shows a stop casting button on the header
// entry.
class ASH_EXPORT MediaCastListView
: public global_media_controls::mojom::DeviceListClient,
public TrayDetailedView {
METADATA_HEADER(MediaCastListView, TrayDetailedView)
public:
MediaCastListView(
base::RepeatingClosure stop_casting_callback,
base::RepeatingCallback<void(const std::string& device_id)>
start_casting_callback,
base::RepeatingCallback<void(const bool has_devices)>
on_devices_updated_callback,
mojo::PendingReceiver<global_media_controls::mojom::DeviceListClient>
receiver);
MediaCastListView(const MediaCastListView&) = delete;
MediaCastListView& operator=(const MediaCastListView&) = delete;
~MediaCastListView() override;
// global_media_controls::mojom::DeviceListClient:
void OnDevicesUpdated(
std::vector<global_media_controls::mojom::DevicePtr> devices) override;
private:
friend class MediaCastAudioSelectorViewTest;
friend class MediaCastListViewTest;
// TrayDetailedView:
void HandleViewClicked(views::View* view) override;
// Creates a stop button which, when pressed, stops casting.
std::unique_ptr<PillButton> CreateStopButton();
// Creates the header of the list. If it's currently casting, add the stop
// casting button.
void CreateCastingHeader();
// Cast item container.
raw_ptr<views::View> item_container_ = nullptr;
// Callbacks to stop/start casting.
base::RepeatingClosure on_stop_casting_callback_;
base::RepeatingCallback<void(const std::string&)> on_start_casting_callback_;
// Runs on devices updated.
base::RepeatingCallback<void(const bool)> on_devices_updated_callback_;
mojo::Receiver<global_media_controls::mojom::DeviceListClient> receiver_;
base::WeakPtrFactory<MediaCastListView> weak_factory_{this};
};
} // namespace ash
#endif // ASH_SYSTEM_CAST_MEDIA_CAST_LIST_VIEW_H_

@ -0,0 +1,179 @@
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/system/cast/media_cast_list_view.h"
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "ash/style/pill_button.h"
#include "ash/system/tray/hover_highlight_view.h"
#include "ash/test/ash_test_base.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/memory/raw_ptr.h"
#include "base/test/scoped_feature_list.h"
#include "media/base/media_switches.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "ui/views/view.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
namespace ash {
class MediaCastListViewTest : public AshTestBase {
public:
// Mock callbacks:
MOCK_METHOD(void, OnStopCasting, (), ());
MOCK_METHOD(void, OnCastDeviceSelected, (const std::string& device_id), ());
MOCK_METHOD(void, OnDeviceListUpdated, (const bool has_devices), ());
// AshTestBase:
void SetUp() override {
feature_list_.InitAndEnableFeature(media::kBackgroundListening);
AshTestBase::SetUp();
// Create a widget so tests can click on views.
widget_ = CreateFramelessTestWidget();
widget_->SetFullscreen(true);
view_ = widget_->SetContentsView(std::make_unique<MediaCastListView>(
base::BindRepeating(&MediaCastListViewTest::OnStopCasting,
base::Unretained(this)),
base::BindRepeating(&MediaCastListViewTest::OnCastDeviceSelected,
base::Unretained(this)),
base::BindRepeating(&MediaCastListViewTest::OnDeviceListUpdated,
base::Unretained(this)),
client_remote_.BindNewPipeAndPassReceiver()));
}
void TearDown() override {
view_ = nullptr;
widget_.reset();
AshTestBase::TearDown();
}
MediaCastListView* media_cast_list_view() { return view_; }
std::vector<raw_ptr<views::View, VectorExperimental>>
GetContainerChildViews() {
return view_->item_container_->children();
}
views::View* GetStopCastingButton() {
return view_->GetViewByID(kStopCastingButtonId);
}
// Adds 3 simulated cast devices.
void AddCastDevices() {
std::vector<global_media_controls::mojom::DevicePtr> devices;
global_media_controls::mojom::DevicePtr device1 =
global_media_controls::mojom::Device::New(
/*id=*/"fake_sink_id_1",
/*name=*/"Sink Name 1",
/*status_text=*/"",
/*icon=*/global_media_controls::mojom::IconType::kTv);
devices.push_back(std::move(device1));
global_media_controls::mojom::DevicePtr device2 =
global_media_controls::mojom::Device::New(
/*id=*/"fake_sink_id_2",
/*name=*/"Sink Name 2",
/*status_text=*/"",
/*icon=*/global_media_controls::mojom::IconType::kSpeaker);
devices.push_back(std::move(device2));
global_media_controls::mojom::DevicePtr device3 =
global_media_controls::mojom::Device::New(
/*id=*/"fake_sink_id_3",
/*name=*/"Sink Name 3",
/*status_text=*/"",
/*icon=*/global_media_controls::mojom::IconType::kSpeakerGroup);
devices.push_back(std::move(device3));
view_->OnDevicesUpdated(std::move(devices));
}
// Removes simulated cast devices.
void ResetCastDevices() { view_->OnDevicesUpdated({}); }
// The entries may not be rendered immediately due to the async call of
// `InvalidateLayout`. Here we manually call `layout` to make sure the views
// are rendered.
void LayoutEntriesIfNecessary() {
view_->GetWidget()->LayoutRootViewIfNecessary();
}
base::test::ScopedFeatureList feature_list_;
std::unique_ptr<views::Widget> widget_;
raw_ptr<MediaCastListView> view_ = nullptr;
bool stop_casting_ = false;
bool start_casting_ = false;
bool update_devices_callback_ = false;
bool is_updated_devices_empty_ = false;
std::string current_device_id_;
mojo::Remote<global_media_controls::mojom::DeviceListClient> client_remote_;
};
TEST_F(MediaCastListViewTest, ViewsCreatedForCastDevices) {
// Adding cast devices creates views.
AddCastDevices();
LayoutEntriesIfNecessary();
// One header row and 3 device row.
ASSERT_EQ(GetContainerChildViews().size(), 4u);
HoverHighlightView* row1 =
views::AsViewClass<HoverHighlightView>(GetContainerChildViews()[1]);
EXPECT_EQ(row1->text_label()->GetText(), u"Sink Name 1");
EXPECT_CALL(*this, OnCastDeviceSelected("fake_sink_id_1")).Times(1);
LeftClickOn(row1);
testing::Mock::VerifyAndClearExpectations(this);
HoverHighlightView* row2 =
static_cast<HoverHighlightView*>(GetContainerChildViews()[2]);
EXPECT_EQ(row2->text_label()->GetText(), u"Sink Name 2");
EXPECT_CALL(*this, OnCastDeviceSelected("fake_sink_id_2")).Times(1);
LeftClickOn(row2);
testing::Mock::VerifyAndClearExpectations(this);
HoverHighlightView* row3 =
static_cast<HoverHighlightView*>(GetContainerChildViews()[3]);
EXPECT_EQ(row3->text_label()->GetText(), u"Sink Name 3");
EXPECT_CALL(*this, OnCastDeviceSelected("fake_sink_id_3")).Times(1);
LeftClickOn(row3);
testing::Mock::VerifyAndClearExpectations(this);
}
TEST_F(MediaCastListViewTest, WithNoDevices) {
// Updates with an default device list.
EXPECT_CALL(*this, OnDeviceListUpdated).Times(1);
AddCastDevices();
EXPECT_EQ(GetContainerChildViews().size(), 4u);
ASSERT_TRUE(GetStopCastingButton());
testing::Mock::VerifyAndClearExpectations(this);
// Updates with an empty device list. Should not show `StopCastingButton`.
EXPECT_CALL(*this, OnDeviceListUpdated).Times(1);
ResetCastDevices();
EXPECT_EQ(GetContainerChildViews().size(), 0u);
EXPECT_EQ(GetStopCastingButton(), nullptr);
testing::Mock::VerifyAndClearExpectations(this);
}
TEST_F(MediaCastListViewTest, StartAndStopCasting) {
AddCastDevices();
LayoutEntriesIfNecessary();
EXPECT_FALSE(GetContainerChildViews().empty());
views::View* first_view = GetContainerChildViews()[1];
// Clicking on an entry triggers a cast session.
EXPECT_CALL(*this, OnCastDeviceSelected("fake_sink_id_1")).Times(1);
LeftClickOn(first_view);
testing::Mock::VerifyAndClearExpectations(this);
// Clicking on stop button triggers stop casting.
EXPECT_CALL(*this, OnStopCasting).Times(1);
LeftClickOn(GetStopCastingButton());
testing::Mock::VerifyAndClearExpectations(this);
}
} // namespace ash

@ -5,11 +5,13 @@
#include "chrome/browser/ui/ash/global_media_controls/media_notification_provider_impl.h"
#include "ash/shell.h"
#include "ash/system/cast/media_cast_audio_selector_view.h"
#include "ash/system/media/media_color_theme.h"
#include "ash/system/media/media_notification_provider.h"
#include "ash/system/media/media_notification_provider_observer.h"
#include "ash/system/media/media_tray.h"
#include "ash/system/status_area_widget.h"
#include "base/functional/callback_forward.h"
#include "base/metrics/histogram_functions.h"
#include "chrome/browser/ash/crosapi/crosapi_ash.h"
#include "chrome/browser/ash/crosapi/crosapi_manager.h"
@ -190,6 +192,23 @@ MediaNotificationProviderImpl::BuildDeviceSelectorView(
base::WeakPtr<media_message_center::MediaNotificationItem> item,
global_media_controls::GlobalMediaControlsEntryPoint entry_point,
bool show_devices) {
// Returns the Ash `MediaCastAudioSelectorView` if BackgroundListening feature
// is enabled.
if (base::FeatureList::IsEnabled(media::kBackgroundListening)) {
auto* const profile = GetProfile();
auto* const device_service = GetDeviceService(item);
if (!ShouldShowDeviceSelectorView(profile, device_service, id, item,
&device_selector_delegate_)) {
return nullptr;
}
auto device_set = CreateHostAndClient(profile, id, item, device_service);
return std::make_unique<MediaCastAudioSelectorView>(
std::move(device_set.host), std::move(device_set.client),
GetStopCastingCallback(profile, id, item), show_devices);
}
return BuildDeviceSelector(id, item, GetDeviceService(item),
&device_selector_delegate_, GetProfile(),
entry_point, show_devices, media_color_theme_);

@ -4,6 +4,8 @@
#include "chrome/browser/ui/views/global_media_controls/media_item_ui_helper.h"
#include <string>
#include "chrome/browser/media/router/media_router_feature.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/global_media_controls/cast_media_notification_item.h"
@ -25,10 +27,40 @@
namespace {
bool ShouldShowDeviceSelectorView(
const std::string& item_id,
void UpdateMediaSessionItemReceiverName(
base::WeakPtr<media_message_center::MediaNotificationItem> item,
Profile* profile) {
const std::optional<media_router::MediaRoute>& route) {
if (item->GetSourceType() ==
media_message_center::SourceType::kLocalMediaSession) {
auto* media_session_item =
static_cast<global_media_controls::MediaSessionNotificationItem*>(
item.get());
if (route.has_value()) {
media_session_item->UpdateDeviceName(route->media_sink_name());
} else {
media_session_item->UpdateDeviceName(std::nullopt);
}
}
}
} // namespace
HostAndClientPair::HostAndClientPair() = default;
HostAndClientPair::HostAndClientPair(HostAndClientPair&&) = default;
HostAndClientPair& HostAndClientPair::operator=(HostAndClientPair&&) = default;
HostAndClientPair::~HostAndClientPair() = default;
bool ShouldShowDeviceSelectorView(
Profile* profile,
global_media_controls::mojom::DeviceService* device_service,
const std::string& item_id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item,
MediaItemUIDeviceSelectorDelegate* selector_delegate) {
if (!device_service || !selector_delegate || !profile || !item) {
return false;
}
auto source_type = item->GetSourceType();
if (source_type == media_message_center::SourceType::kCast) {
return false;
@ -47,23 +79,68 @@ bool ShouldShowDeviceSelectorView(
return true;
}
void UpdateMediaSessionItemReceiverName(
base::WeakPtr<media_message_center::MediaNotificationItem> item,
const std::optional<media_router::MediaRoute>& route) {
if (item->GetSourceType() ==
media_message_center::SourceType::kLocalMediaSession) {
auto* media_session_item =
static_cast<global_media_controls::MediaSessionNotificationItem*>(
item.get());
if (route.has_value()) {
media_session_item->UpdateDeviceName(route->media_sink_name());
HostAndClientPair CreateHostAndClient(
Profile* profile,
const std::string& id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item,
global_media_controls::mojom::DeviceService* device_service) {
mojo::PendingRemote<global_media_controls::mojom::DeviceListHost> host;
mojo::PendingRemote<global_media_controls::mojom::DeviceListClient> client;
auto client_receiver = client.InitWithNewPipeAndPassReceiver();
if (media_router::GlobalMediaControlsCastStartStopEnabled(profile)) {
if (item->GetSourceType() ==
media_message_center::SourceType::kLocalMediaSession) {
device_service->GetDeviceListHostForSession(
id, host.InitWithNewPipeAndPassReceiver(), std::move(client));
} else {
media_session_item->UpdateDeviceName(std::nullopt);
device_service->GetDeviceListHostForPresentation(
host.InitWithNewPipeAndPassReceiver(), std::move(client));
}
}
HostAndClientPair host_and_client;
host_and_client.host = std::move(host);
host_and_client.client = std::move(client_receiver);
return host_and_client;
}
} // namespace
base::RepeatingClosure GetStopCastingCallback(
Profile* profile,
const std::string& id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item) {
base::RepeatingClosure stop_casting_callback;
// Show a footer view for a local media item when it has an associated Remote
// Playback session or a Tab Mirroring Session.
if (item->GetSourceType() !=
media_message_center::SourceType::kLocalMediaSession) {
return stop_casting_callback;
}
auto route = GetSessionRoute(id, item, profile);
UpdateMediaSessionItemReceiverName(item, route);
if (!route.has_value()) {
return stop_casting_callback;
}
const auto& route_id = route->media_route_id();
auto cast_mode = HasRemotePlaybackRoute(item)
? media_router::MediaCastMode::REMOTE_PLAYBACK
: media_router::MediaCastMode::TAB_MIRROR;
stop_casting_callback = base::BindRepeating(
[](const std::string& route_id, media_router::MediaRouter* router,
media_router::MediaCastMode cast_mode) {
router->TerminateRoute(route_id);
MediaItemUIMetrics::RecordStopCastingMetrics(cast_mode);
if (cast_mode == media_router::MediaCastMode::TAB_MIRROR) {
MediaDialogView::HideDialog();
}
},
route_id,
media_router::MediaRouterFactory::GetApiForBrowserContext(profile),
cast_mode);
return stop_casting_callback;
}
bool HasRemotePlaybackRoute(
base::WeakPtr<media_message_center::MediaNotificationItem> item) {
@ -141,30 +218,20 @@ BuildDeviceSelector(
global_media_controls::GlobalMediaControlsEntryPoint entry_point,
bool show_devices,
std::optional<media_message_center::MediaColorTheme> media_color_theme) {
if (!device_service || !selector_delegate || !profile ||
!ShouldShowDeviceSelectorView(id, item, profile)) {
if (!ShouldShowDeviceSelectorView(profile, device_service, id, item,
selector_delegate)) {
return nullptr;
}
const bool is_local_media_session =
item->GetSourceType() ==
media_message_center::SourceType::kLocalMediaSession;
const bool gmc_cast_start_stop_enabled =
media_router::GlobalMediaControlsCastStartStopEnabled(profile);
mojo::PendingRemote<global_media_controls::mojom::DeviceListHost> host;
mojo::PendingRemote<global_media_controls::mojom::DeviceListClient> client;
auto client_receiver = client.InitWithNewPipeAndPassReceiver();
if (gmc_cast_start_stop_enabled) {
if (is_local_media_session) {
device_service->GetDeviceListHostForSession(
id, host.InitWithNewPipeAndPassReceiver(), std::move(client));
} else {
device_service->GetDeviceListHostForPresentation(
host.InitWithNewPipeAndPassReceiver(), std::move(client));
}
}
auto device_set = CreateHostAndClient(profile, id, item, device_service);
return std::make_unique<MediaItemUIDeviceSelectorView>(
id, selector_delegate, std::move(host), std::move(client_receiver),
id, selector_delegate, std::move(device_set.host),
std::move(device_set.client),
/*has_audio_output=*/is_local_media_session, entry_point, show_devices,
media_color_theme);
}
@ -200,36 +267,12 @@ std::unique_ptr<global_media_controls::MediaItemUIFooter> BuildFooter(
static_cast<CastMediaNotificationItem*>(item.get())->GetWeakPtr()));
}
// Show a footer view for a local media item when it has an associated Remote
// Playback session or a Tab Mirroring Session.
if (item->GetSourceType() !=
media_message_center::SourceType::kLocalMediaSession) {
base::RepeatingClosure stop_casting_cb =
GetStopCastingCallback(profile, id, item);
if (stop_casting_cb.is_null()) {
return nullptr;
}
auto route = GetSessionRoute(id, item, profile);
UpdateMediaSessionItemReceiverName(item, route);
if (!route.has_value()) {
return nullptr;
}
const auto& route_id = route->media_route_id();
auto cast_mode = HasRemotePlaybackRoute(item)
? media_router::MediaCastMode::REMOTE_PLAYBACK
: media_router::MediaCastMode::TAB_MIRROR;
auto stop_casting_cb = base::BindRepeating(
[](const std::string& route_id, media_router::MediaRouter* router,
media_router::MediaCastMode cast_mode) {
router->TerminateRoute(route_id);
MediaItemUIMetrics::RecordStopCastingMetrics(cast_mode);
if (cast_mode == media_router::MediaCastMode::TAB_MIRROR) {
MediaDialogView::HideDialog();
}
},
route_id,
media_router::MediaRouterFactory::GetApiForBrowserContext(profile),
cast_mode);
return std::make_unique<MediaItemUILegacyCastFooterView>(
std::move(stop_casting_cb));
}

@ -11,6 +11,7 @@
#include "base/memory/weak_ptr.h"
#include "components/global_media_controls/public/constants.h"
#include "components/global_media_controls/public/mojom/device_service.mojom.h"
#include "components/media_message_center/notification_theme.h"
class MediaItemUIDeviceSelectorDelegate;
@ -37,9 +38,44 @@ namespace media_router {
class MediaRoute;
} // namespace media_router
// A set of the pending remote of `DeviceListHost` and the pending receiver of
// `DeviceListClient`.
struct HostAndClientPair {
HostAndClientPair();
HostAndClientPair(HostAndClientPair&&);
HostAndClientPair& operator=(HostAndClientPair&&);
~HostAndClientPair();
mojo::PendingRemote<global_media_controls::mojom::DeviceListHost> host;
mojo::PendingReceiver<global_media_controls::mojom::DeviceListClient> client;
};
// These helper functions are shared among Chrome OS and other desktop
// platforms.
// Checks if we should show the device selector view (cast device list) under
// the media ui view. Returns false if any of `device_service`,
// `selector_delegate`, `profile`, `item` is null.
bool ShouldShowDeviceSelectorView(
Profile* profile,
global_media_controls::mojom::DeviceService* device_service,
const std::string& item_id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item,
MediaItemUIDeviceSelectorDelegate* selector_delegate);
// Creates and returns a `HostAndClientPair`.
HostAndClientPair CreateHostAndClient(
Profile* profile,
const std::string& id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item,
global_media_controls::mojom::DeviceService* device_service);
// Returns the stop casting callback if it's casting.
base::RepeatingClosure GetStopCastingCallback(
Profile* profile,
const std::string& id,
const base::WeakPtr<media_message_center::MediaNotificationItem>& item);
// Returns whether `item` has an associated Remote Playback route.
bool HasRemotePlaybackRoute(
base::WeakPtr<media_message_center::MediaNotificationItem> item);

@ -675,7 +675,15 @@ void MediaItemUIDetailedView::UpdateCastingState() {
start_casting_button_->UpdateText(
IDS_MEDIA_MESSAGE_CENTER_MEDIA_NOTIFICATION_ACTION_SHOW_DEVICE_LIST);
}
device_selector_view_separator_->SetVisible(is_expanded);
bool is_ash_background_listening_enabled = false;
#if BUILDFLAG(IS_CHROMEOS_ASH)
is_ash_background_listening_enabled =
base::FeatureList::IsEnabled(media::kBackgroundListening);
#endif // BUILDFLAG(IS_CHROMEOS_ASH)
device_selector_view_separator_->SetVisible(
is_expanded && !is_ash_background_listening_enabled);
} else {
device_selector_view_->SetVisible(false);
device_selector_view_separator_->SetVisible(false);

@ -501,4 +501,49 @@ TEST_F(MediaItemUIDetailedViewTest, ChapterList) {
#endif
}
TEST_F(MediaItemUIDetailedViewTest, ShouldNotShowDeviceSelectorViewForAsh) {
#if BUILDFLAG(IS_CHROMEOS_ASH)
base::test::ScopedFeatureList scoped_feature_list;
scoped_feature_list.InitAndEnableFeature(media::kBackgroundListening);
auto* start_casting_button = view()->GetStartCastingButtonForTesting();
auto* separator = view()->GetDeviceSelectorSeparatorForTesting();
auto* device_selector_view = view()->GetDeviceSelectorForTesting();
ASSERT_TRUE(start_casting_button);
EXPECT_FALSE(start_casting_button->GetVisible());
EXPECT_EQ(device_selector_view, device_selector());
EXPECT_FALSE(device_selector_view->GetVisible());
ASSERT_TRUE(separator);
EXPECT_FALSE(separator->GetVisible());
// Add devices to the list to show the start casting button.
EXPECT_CALL(*device_selector(), IsDeviceSelectorExpanded())
.WillOnce(Return(false));
view()->UpdateDeviceSelectorAvailability(/*has_devices=*/true);
EXPECT_TRUE(start_casting_button->GetVisible());
EXPECT_TRUE(device_selector_view->GetVisible());
EXPECT_FALSE(separator->GetVisible());
// Click the start casting button to show devices.
EXPECT_CALL(*device_selector(), ShowDevices());
EXPECT_CALL(*device_selector(), IsDeviceSelectorExpanded())
.WillOnce(Return(false))
.WillOnce(Return(true));
views::test::ButtonTestApi(start_casting_button)
.NotifyClick(ui::MouseEvent(ui::ET_MOUSE_PRESSED, gfx::Point(),
gfx::Point(), ui::EventTimeForNow(), 0, 0));
EXPECT_FALSE(separator->GetVisible());
// Click the start casting button to hide devices.
EXPECT_CALL(*device_selector(), HideDevices());
EXPECT_CALL(*device_selector(), IsDeviceSelectorExpanded())
.WillOnce(Return(true))
.WillOnce(Return(false));
views::test::ButtonTestApi(start_casting_button)
.NotifyClick(ui::MouseEvent(ui::ET_MOUSE_PRESSED, gfx::Point(),
gfx::Point(), ui::EventTimeForNow(), 0, 0));
EXPECT_FALSE(separator->GetVisible());
#endif
}
} // namespace global_media_controls