Browse Source

UI: Add "YouTube Control Panel" dock panel

New dock panel with integrated youtube studio live control room.
This commit also modifies CI files.
Yuriy Chumak 2 years ago
parent
commit
81b588137a

+ 10 - 2
UI/cmake/feature-youtube.cmake

@@ -2,8 +2,16 @@ if(YOUTUBE_CLIENTID
    AND YOUTUBE_SECRET
    AND YOUTUBE_CLIENTID_HASH MATCHES "(0|[a-fA-F0-9]+)"
    AND YOUTUBE_SECRET_HASH MATCHES "(0|[a-fA-F0-9]+)")
-  target_sources(obs-studio PRIVATE auth-youtube.cpp auth-youtube.hpp window-youtube-actions.cpp
-                                    window-youtube-actions.hpp youtube-api-wrappers.cpp youtube-api-wrappers.hpp)
+  target_sources(
+    obs-studio
+    PRIVATE auth-youtube.cpp
+            auth-youtube.hpp
+            window-dock-youtube-app.cpp
+            window-dock-youtube-app.hpp
+            window-youtube-actions.cpp
+            window-youtube-actions.hpp
+            youtube-api-wrappers.cpp
+            youtube-api-wrappers.hpp)
 
   target_enable_feature(obs-studio "YouTube API connection" YOUTUBE_ENABLED)
 else()

+ 10 - 2
UI/cmake/legacy.cmake

@@ -330,8 +330,16 @@ endif()
 
 if(YOUTUBE_ENABLED)
   target_compile_definitions(obs PRIVATE YOUTUBE_ENABLED)
-  target_sources(obs PRIVATE auth-youtube.cpp auth-youtube.hpp youtube-api-wrappers.cpp youtube-api-wrappers.hpp
-                             window-youtube-actions.cpp window-youtube-actions.hpp)
+  target_sources(
+    obs
+    PRIVATE auth-youtube.cpp
+            auth-youtube.hpp
+            window-dock-youtube-app.cpp
+            window-dock-youtube-app.hpp
+            window-youtube-actions.cpp
+            window-youtube-actions.hpp
+            youtube-api-wrappers.cpp
+            youtube-api-wrappers.hpp)
 endif()
 
 if(OS_WINDOWS)

+ 3 - 0
UI/data/locale/en-US.ini

@@ -1526,3 +1526,6 @@ YouTube.Errors.liveChatDisabled="Live chat is disabled on this stream."
 YouTube.Errors.liveChatEnded="Live stream has ended."
 YouTube.Errors.messageTextInvalid="The message text is not valid."
 YouTube.Errors.rateLimitExceeded="You are sending messages too quickly."
+# Browser Dock
+YouTube.DocksRemoval.Title="Clear Legacy YouTube Browser Docks"
+YouTube.DocksRemoval.Text="These browser docks will be removed as deprecated:\n\n%1\nUse \"Docks/YouTube Live Control Room\" instead."

+ 7 - 0
UI/window-basic-auto-config.cpp

@@ -1050,6 +1050,13 @@ void AutoConfig::done(int result)
 		if (type == Type::Streaming)
 			SaveStreamSettings();
 		SaveSettings();
+
+#ifdef YOUTUBE_ENABLED
+		if (YouTubeAppDock::IsYTServiceSelected()) {
+			OBSBasic *main = OBSBasic::Get();
+			main->NewYouTubeAppDock();
+		}
+#endif
 	}
 }
 

+ 49 - 5
UI/window-basic-main.cpp

@@ -2170,6 +2170,9 @@ void OBSBasic::OBSInit()
 
 	/* ----------------------------- */
 	/* add custom browser docks      */
+#if defined(BROWSER_AVAILABLE) && defined(YOUTUBE_ENABLED)
+	YouTubeAppDock::CleanupYouTubeUrls();
+#endif
 
 #ifdef BROWSER_AVAILABLE
 	if (cef) {
@@ -2331,6 +2334,12 @@ void OBSBasic::OBSInit()
 	UpdatePreviewProgramIndicators();
 	OnFirstLoad();
 
+#ifdef YOUTUBE_ENABLED
+	/* setup YouTube app dock */
+	if (YouTubeAppDock::IsYTServiceSelected())
+		youtubeAppDock = new YouTubeAppDock();
+#endif
+
 	if (!hideWindowOnStart)
 		activateWindow();
 
@@ -6823,9 +6832,10 @@ void OBSBasic::DisplayStreamStartError()
 }
 
 #ifdef YOUTUBE_ENABLED
