// 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.
#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif
#include "ash/system/focus_mode/sounds/focus_mode_sounds_controller.h"
#include <memory>
#include <utility>
#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/public/cpp/image_downloader.h"
#include "ash/public/cpp/session/session_types.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/shell_delegate.h"
#include "ash/system/focus_mode/focus_mode_controller.h"
#include "ash/system/focus_mode/focus_mode_metrics_recorder.h"
#include "ash/system/focus_mode/focus_mode_util.h"
#include "ash/system/focus_mode/sounds/focus_mode_soundscape_delegate.h"
#include "ash/system/focus_mode/sounds/focus_mode_youtube_music_delegate.h"
#include "ash/system/focus_mode/sounds/youtube_music/youtube_music_types.h"
#include "base/barrier_callback.h"
#include "base/check.h"
#include "base/check_is_test.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "components/prefs/pref_change_registrar.h"
#include "components/prefs/pref_service.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "services/media_session/public/cpp/media_session_service.h"
#include "services/media_session/public/cpp/util.h"
#include "services/media_session/public/mojom/media_session.mojom.h"
#include "url/gurl.h"
namespace ash {
namespace {
constexpr int kPlaylistNum = 4;
// Arrays for histogram records.
constexpr focus_mode_histogram_names::FocusModePlaylistChosen
soundscapes_chosen[] = {
focus_mode_histogram_names::FocusModePlaylistChosen::kSoundscapes1,
focus_mode_histogram_names::FocusModePlaylistChosen::kSoundscapes2,
focus_mode_histogram_names::FocusModePlaylistChosen::kSoundscapes3,
focus_mode_histogram_names::FocusModePlaylistChosen::kSoundscapes4};
constexpr focus_mode_histogram_names::FocusModePlaylistChosen
youtube_music_chosen[] = {
focus_mode_histogram_names::FocusModePlaylistChosen::kYouTubeMusic1,
focus_mode_histogram_names::FocusModePlaylistChosen::kYouTubeMusic2,
focus_mode_histogram_names::FocusModePlaylistChosen::kYouTubeMusic3,
focus_mode_histogram_names::FocusModePlaylistChosen::kYouTubeMusic4};
constexpr net::NetworkTrafficAnnotationTag kFocusModeSoundsThumbnailTag =
net::DefineNetworkTrafficAnnotation("focus_mode_sounds_image_downloader",
R"(
semantics {
sender: "Focus Mode"
description:
"Download Focus Mode Sounds playlist thumbnails which will be "
"shown on the focus mode panel."
trigger: "User opens a panel in Focus Mode."
data: "None."
destination: GOOGLE_OWNED_SERVICE
user_data {
type: NONE
}
internal {
contacts {
email: "[email protected]"
}
contacts {
email: "[email protected]"
}
}
last_reviewed: "2024-03-15"
}
policy {
cookies_allowed: NO
setting:
"This feature is off by default and can be overridden by user."
chrome_policy {
FocusModeSoundsEnabled {
FocusModeSoundsEnabled: "disabled"
}
}
})");
// Invoked upon completion of the `thumbnail` download. `thumbnail` can be a
// null image if the download attempt from the url failed.
void OnOneThumbnailDownloaded(
base::OnceCallback<void(
std::unique_ptr<FocusModeSoundsController::Playlist>)> barrier_callback,
std::string id,
std::string title,
const gfx::ImageSkia& thumbnail) {
std::move(barrier_callback)
.Run(std::make_unique<FocusModeSoundsController::Playlist>(id, title,
thumbnail));
}
// Re-order `playlists` according to the order of `data`.
void ReorderPlaylists(
const std::vector<FocusModeSoundsDelegate::Playlist>& data,
base::OnceCallback<
void(std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>)>
sorted_playlists_callback,
std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>
unsorted_playlists) {
std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>
sorted_playlists;
// Create `sorted_playlists` to match the given order.
for (const auto& item : data) {
auto iter = std::find_if(
unsorted_playlists.begin(), unsorted_playlists.end(),
[item](const std::unique_ptr<FocusModeSoundsController::Playlist>&
playlist) {
return playlist && playlist->playlist_id == item.id;
});
if (iter == unsorted_playlists.end()) {
continue;
}
sorted_playlists.push_back(std::move(*iter));
}
std::move(sorted_playlists_callback).Run(std::move(sorted_playlists));
}
// In response to receiving the playlists, start downloading the playlist
// thumbnails.
void DispatchRequests(
base::OnceCallback<
void(std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>)>
sorted_playlists_callback,
const std::vector<FocusModeSoundsDelegate::Playlist>& data) {
if (data.empty()) {
LOG(WARNING) << "Retrieving Playlist data failed.";
std::move(sorted_playlists_callback).Run({});
return;
}
CHECK_EQ(static_cast<int>(data.size()), kPlaylistNum);
// TODO(b/340304748): Currently, when opening the focus panel, we will clean
// up all saved data and then download all playlists. In the future, we can
// keep this cached and update if there are new playlists.
using BarrierReturn = std::unique_ptr<FocusModeSoundsController::Playlist>;
auto barrier_callback = base::BarrierCallback<BarrierReturn>(
/*num_callbacks=*/kPlaylistNum,
/*done_callback=*/base::BindOnce(&ReorderPlaylists, data,
std::move(sorted_playlists_callback)));
for (const auto& item : data) {
FocusModeSoundsController::DownloadTrackThumbnail(
item.thumbnail_url,
base::BindOnce(&OnOneThumbnailDownloaded, barrier_callback, item.id,
item.title));
}
}
// In response to receiving the track, start playing the track.
void OnTrackFetched(
FocusModeSoundsController::GetNextTrackCallback callback,
const std::optional<FocusModeSoundsDelegate::Track>& track) {
if (!track) {
// TODO(b/343961303): Potentially retry the request.
LOG(WARNING) << "Retrieving track failed";
}
std::move(callback).Run(track);
}
// Parses the ash.focus_mode.sounds_enabled pref and returns a set of the
// `SoundType`s that should be enabled.
base::flat_set<focus_mode_util::SoundType> ReadSoundSectionPolicy(
const PrefService* pref_service) {
CHECK(pref_service);
const std::string& enabled_sections_pref =
pref_service->GetString(prefs::kFocusModeSoundsEnabled);
if (enabled_sections_pref == focus_mode_util::kFocusModeSoundsEnabled) {
return {focus_mode_util::SoundType::kSoundscape,
focus_mode_util::SoundType::kYouTubeMusic};
} else if (enabled_sections_pref == focus_mode_util::kFocusSoundsOnly) {
return {focus_mode_util::SoundType::kSoundscape};
} else if (enabled_sections_pref ==
focus_mode_util::kFocusModeSoundsDisabled) {
return {};
}
// Unrecognized value. It's likely a new restriction so disable everything.
return {};
}
// Return true if there is no selected playlist, or if the selected playlist
// type doesn't match `playlists_fetched` (which means the selected playlist
// couldn't be found in this list), or if the selected playlist is found in the
// `playlists_fetched`.
bool MayContainsSelectedPlaylist(
const focus_mode_util::SelectedPlaylist& selected_playlist,
bool is_soundscape_type,
const std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>&
playlists_fetched) {
if (selected_playlist.empty() ||
selected_playlist.type !=
(is_soundscape_type ? focus_mode_util::SoundType::kSoundscape
: focus_mode_util::SoundType::kYouTubeMusic)) {
return true;
}
return base::Contains(playlists_fetched, selected_playlist.id,
&FocusModeSoundsController::Playlist::playlist_id);
}
bool HasAudioFocus(const base::UnguessableToken& focus_mode_request_id,
const base::UnguessableToken& session_request_id) {
return !focus_mode_request_id.is_empty() &&
focus_mode_request_id == session_request_id;
}
void RecordPlaylistPlayedLatency(focus_mode_util::SoundType playlist_type,
const base::Time& sounds_started_time) {
std::string histogram_name;
switch (playlist_type) {
case focus_mode_util::SoundType::kSoundscape:
histogram_name = focus_mode_histogram_names::
kSoundscapeLatencyInMillisecondsHistogramName;
break;
case focus_mode_util::SoundType::kYouTubeMusic:
histogram_name = focus_mode_histogram_names::
kYouTubeMusicLatencyInMillisecondsHistogramName;
break;
case focus_mode_util::SoundType::kNone:
// A selected playlist should always have a valid type.
NOTREACHED();
}
base::UmaHistogramCustomCounts(
/*name=*/histogram_name,
/*sample=*/(base::Time::Now() - sounds_started_time).InMilliseconds(),
/*min=*/0, /*exclusive_max=*/2000, /*buckets=*/50);
}
focus_mode_histogram_names::FocusModePlaylistChosen GetPlaylistChosenType(
int index,
focus_mode_util::SoundType sound_type) {
switch (sound_type) {
case focus_mode_util::SoundType::kSoundscape:
return soundscapes_chosen[index];
case focus_mode_util::SoundType::kYouTubeMusic:
return youtube_music_chosen[index];
case focus_mode_util::SoundType::kNone:
NOTREACHED();
}
}
void RecordPlaylistChosenHistogram(
const focus_mode_util::SelectedPlaylist& selected_playlist,
const std::vector<std::unique_ptr<FocusModeSoundsController::Playlist>>&
selected_playlist_list) {
for (size_t i = 0; i < selected_playlist_list.size(); ++i) {
if (selected_playlist_list.at(i)->playlist_id == selected_playlist.id) {
base::UmaHistogramEnumeration(
/*name=*/focus_mode_histogram_names::kPlaylistChosenHistogram,
/*sample=*/GetPlaylistChosenType(i, selected_playlist.type));
return;
}
}
base::UmaHistogramEnumeration(
/*name=*/focus_mode_histogram_names::kPlaylistChosenHistogram,
/*sample=*/focus_mode_histogram_names::FocusModePlaylistChosen::kNone);
}
} // namespace
FocusModeSoundsController::FocusModeSoundsController()
: soundscape_delegate_(FocusModeSoundscapeDelegate::Create("en-US")),
youtube_music_delegate_(
std::make_unique<FocusModeYouTubeMusicDelegate>()) {
// TODO(b/341176182): Plumb the locale here and replace the default
// locale.
soundscape_playlists_.reserve(kPlaylistNum);
youtube_music_playlists_.reserve(kPlaylistNum);
// Default sound sections to enabled.
enabled_sound_sections_ = {focus_mode_util::SoundType::kSoundscape,
focus_mode_util::SoundType::kYouTubeMusic};
// `service` can be null in tests.
media_session::MediaSessionService* service =
Shell::Get()->shell_delegate()->GetMediaSessionService();
if (!service) {
return;
}
// Connect to receive audio focus events.
mojo::Remote<media_session::mojom::AudioFocusManager> audio_focus_remote;
service->BindAudioFocusManager(
audio_focus_remote.BindNewPipeAndPassReceiver());
audio_focus_remote->AddObserver(
audio_focus_observer_receiver_.BindNewPipeAndPassRemote());
// Connect to the `MediaControllerManager`.
service->BindMediaControllerManager(
media_controller_manager_remote_.BindNewPipeAndPassReceiver());
}
FocusModeSoundsController::~FocusModeSoundsController() = default;
// static
void FocusModeSoundsController::DownloadTrackThumbnail(
const GURL& url,
ImageDownloader::DownloadCallback callback) {
CHECK(!url.is_empty());
const UserSession* active_user_session =
Shell::Get()->session_controller()->GetUserSession(0);
CHECK(active_user_session);
ImageDownloader::Get()->Download(url, kFocusModeSoundsThumbnailTag,
active_user_session->user_info.account_id,
std::move(callback));
}
void FocusModeSoundsController::GetNextTrack(GetNextTrackCallback callback) {
if (selected_playlist_.type == focus_mode_util::SoundType::kNone ||
selected_playlist_.id.empty() || !IsPlaylistAllowed(selected_playlist_)) {
LOG(WARNING) << "No selected playlist";
std::move(callback).Run(std::nullopt);
return;
}
FocusModeSoundsDelegate* delegate;
if (selected_playlist_.type == focus_mode_util::SoundType::kSoundscape) {
delegate = soundscape_delegate_.get();
} else if (selected_playlist_.type ==
focus_mode_util::SoundType::kYouTubeMusic) {
delegate = youtube_music_delegate_.get();
} else {
LOG(ERROR) << "Unrecognized playlist type";
std::move(callback).Run(std::nullopt);
return;
}
delegate->GetNextTrack(selected_playlist_.id,
base::BindOnce(&OnTrackFetched, std::move(callback)));
}
void FocusModeSoundsController::AddObserver(Observer* observer) {
observers_.AddObserver(observer);
}
void FocusModeSoundsController::RemoveObserver(Observer* observer) {
observers_.RemoveObserver(observer);
}
void FocusModeSoundsController::OnFocusGained(
media_session::mojom::AudioFocusRequestStatePtr session) {
if (selected_playlist_.empty() || has_audio_focus_) {
return;
}
CHECK(session->request_id.has_value());
const auto& request_id =
FocusModeController::Get()->GetMediaSessionRequestId();
has_audio_focus_ = HasAudioFocus(request_id, session->request_id.value());
// If it's not our focus mode media gained the focus, or if the request id
// isn't changed, we will do nothing.
if (!has_audio_focus_ || media_session_request_id_ == request_id) {
return;
}
RecordPlaylistPlayedLatency(selected_playlist_.type, sounds_started_time_);
RecordPlaylistChosenHistogram(
selected_playlist_,
selected_playlist_.type == focus_mode_util::SoundType::kSoundscape
? soundscape_playlists_
: youtube_music_playlists_);
// Otherwise, we will bind the media controller observer with the specific
// request id to observe our media state.
media_session_request_id_ = request_id;
// `media_controller_manager_remote_` is null in test.
if (!media_controller_manager_remote_) {
return;
}
media_controller_remote_.reset();
media_controller_observer_receiver_.reset();
media_controller_manager_remote_->CreateMediaControllerForSession(
media_controller_remote_.BindNewPipeAndPassReceiver(),
media_session_request_id_);
media_controller_remote_->AddObserver(
media_controller_observer_receiver_.BindNewPipeAndPassRemote());
}
void FocusModeSoundsController::OnFocusLost(
media_session::mojom::AudioFocusRequestStatePtr session) {
if (!has_audio_focus_) {
return;
}
CHECK(session->request_id.has_value());
has_audio_focus_ =
HasAudioFocus(FocusModeController::Get()->GetMediaSessionRequestId(),
session->request_id.value());
}
void FocusModeSoundsController::OnRequestIdReleased(
const base::UnguessableToken& request_id) {
if (request_id.is_empty() || request_id != media_session_request_id_) {
return;
}
has_audio_focus_ = false;
media_session_request_id_ = base::UnguessableToken::Null();
if (selected_playlist_.empty() ||
selected_playlist_.state != focus_mode_util::SoundState::kPlaying) {
return;
}
// When entering the ending moment, the media widget will be closed, then
// the request id will be released. Hence, we need to update the state of
// the `selected_playlist_` when entering the ending moment.
selected_playlist_.state = focus_mode_util::SoundState::kSelected;
for (auto& observer : observers_) {
observer.OnPlaylistStateChanged();
}
}
void FocusModeSoundsController::MediaSessionInfoChanged(
media_session::mojom::MediaSessionInfoPtr session_info) {
if (!session_info) {
return;
}
switch (session_info->playback_state) {
case media_session::mojom::MediaPlaybackState::kPlaying:
selected_playlist_.state = focus_mode_util::SoundState::kPlaying;
break;
case media_session::mojom::MediaPlaybackState::kPaused:
selected_playlist_.state = focus_mode_util::SoundState::kPaused;
// TODO(b/343795949): Make sure to not count this when pause events are
// triggered by the ending moment in the future.
paused_event_count_++;
break;
}
for (auto& observer : observers_) {
observer.OnPlaylistStateChanged();
}
}
void FocusModeSoundsController::TogglePlaylist(
const focus_mode_util::SelectedPlaylist& playlist_data) {
if (playlist_data.state != focus_mode_util::SoundState::kNone) {
// When the user toggles a selected playlist, we will deselect it.
ResetSelectedPlaylist();
} else {
SelectPlaylist(playlist_data);
}
}
void FocusModeSoundsController::PausePlayback() {
if (simulate_playback_for_testing_) {
CHECK_IS_TEST();
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce([]() {
auto* sounds_controller =
FocusModeController::Get()->focus_mode_sounds_controller();
sounds_controller
->update_selected_playlist_state_for_testing( // IN-TEST
focus_mode_util::SoundState::kPaused);
}));
return;
}
if (media_controller_remote_ && media_controller_remote_.is_bound() &&
!media_session_request_id_.is_empty()) {
media_session::PerformMediaSessionAction(
media_session::mojom::MediaSessionAction::kPause,
media_controller_remote_);
}
}
void FocusModeSoundsController::ResumePlayingPlayback() {
if (simulate_playback_for_testing_) {
CHECK_IS_TEST();
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce([]() {
auto* sounds_controller =
FocusModeController::Get()->focus_mode_sounds_controller();
sounds_controller
->update_selected_playlist_state_for_testing( // IN-TEST
focus_mode_util::SoundState::kPlaying);
}));
return;
}
if (media_controller_remote_ && media_controller_remote_.is_bound() &&
!media_session_request_id_.is_empty()) {
media_session::PerformMediaSessionAction(
media_session::mojom::MediaSessionAction::kPlay,
media_controller_remote_);
}
}
void FocusModeSoundsController::DownloadPlaylistsForType(
const bool is_soundscape_type,
UpdateSoundsViewCallback update_sounds_view_callback) {
// During shutdown, `ImageDownloader` may not exist here.
if (!ImageDownloader::Get()) {
return;
}
if (is_soundscape_type) {
if (!base::Contains(enabled_sound_sections_,
focus_mode_util::SoundType::kSoundscape)) {
LOG(WARNING) << "Playlist download for Focus Sounds blocked by policy";
return;
}
} else {
if (!base::Contains(enabled_sound_sections_,
focus_mode_util::SoundType::kYouTubeMusic)) {
LOG(WARNING)
<< "Playlist download for YouTube Music blocked by policy or flag";
return;
}
}
auto sorted_playlists_callback =
base::BindOnce(&FocusModeSoundsController::OnAllThumbnailsDownloaded,
weak_factory_.GetWeakPtr(), is_soundscape_type,
std::move(update_sounds_view_callback));
if (is_soundscape_type) {
soundscape_delegate_->GetPlaylists(base::BindOnce(
&DispatchRequests, std::move(sorted_playlists_callback)));
} else {
youtube_music_delegate_->GetPlaylists(base::BindOnce(
&DispatchRequests, std::move(sorted_playlists_callback)));
}
}
void FocusModeSoundsController::UpdateFromUserPrefs() {
PrefService* active_user_prefs =
Shell::Get()->session_controller()->GetActivePrefService();
if (!active_user_prefs) {
return;
}
pref_registrar_.Reset();
pref_registrar_.Init(active_user_prefs);
pref_registrar_.Add(
prefs::kFocusModeSoundsEnabled,
base::BindRepeating(&FocusModeSoundsController::OnPrefChanged,
weak_factory_.GetWeakPtr()));
OnPrefChanged();
const auto& dict = active_user_prefs->GetDict(prefs::kFocusModeSoundSection);
// If the user didn't select any playlist before, we should show the
// `Soundscape` sound section as default behavior.
if (dict.empty()) {
sound_type_ = focus_mode_util::SoundType::kSoundscape;
} else {
sound_type_ = static_cast<focus_mode_util::SoundType>(
dict.FindInt(focus_mode_util::kSoundTypeKey).value());
}
}
void FocusModeSoundsController::SetYouTubeMusicNoPremiumCallback(
base::RepeatingClosure callback) {
CHECK(callback);
youtube_music_delegate_->SetNoPremiumCallback(std::move(callback));
}
void FocusModeSoundsController::ReportYouTubeMusicPlayback(
const youtube_music::PlaybackData& playback_data) {
youtube_music_delegate_->ReportPlayback(playback_data);
}
bool FocusModeSoundsController::IsPlaylistAllowed(
const focus_mode_util::SelectedPlaylist& playlist) const {
return base::Contains(enabled_sound_sections_, playlist.type);
}
void FocusModeSoundsController::SaveUserPref() {
if (PrefService* active_user_prefs =
Shell::Get()->session_controller()->GetActivePrefService()) {
base::Value::Dict dict;
dict.Set(focus_mode_util::kSoundTypeKey, static_cast<int>(sound_type_));
dict.Set(focus_mode_util::kPlaylistIdKey, selected_playlist_.id);
active_user_prefs->SetDict(prefs::kFocusModeSoundSection, std::move(dict));
}
}
void FocusModeSoundsController::ResetSelectedPlaylist() {
// TODO: Stop the music for current selected playlist.
selected_playlist_ = {};
// We still want to keep the user pref for sound section after deselecting the
// selected playlist.
SaveUserPref();
for (auto& observer : observers_) {
observer.OnSelectedPlaylistChanged();
}
}
void FocusModeSoundsController::SelectPlaylist(
const focus_mode_util::SelectedPlaylist& playlist_data) {
if (!IsPlaylistAllowed(playlist_data)) {
LOG(WARNING) << "Playlist cannot be selected due to policy";
return;
}
if (FocusModeController::Get()->in_focus_session()) {
SoundsStarted();
}
selected_playlist_ = playlist_data;
// TODO(b/337063849): Update the sound state when the media stream
// actually starts playing.
selected_playlist_.state = focus_mode_util::SoundState::kSelected;
sound_type_ = selected_playlist_.type;
// Reserve a place for the last selected playlist for future use.
if (sound_type_ == focus_mode_util::SoundType::kYouTubeMusic) {
youtube_music_delegate_->ReservePlaylistForGetPlaylists(
selected_playlist_.id);
}
SaveUserPref();
for (auto& observer : observers_) {
observer.OnSelectedPlaylistChanged();
}
}
void FocusModeSoundsController::OnAllThumbnailsDownloaded(
bool is_soundscape_type,
UpdateSoundsViewCallback update_sounds_view_callback,
std::vector<std::unique_ptr<Playlist>> sorted_playlists) {
// For the case that the `selected_playlist_` is missing from the list of
// playlists fetched from backend, we will show the cached playlist info if
// the selected playlist is currently playing; otherwise, we will clear the
// selected playlist in the controller.
if (!MayContainsSelectedPlaylist(selected_playlist_, is_soundscape_type,
sorted_playlists)) {
if (selected_playlist_.state == focus_mode_util::SoundState::kPlaying) {
sorted_playlists.pop_back();
sorted_playlists.insert(
sorted_playlists.begin() + 1,
std::make_unique<Playlist>(selected_playlist_.id,
selected_playlist_.title,
selected_playlist_.thumbnail));
} else {
ResetSelectedPlaylist();
}
}
if (is_soundscape_type) {
soundscape_playlists_.swap(sorted_playlists);
} else {
youtube_music_playlists_.swap(sorted_playlists);
}
// Only trigger the observer function when all the thumbnails are finished
// downloading.
// TODO(b/321071604): We may need to update this once caching is implemented.
std::move(update_sounds_view_callback).Run(is_soundscape_type);
}
void FocusModeSoundsController::OnPrefChanged() {
PrefService* active_user_prefs =
Shell::Get()->session_controller()->GetActivePrefService();
enabled_sound_sections_ = ReadSoundSectionPolicy(active_user_prefs);
// Hide the YTM sound section if the flag isn't enabled.
if (!features::IsFocusModeYTMEnabled()) {
enabled_sound_sections_.erase(focus_mode_util::SoundType::kYouTubeMusic);
}
}
} // namespace ash