-void OBSBasic::YouTubeActionDialogOk(const QString &id, const QString &key,
-				     bool autostart, bool autostop,
-				     bool start_now)
+void OBSBasic::YouTubeActionDialogOk(const QString &broadcast_id,
+				     const QString &stream_id,
+				     const QString &key, bool autostart,
+				     bool autostop, bool start_now)
 {
 	//blog(LOG_DEBUG, "Stream key: %s", QT_TO_UTF8(key));
 	obs_service_t *service_obj = GetService();
@@ -6834,8 +6844,11 @@ void OBSBasic::YouTubeActionDialogOk(const QString &id, const QString &key,
 	const std::string a_key = QT_TO_UTF8(key);
 	obs_data_set_string(settings, "key", a_key.c_str());
 
-	const std::string an_id = QT_TO_UTF8(id);
-	obs_data_set_string(settings, "stream_id", an_id.c_str());
+	const std::string b_id = QT_TO_UTF8(broadcast_id);
+	obs_data_set_string(settings, "broadcast_id", b_id.c_str());
+
+	const std::string s_id = QT_TO_UTF8(stream_id);
+	obs_data_set_string(settings, "stream_id", s_id.c_str());
 
 	obs_service_update(service_obj, settings);
 	autoStartBroadcast = autostart;
@@ -7419,6 +7432,11 @@ void OBSBasic::StreamingStart()
 
 	OnActivate();
 
+#ifdef YOUTUBE_ENABLED
+	if (YouTubeAppDock::IsYTServiceSelected())
+		youtubeAppDock->IngestionStarted();
+#endif
+
 	blog(LOG_INFO, STREAMING_START);
 }
 
@@ -7499,6 +7517,11 @@ void OBSBasic::StreamingStop(int code, QString last_error)
 
 	OnDeactivate();
 
+#ifdef YOUTUBE_ENABLED
+	if (YouTubeAppDock::IsYTServiceSelected())
+		youtubeAppDock->IngestionStopped();
+#endif
+
 	blog(LOG_INFO, STREAMING_STOP);
 
 	if (encode_error) {
@@ -8462,6 +8485,27 @@ config_t *OBSBasic::Config() const
 	return basicConfig;
 }
 
+#ifdef YOUTUBE_ENABLED
+YouTubeAppDock *OBSBasic::GetYouTubeAppDock()
+{
+	return youtubeAppDock;
+}
+
+void OBSBasic::NewYouTubeAppDock()
+{
+	if (youtubeAppDock)
+		delete youtubeAppDock;
+	youtubeAppDock = new YouTubeAppDock();
+}
+
+void OBSBasic::DeleteYouTubeAppDock()
+{
+	if (youtubeAppDock)
+		delete youtubeAppDock;
+	youtubeAppDock = nullptr;
+}
+#endif
+
 void OBSBasic::UpdateEditMenu()
 {
 	QModelIndexList items = GetAllSelectedSourceItems();

+ 13 - 1
UI/window-basic-main.hpp

@@ -36,6 +36,9 @@
 #include "window-missing-files.hpp"
 #include "window-projector.hpp"
 #include "window-basic-about.hpp"
+#ifdef YOUTUBE_ENABLED
+#include "window-dock-youtube-app.hpp"
+#endif
 #include "auth-base.hpp"
 #include "log-viewer.hpp"
 #include "undo-stack-obs.hpp"
@@ -262,6 +265,9 @@ private:
 	QPointer<OBSBasicAdvAudio> advAudioWindow;
 	QPointer<OBSBasicFilters> filters;
 	QPointer<QDockWidget> statsDock;
+#ifdef YOUTUBE_ENABLED
+	QPointer<YouTubeAppDock> youtubeAppDock;
+#endif
 	QPointer<OBSAbout> about;
 	QPointer<OBSMissingFiles> missDialog;
 	QPointer<OBSLogViewer> logView;
@@ -631,7 +637,8 @@ private:
 #ifdef YOUTUBE_ENABLED
 	void YoutubeStreamCheck(const std::string &key);
 	void ShowYouTubeAutoStartWarning();
-	void YouTubeActionDialogOk(const QString &id, const QString &key,
+	void YouTubeActionDialogOk(const QString &broadcast_id,
+				   const QString &stream_id, const QString &key,
 				   bool autostart, bool autostop,
 				   bool start_now);
 #endif
@@ -1253,6 +1260,11 @@ public:
 				   const char *file) const override;
 
 	static void InitBrowserPanelSafeBlock();
+#ifdef YOUTUBE_ENABLED
+	void NewYouTubeAppDock();
+	void DeleteYouTubeAppDock();
+	YouTubeAppDock *GetYouTubeAppDock();
+#endif
 };
 
 class SceneRenameDelegate : public QStyledItemDelegate {

+ 18 - 0
UI/window-basic-settings-stream.cpp

@@ -770,6 +770,14 @@ void OBSBasicSettings::on_connectAccount_clicked()
 	auth = OAuthStreamKey::Login(this, service);
 	if (!!auth) {
 		OnAuthConnected();
+#ifdef YOUTUBE_ENABLED
+		if (IsYouTubeService(service)) {
+			if (!main->GetYouTubeAppDock()) {
+				main->NewYouTubeAppDock();
+			}
+			main->GetYouTubeAppDock()->AccountConnected();
+		}
+#endif
 
 		ui->useStreamKeyAdv->setVisible(false);
 	}
@@ -812,6 +820,16 @@ void OBSBasicSettings::on_disconnectAccount_clicked()
 
 	ui->connectedAccountLabel->setVisible(false);
 	ui->connectedAccountText->setVisible(false);
+
+#ifdef YOUTUBE_ENABLED
+	if (IsYouTubeService(service)) {
+		if (!main->GetYouTubeAppDock()) {
+			main->NewYouTubeAppDock();
+		}
+		main->GetYouTubeAppDock()->AccountDisconnected();
+		main->GetYouTubeAppDock()->Update();
+	}
+#endif
 }
 
 void OBSBasicSettings::on_useStreamKey_clicked()

+ 20 - 0
UI/window-basic-settings.cpp

@@ -48,6 +48,10 @@
 #include "window-basic-main-outputs.hpp"
 #include "window-projector.hpp"
 
+#ifdef YOUTUBE_ENABLED
+#include "youtube-api-wrappers.hpp"
+#endif
+
 #include <util/platform.h>
 #include <util/dstr.hpp>
 #include "ui-config.h"
@@ -4251,6 +4255,22 @@ void OBSBasicSettings::on_buttonBox_clicked(QAbstractButton *button)
 			return;
 
 		SaveSettings();
+
+#ifdef YOUTUBE_ENABLED
+		std::string service = ui->service->currentText().toStdString();
+		if (IsYouTubeService(service)) {
+			if (!main->GetYouTubeAppDock()) {
+				main->NewYouTubeAppDock();
+			}
+			main->GetYouTubeAppDock()->SettingsUpdated(
+				!IsYouTubeService(service) || stream1Changed);
+		} else {
+			if (main->GetYouTubeAppDock()) {
+				main->GetYouTubeAppDock()->AccountDisconnected();
+			}
+			main->DeleteYouTubeAppDock();
+		}
+#endif
 		ClearChanged();
 	}
 

+ 482 - 0
UI/window-dock-youtube-app.cpp

@@ -0,0 +1,482 @@
+#include <QUuid>
+
+#include "window-basic-main.hpp"
+#include "youtube-api-wrappers.hpp"
+#include "window-dock-youtube-app.hpp"
+
+#include "ui-config.h"
+#include "qt-wrappers.hpp"
+
+#include <nlohmann/json.hpp>
+using json = nlohmann::json;
+
+#ifdef YOUTUBE_WEBAPP_PLACEHOLDER
+static constexpr const char *YOUTUBE_WEBAPP_PLACEHOLDER_URL =
+	YOUTUBE_WEBAPP_PLACEHOLDER;
+#else
+static constexpr const char *YOUTUBE_WEBAPP_PLACEHOLDER_URL =
+	"https://studio.youtube.com/live/channel/UC/console?kc=OBS";
+#endif
+
+#ifdef YOUTUBE_WEBAPP_ADDRESS
+static constexpr const char *YOUTUBE_WEBAPP_ADDRESS_URL =
+	YOUTUBE_WEBAPP_ADDRESS;
+#else
+static constexpr const char *YOUTUBE_WEBAPP_ADDRESS_URL =
+	"https://studio.youtube.com/live/channel/%1/console?kc=OBS";
+#endif
+
+static constexpr const char *BROADCAST_CREATED = "BROADCAST_CREATED";
+static constexpr const char *BROADCAST_SELECTED = "BROADCAST_SELECTED";
+static constexpr const char *INGESTION_STARTED = "INGESTION_STARTED";
+static constexpr const char *INGESTION_STOPPED = "INGESTION_STOPPED";
+
+YouTubeAppDock::YouTubeAppDock()
+	: BrowserDock(),
+	  dockBrowser(nullptr),
+	  cookieManager(nullptr)
+{
+	OBSBasic::InitBrowserPanelSafeBlock();
+	AddYouTubeAppDock("YouTube Live Control Panel");
+}
+
+YouTubeAppDock::~YouTubeAppDock()
+{
+	if (cookieManager) {
+		cookieManager->FlushStore();
+		delete cookieManager;
+	}
+}
+
+bool YouTubeAppDock::IsYTServiceSelected()
+{
+	obs_service_t *service_obj = OBSBasic::Get()->GetService();
+	OBSDataAutoRelease settings = obs_service_get_settings(service_obj);
+	const char *service = obs_data_get_string(settings, "service");
+	return IsYouTubeService(service);
+}
+
+void YouTubeAppDock::AccountConnected()
+{
+	channelId.clear(); // renew channel id
+	UpdateChannelId();
+}
+
+void YouTubeAppDock::AccountDisconnected()
+{
+	SettingsUpdated(true);
+}
+
+void YouTubeAppDock::SettingsUpdated(bool cleanup)
+{
+	bool ytservice = IsYTServiceSelected();
+	SetVisibleYTAppDockInMenu(ytservice);
+
+	// definitely cleanup if YT switched off
+	if (!ytservice || cleanup) {
+		if (cookieManager)
+			cookieManager->DeleteCookies("", "");
+	}
+	if (ytservice)
+		Update();
+}
+
+std::string YouTubeAppDock::InitYTUserUrl()
+{
+	std::string user_url(YOUTUBE_WEBAPP_PLACEHOLDER_URL);
+
+	if (IsUserSignedIntoYT()) {
+		YoutubeApiWrappers *apiYouTube = GetYTApi();
+		if (apiYouTube) {
+			ChannelDescription channel_description;
+			if (apiYouTube->GetChannelDescription(
+				    channel_description)) {
+				QString url =
+					QString(YOUTUBE_WEBAPP_ADDRESS_URL)
+						.arg(channel_description.id);
+				user_url = url.toStdString();
+			} else {
+				blog(LOG_ERROR,
+				     "YT: InitYTUserUrl() Failed to get channel id");
+			}
+		}
+	} else {
+		blog(LOG_ERROR, "YT: InitYTUserUrl() User is not signed");
+	}
+
+	blog(LOG_DEBUG, "YT: InitYTUserUrl() User url: %s", user_url.c_str());
+	return user_url;
+}
+
+void YouTubeAppDock::AddYouTubeAppDock(const QString &title)
+{
+	QString bId(QUuid::createUuid().toString());
+	bId.replace(QRegularExpression("[{}-]"), "");
+	this->setProperty("uuid", bId);
+	this->setObjectName(title + "Object");
+	this->resize(580, 500);
+	this->setMinimumSize(400, 300);
+	this->setWindowTitle(title);
+	this->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	OBSBasic::Get()->addDockWidget(Qt::RightDockWidgetArea, this);
+	actionYTAppDock = OBSBasic::Get()->AddDockWidget(this);
+
+	if (IsYTServiceSelected()) {
+		const std::string url = InitYTUserUrl();
+		CreateBrowserWidget(url);
+
+		// reload panel layout
+		const char *dockStateStr = config_get_string(
+			App()->GlobalConfig(), "BasicWindow", "DockState");
+		if (dockStateStr) {
+			QByteArray dockState = QByteArray::fromBase64(
+				QByteArray(dockStateStr));
+			OBSBasic::Get()->restoreState(dockState);
+		}
+	} else {
+		this->setVisible(false);
+		actionYTAppDock->setVisible(false);
+	}
+}
+
+void YouTubeAppDock::CreateBrowserWidget(const std::string &url)
+{
+	std::string dir_name = std::string("obs_profile_cookies_youtube/") +
+			       config_get_string(OBSBasic::Get()->Config(),
+						 "Panels", "CookieId");
+	if (cookieManager)
+		delete cookieManager;
+	cookieManager = cef->create_cookie_manager(dir_name, true);
+
+	if (dockBrowser)
+		delete dockBrowser;
+	dockBrowser = cef->create_widget(this, url, cookieManager);
+	if (!dockBrowser)
+		return;
+
+	if (obs_browser_qcef_version() >= 1)
+		dockBrowser->allowAllPopups(true);
+
+	this->SetWidget(dockBrowser);
+	Update();
+}
+
+void YouTubeAppDock::SetVisibleYTAppDockInMenu(bool visible)
+{
+	if (!actionYTAppDock)
+		return;
+
+	actionYTAppDock->setVisible(visible);
+	this->setVisible(visible);
+}
+
+// only 'ACCOUNT' mode supported
+void YouTubeAppDock::BroadcastCreated(const char *stream_id)
+{
+	DispatchYTEvent(BROADCAST_CREATED, stream_id, YTSM_ACCOUNT);
+}
+
+// only 'ACCOUNT' mode supported
+void YouTubeAppDock::BroadcastSelected(const char *stream_id)
+{
+	DispatchYTEvent(BROADCAST_SELECTED, stream_id, YTSM_ACCOUNT);
+}
+
+// both 'ACCOUNT' and 'STREAM_KEY' modes supported
+void YouTubeAppDock::IngestionStarted()
+{
+	obs_service_t *service_obj = OBSBasic::Get()->GetService();
+	OBSDataAutoRelease settings = obs_service_get_settings(service_obj);
+	const char *service = obs_data_get_string(settings, "service");
+	if (IsYouTubeService(service)) {
+		if (IsUserSignedIntoYT()) {
+			const char *broadcast_id =
+				obs_data_get_string(settings, "broadcast_id");
+			this->IngestionStarted(broadcast_id,
+					       YouTubeAppDock::YTSM_ACCOUNT);
+		} else {
+			const char *stream_key =
+				obs_data_get_string(settings, "key");
+			this->IngestionStarted(stream_key,
+					       YouTubeAppDock::YTSM_STREAM_KEY);
+		}
+	}
+}
+
+void YouTubeAppDock::IngestionStarted(const char *stream_id,
+				      streaming_mode_t mode)
+{
+	DispatchYTEvent(INGESTION_STARTED, stream_id, mode);
+}
+
+// both 'ACCOUNT' and 'STREAM_KEY' modes supported
+void YouTubeAppDock::IngestionStopped()
+{
+	obs_service_t *service_obj = OBSBasic::Get()->GetService();
+	OBSDataAutoRelease settings = obs_service_get_settings(service_obj);
+	const char *service = obs_data_get_string(settings, "service");
+
+	if (IsYouTubeService(service)) {
+		if (IsUserSignedIntoYT()) {
+			const char *broadcast_id =
+				obs_data_get_string(settings, "broadcast_id");
+			this->IngestionStopped(broadcast_id,
+					       YouTubeAppDock::YTSM_ACCOUNT);
+		} else {
+			const char *stream_key =
+				obs_data_get_string(settings, "key");
+			this->IngestionStopped(stream_key,
+					       YouTubeAppDock::YTSM_STREAM_KEY);
+		}
+	}
+}
+
+void YouTubeAppDock::IngestionStopped(const char *stream_id,
+				      streaming_mode_t mode)
+{
+	DispatchYTEvent(INGESTION_STOPPED, stream_id, mode);
+}
+
+void YouTubeAppDock::showEvent(QShowEvent *)
+{
+	if (!dockBrowser)
+		Update();
+}
+
+void YouTubeAppDock::closeEvent(QCloseEvent *event)
+{
+	BrowserDock::closeEvent(event);
+	this->SetWidget(nullptr);
+}
+
+void YouTubeAppDock::DispatchYTEvent(const char *event, const char *video_id,
+				     streaming_mode_t mode)
+{
+	if (!dockBrowser)
+		return;
+
+	// update channelId if empty:
+	UpdateChannelId();
+
+	// notify YouTube Live Streaming API:
+	std::string script;
+	if (mode == YTSM_ACCOUNT) {
+		script = QString(R"""(
+			if (window.location.hostname == 'studio.youtube.com') {
+				let event = {
+					type: '%1',
+					channelId: '%2',
+					videoId: '%3',
+				};
+				console.log(event);
+				if (window.ytlsapi && window.ytlsapi.dispatchEvent)
+					window.ytlsapi.dispatchEvent(event);
+			}
+		)""")
+				 .arg(event)
+				 .arg(channelId)
+				 .arg(video_id)
+				 .toStdString();
+	} else {
+		const char *stream_key = video_id;
+		script = QString(R"""(
+			if (window.location.hostname == 'studio.youtube.com') {
+				let event = {
+					type: '%1',
+					streamKey: '%2',
+				};
+				console.log(event);
+				if (window.ytlsapi && window.ytlsapi.dispatchEvent)
+					window.ytlsapi.dispatchEvent(event);
+			}
+		)""")
+				 .arg(event)
+				 .arg(stream_key)
+				 .toStdString();
+	}
+	dockBrowser->executeJavaScript(script);
+
+	// in case of user still not logged in in dock panel, remember last event
+	SetInitEvent(mode, event, video_id, channelId.toStdString().c_str());
+}
+
+void YouTubeAppDock::Update()
+{
+	std::string url = InitYTUserUrl();
+
+	if (!dockBrowser) {
+		CreateBrowserWidget(url);
+	} else {
+		dockBrowser->setURL(url);
+	}
+
+	// if streaming already run, let's notify YT about past event
+	if (OBSBasic::Get()->StreamingActive()) {
+		obs_service_t *service_obj = OBSBasic::Get()->GetService();
+		OBSDataAutoRelease settings =
+			obs_service_get_settings(service_obj);
+		if (IsUserSignedIntoYT()) {
+			channelId.clear(); // renew channelId
+			UpdateChannelId();
+			const char *broadcast_id =
+				obs_data_get_string(settings, "broadcast_id");
+			SetInitEvent(YTSM_ACCOUNT, INGESTION_STARTED,
+				     broadcast_id,
+				     channelId.toStdString().c_str());
+		} else {
+			const char *stream_key =
+				obs_data_get_string(settings, "key");
+			SetInitEvent(YTSM_STREAM_KEY, INGESTION_STARTED,
+				     stream_key);
+		}
+	} else {
+		SetInitEvent(IsUserSignedIntoYT() ? YTSM_ACCOUNT
+						  : YTSM_STREAM_KEY);
+	}
+
+	dockBrowser->reloadPage();
+}
+
+void YouTubeAppDock::UpdateChannelId()
+{
+	if (channelId.isEmpty()) {
+		YoutubeApiWrappers *apiYouTube = GetYTApi();
+		if (apiYouTube) {
+			ChannelDescription channel_description;
+			if (apiYouTube->GetChannelDescription(
+				    channel_description)) {
+				channelId = channel_description.id;
+			} else {
+				blog(LOG_ERROR, "YT: AccountConnected() Failed "
+						"to get channel id");
+			}
+		}
+	}
+}
+
+void YouTubeAppDock::SetInitEvent(streaming_mode_t mode, const char *event,
+				  const char *video_id, const char *channelId)
+{
+	const std::string version = App()->GetVersionString();
+
+	QString api_event;
+	if (event) {
+		if (mode == YTSM_ACCOUNT) {
+			api_event = QString(R"""(,
+					initEvent: {
+						type: '%1',
+						channelId: '%2',
+						videoId: '%3',
+					}
+			)""")
+					    .arg(event)
+					    .arg(channelId)
+					    .arg(video_id);
+		} else {
+			api_event = QString(R"""(,
+					initEvent: {
+						type: '%1',
+						streamKey: '%2',
+					}
+			)""")
+					    .arg(event)
+					    .arg(video_id);
+		}
+	}
+
+	std::string script = QString(R"""(
+		let obs_name = '%1';
+		let obs_version = '%2';
+		let client_mode = %3;
+		if (window.location.hostname == 'studio.youtube.com') {
+			console.log("name:", obs_name);
+			console.log("version:", obs_version);
+			console.log("initEvent:", {
+					initClientMode: client_mode
+					%4 });
+			if (window.ytlsapi && window.ytlsapi.init)
+				window.ytlsapi.init(obs_name, obs_version, undefined, {
+					initClientMode: client_mode
+					%4 });
+		}
+	)""")
+				     .arg("OBS")
+				     .arg(version.c_str())
+				     .arg(mode == YTSM_ACCOUNT ? "'ACCOUNT'"
+							       : "'STREAM_KEY'")
+				     .arg(api_event)
+				     .toStdString();
+	dockBrowser->setStartupScript(script);
+}
+
+YoutubeApiWrappers *YouTubeAppDock::GetYTApi()
+{
+	Auth *auth = OBSBasic::Get()->GetAuth();
+	if (auth) {
+		YoutubeApiWrappers *apiYouTube(
+			dynamic_cast<YoutubeApiWrappers *>(auth));
+		if (apiYouTube) {
+			return apiYouTube;
+		} else {
+			blog(LOG_ERROR,
+			     "YT: GetYTApi() Failed to get YoutubeApiWrappers");
+		}
+	} else {
+		blog(LOG_ERROR, "YT: GetYTApi() Failed to get Auth");
+	}
+	return nullptr;
+}
+
+void YouTubeAppDock::CleanupYouTubeUrls()
+{
+	static constexpr const char *YOUTUBE_VIDEO_URL =
+		"://studio.youtube.com/video/";
+	// remove legacy YouTube Browser Docks (once)
+
+	bool youtube_cleanup_done = config_get_bool(
+		App()->GlobalConfig(), "General", "YtDockCleanupDone");
+
+	if (youtube_cleanup_done)
+		return;
+
+	config_set_bool(App()->GlobalConfig(), "General", "YtDockCleanupDone",
+			true);
+
+	const char *jsonStr = config_get_string(
+		App()->GlobalConfig(), "BasicWindow", "ExtraBrowserDocks");
+	if (!jsonStr)
+		return;
+
+	json array = json::parse(jsonStr);
+	if (!array.is_array())
+		return;
+
+	json save_array;
+	std::string removedYTUrl;
+
+	for (json &item : array) {
+		auto url = item["url"].get<std::string>();
+
+		if (url.find(YOUTUBE_VIDEO_URL) != std::string::npos) {
+			blog(LOG_DEBUG, "YT: found legacy url: %s",
+			     url.c_str());
+			removedYTUrl += url;
+			removedYTUrl += ";\n";
+		} else {
+			save_array.push_back(item);
+		}
+	}
+
+	if (!removedYTUrl.empty()) {
+		const QString msg_title = QTStr("YouTube.DocksRemoval.Title");
+		const QString msg_text =
+			QTStr("YouTube.DocksRemoval.Text")
+				.arg(QT_UTF8(removedYTUrl.c_str()));
+		OBSMessageBox::warning(OBSBasic::Get(), msg_title, msg_text);
+
+		std::string output = save_array.dump();
+		config_set_string(App()->GlobalConfig(), "BasicWindow",
+				  "ExtraBrowserDocks", output.c_str());
+	}
+}

+ 54 - 0
UI/window-dock-youtube-app.hpp

@@ -0,0 +1,54 @@
+#pragma once
+
+#include "window-dock-browser.hpp"
+#include "youtube-api-wrappers.hpp"
+
+class QAction;
+class QCefWidget;
+
+class YouTubeAppDock : public BrowserDock {
+	Q_OBJECT
+
+public:
+	YouTubeAppDock();
+	~YouTubeAppDock();
+
+	enum streaming_mode_t { YTSM_ACCOUNT, YTSM_STREAM_KEY };
+
+	void AccountConnected();
+	void AccountDisconnected();
+	void SettingsUpdated(bool cleanup = false);
+	void Update();
+
+	void BroadcastCreated(const char *stream_id);
+	void BroadcastSelected(const char *stream_id);
+	void IngestionStarted();
+	void IngestionStopped();
+
+	static bool IsYTServiceSelected();
+	static YoutubeApiWrappers *GetYTApi();
+	static void CleanupYouTubeUrls();
+
+protected:
+	void IngestionStarted(const char *stream_id, streaming_mode_t mode);
+	void IngestionStopped(const char *stream_id, streaming_mode_t mode);
+
+private:
+	std::string InitYTUserUrl();
+	void SetVisibleYTAppDockInMenu(bool visible);
+	void AddYouTubeAppDock(const QString &title);
+	void CreateBrowserWidget(const std::string &url);
+	virtual void showEvent(QShowEvent *event) override;
+	virtual void closeEvent(QCloseEvent *event) override;
+	void DispatchYTEvent(const char *event, const char *video_id,
+			     streaming_mode_t mode);
+	void UpdateChannelId();
+	void SetInitEvent(streaming_mode_t mode, const char *event = nullptr,
+			  const char *video_id = nullptr,
+			  const char *channelId = nullptr);
+
+	QString channelId;
+	QPointer<QCefWidget> dockBrowser;
+	QCefCookieManager *cookieManager; // is not a Qt object
+	QPointer<QAction> actionYTAppDock;
+};

+ 28 - 8
UI/window-youtube-actions.cpp

@@ -444,12 +444,12 @@ void OBSYoutubeActions::UpdateOkButtonStatus()
 	}
 }
 bool OBSYoutubeActions::CreateEventAction(YoutubeApiWrappers *api,
+					  BroadcastDescription &broadcast,
 					  StreamDescription &stream,
 					  bool stream_later,
 					  bool ready_broadcast)
 {
 	YoutubeApiWrappers *apiYouTube = api;
-	BroadcastDescription broadcast = {};
 	UiToBroadcast(broadcast);
 
 	if (stream_later) {
@@ -513,6 +513,12 @@ bool OBSYoutubeActions::CreateEventAction(YoutubeApiWrappers *api,
 		}
 	}
 
+#ifdef YOUTUBE_ENABLED
+	if (OBSBasic::Get()->GetYouTubeAppDock())
+		OBSBasic::Get()->GetYouTubeAppDock()->BroadcastCreated(
+			broadcast.id.toStdString().c_str());
+#endif
+
 	return true;
 }
 
@@ -568,6 +574,12 @@ bool OBSYoutubeActions::ChooseAnEventAction(YoutubeApiWrappers *api,
 	else
 		apiYouTube->ResetChat();
 
+#ifdef YOUTUBE_ENABLED
+	if (OBSBasic::Get()->GetYouTubeAppDock())
+		OBSBasic::Get()->GetYouTubeAppDock()->BroadcastSelected(
+			selectedBroadcast.toStdString().c_str());
+#endif
+
 	return true;
 }
 
@@ -585,6 +597,7 @@ void OBSYoutubeActions::ShowErrorDialog(QWidget *parent, QString text)
 
 void OBSYoutubeActions::InitBroadcast()
 {
+	BroadcastDescription broadcast;
 	StreamDescription stream;
 	QMessageBox msgBox(this);
 	msgBox.setWindowFlags(msgBox.windowFlags() &
@@ -597,10 +610,12 @@ void OBSYoutubeActions::InitBroadcast()
 	auto action = [&]() {
 		if (ui->tabWidget->currentIndex() == 0) {
 			success = this->CreateEventAction(
-				apiYouTube, stream,
+				apiYouTube, broadcast, stream,
 				ui->checkScheduledLater->isChecked());
 		} else {
 			success = this->ChooseAnEventAction(apiYouTube, stream);
+			if (success)
+				broadcast.id = this->selectedBroadcast;
 		};
 		QMetaObject::invokeMethod(&msgBox, "accept",
 					  Qt::QueuedConnection);
@@ -627,15 +642,17 @@ void OBSYoutubeActions::InitBroadcast()
 				// Stream now usecase.
 				blog(LOG_DEBUG, "New valid stream: %s",
 				     QT_TO_UTF8(stream.name));
-				emit ok(QT_TO_UTF8(stream.id),
+				emit ok(QT_TO_UTF8(broadcast.id),
+					QT_TO_UTF8(stream.id),
 					QT_TO_UTF8(stream.name), true, true,
 					true);
 				Accept();
 			}
 		} else {
 			// Stream to precreated broadcast usecase.
-			emit ok(QT_TO_UTF8(stream.id), QT_TO_UTF8(stream.name),
-				autostart, autostop, true);
+			emit ok(QT_TO_UTF8(broadcast.id), QT_TO_UTF8(stream.id),
+				QT_TO_UTF8(stream.name), autostart, autostop,
+				true);
 			Accept();
 		}
 	} else {
@@ -654,6 +671,7 @@ void OBSYoutubeActions::InitBroadcast()
 
 void OBSYoutubeActions::ReadyBroadcast()
 {
+	BroadcastDescription broadcast;
 	StreamDescription stream;
 	QMessageBox msgBox(this);
 	msgBox.setWindowFlags(msgBox.windowFlags() &
@@ -666,10 +684,12 @@ void OBSYoutubeActions::ReadyBroadcast()
 	auto action = [&]() {
 		if (ui->tabWidget->currentIndex() == 0) {
 			success = this->CreateEventAction(
-				apiYouTube, stream,
+				apiYouTube, broadcast, stream,
 				ui->checkScheduledLater->isChecked(), true);
 		} else {
 			success = this->ChooseAnEventAction(apiYouTube, stream);
+			if (success)
+				broadcast.id = this->selectedBroadcast;
 		};
 		QMetaObject::invokeMethod(&msgBox, "accept",
 					  Qt::QueuedConnection);
@@ -680,8 +700,8 @@ void OBSYoutubeActions::ReadyBroadcast()
 	thread->wait();
 
 	if (success) {
-		emit ok(QT_TO_UTF8(stream.id), QT_TO_UTF8(stream.name),
-			autostart, autostop, false);
+		emit ok(QT_TO_UTF8(broadcast.id), QT_TO_UTF8(stream.id),
+			QT_TO_UTF8(stream.name), autostart, autostop, false);
 		Accept();
 	} else {
 		// Fail.

+ 4 - 2
UI/window-youtube-actions.hpp

@@ -36,14 +36,16 @@ class OBSYoutubeActions : public QDialog {
 	std::unique_ptr<Ui::OBSYoutubeActions> ui;
 
 signals:
-	void ok(const QString &id, const QString &key, bool autostart,
-		bool autostop, bool start_now);
+	void ok(const QString &broadcast_id, const QString &stream_id,
+		const QString &key, bool autostart, bool autostop,
+		bool start_now);
 
 protected:
 	void showEvent(QShowEvent *event) override;
 	void UpdateOkButtonStatus();
 
 	bool CreateEventAction(YoutubeApiWrappers *api,
+			       BroadcastDescription &broadcast,
 			       StreamDescription &stream, bool stream_later,
 			       bool ready_broadcast = false);
 	bool ChooseAnEventAction(YoutubeApiWrappers *api,

+ 13 - 0
UI/youtube-api-wrappers.cpp

@@ -9,6 +9,7 @@
 
 #include "auth-youtube.hpp"
 #include "obs-app.hpp"
+#include "window-basic-main.hpp"
 #include "qt-wrappers.hpp"
 #include "remote-text.hpp"
 #include "ui-config.h"
@@ -45,6 +46,18 @@ bool IsYouTubeService(const std::string &service)
 			  });
 	return it != youtubeServices.end();
 }
+bool IsUserSignedIntoYT()
+{
+	Auth *auth = OBSBasic::Get()->GetAuth();
+	if (auth) {
+		YoutubeApiWrappers *apiYouTube(
+			dynamic_cast<YoutubeApiWrappers *>(auth));
+		if (apiYouTube) {
+			return true;
+		}
+	}
+	return false;
+}
 
 bool YoutubeApiWrappers::GetTranslatedError(QString &error_message)
 {

+ 1 - 0
UI/youtube-api-wrappers.hpp

@@ -38,6 +38,7 @@ struct BroadcastDescription {
 };
 
 bool IsYouTubeService(const std::string &service);
+bool IsUserSignedIntoYT();
 
 class YoutubeApiWrappers : public YoutubeAuth {
 	Q_OBJECT