1
0
Эх сурвалжийг харах

UI: Add thumbnail option to YouTube broadcast setup

derrod 4 жил өмнө
parent
commit
39bbbb41dc

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

@@ -1205,6 +1205,11 @@ YouTube.Actions.Privacy.Public="Public"
 YouTube.Actions.Privacy.Unlisted="Unlisted"
 YouTube.Actions.Category="Category"
 
+YouTube.Actions.Thumbnail="Thumbnail"
+YouTube.Actions.Thumbnail.SelectFile="Select file..."
+YouTube.Actions.Thumbnail.NoFileSelected="No file selected"
+YouTube.Actions.Thumbnail.ClearFile="Clear"
+
 YouTube.Actions.MadeForKids="Is this video made for kids?*"
 YouTube.Actions.MadeForKids.Yes="Yes, it's made for kids"
 YouTube.Actions.MadeForKids.No="No, it's not made for kids"
@@ -1238,6 +1243,9 @@ YouTube.Actions.Error.NoBroadcastCreated="Broadcast creation error '%1'.<br/>A d
 YouTube.Actions.Error.NoStreamCreated="No stream created. Please relink your account."
 YouTube.Actions.Error.YouTubeApi="YouTube API Error. Please see the log file for more information."
 YouTube.Actions.Error.BroadcastNotFound="The selected broadcast was not found."
+YouTube.Actions.Error.FileMissing="Selected file does not exist."
+YouTube.Actions.Error.FileOpeningFailed="Failed opening selected file."
+YouTube.Actions.Error.FileTooLarge="Selected file is too large (Limit: 2 MiB)."
 
 YouTube.Actions.EventsLoading="Loading list of events..."
 YouTube.Actions.EventCreated.Title="Event Created"

+ 8 - 0
UI/data/themes/Acri.qss

@@ -1165,6 +1165,14 @@ QSlider::handle:horizontal[themeID="tBarSlider"] {
 }
 
 /* YouTube Integration */
+OBSYoutubeActions {
+    qproperty-thumbPlaceholder: url(./Dark/sources/image.svg);
+}
+
+#thumbnailPreview {
+	background-color: rgb(40,40,42);
+}
+
 #ytEventList QLabel {
     color: rgb(225,224,225);
 	background-color: #162458;

+ 4 - 0
UI/data/themes/Dark.qss

@@ -896,6 +896,10 @@ QSlider::handle:horizontal[themeID="tBarSlider"] {
 }
 
 /* YouTube Integration */
+OBSYoutubeActions {
+    qproperty-thumbPlaceholder: url(./Dark/sources/image.svg);
+}
+
 #broadcastButton[broadcastState=ready] {
     background: blue;
 }

+ 4 - 0
UI/data/themes/Rachni.qss

@@ -1465,6 +1465,10 @@ QPushButton#sourceFiltersButton {
 }
 
 /* YouTube Integration */
+OBSYoutubeActions {
+    qproperty-thumbPlaceholder: url(./Dark/sources/image.svg);
+}
+
 #ytEventList QLabel {
     background-color: rgb(0, 188, 212);; /* Cyan (Primary) */
 	color: rgb(239, 240, 241); /* White */

+ 4 - 0
UI/data/themes/System.qss

@@ -302,6 +302,10 @@ QSlider::handle:horizontal[themeID="tBarSlider"] {
 }
 
 /* YouTube Integration */
+OBSYoutubeActions {
+    qproperty-thumbPlaceholder: url(:res/images/sources/image.svg);
+}
+
 #ytEventList QLabel {
 	background-color: #e1e1e1;
 	border: 1px solid #ddd;

+ 99 - 21
UI/forms/OBSYoutubeActions.ui

@@ -6,8 +6,8 @@
    <rect>
     <x>0</x>
     <y>0</y>
-    <width>585</width>
-    <height>536</height>
+    <width>616</width>
+    <height>645</height>
    </rect>
   </property>
   <property name="sizePolicy">
@@ -22,6 +22,12 @@
     <height>0</height>
    </size>
   </property>
+  <property name="maximumSize">
+   <size>
+    <width>720</width>
+    <height>880</height>
+   </size>
+  </property>
   <property name="windowTitle">
    <string>YouTube.Actions.WindowTitle</string>
   </property>
@@ -37,7 +43,7 @@
      <property name="currentIndex">
       <number>0</number>
      </property>
-     <widget class="QWidget" name="tab_1">
+     <widget class="QWidget" name="ytEventCreate">
       <attribute name="title">
        <string>YouTube.Actions.CreateNewEvent</string>
       </attribute>
@@ -175,13 +181,20 @@
         </layout>
        </item>
        <item row="6" column="0">
+        <widget class="QLabel" name="label_6">
+         <property name="text">
+          <string>YouTube.Actions.Thumbnail</string>
+         </property>
+        </widget>
+       </item>
+       <item row="9" column="0">
         <widget class="QLabel" name="label_7">
          <property name="text">
           <string>YouTube.Actions.AdditionalSettings</string>
          </property>
         </widget>
        </item>
-       <item row="6" column="1">
+       <item row="9" column="1">
         <spacer name="horizontalSpacer_2">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -194,7 +207,7 @@
          </property>
         </spacer>
        </item>
-       <item row="7" column="0">
+       <item row="10" column="0">
         <spacer name="horizontalSpacer_7">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -207,7 +220,7 @@
          </property>
         </spacer>
        </item>
-       <item row="7" column="1">
+       <item row="10" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout">
          <item>
           <widget class="QLabel" name="label_3">
@@ -228,7 +241,7 @@
          </item>
         </layout>
        </item>
-       <item row="8" column="0">
+       <item row="11" column="0">
         <spacer name="horizontalSpacer_14">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -241,7 +254,7 @@
          </property>
         </spacer>
        </item>
-       <item row="8" column="1">
+       <item row="11" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_3">
          <item>
           <widget class="QCheckBox" name="checkDVR">
@@ -255,7 +268,7 @@
          </item>
         </layout>
        </item>
-       <item row="9" column="0">
+       <item row="12" column="0">
         <spacer name="horizontalSpacer_8">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -268,7 +281,7 @@
          </property>
         </spacer>
        </item>
-       <item row="9" column="1">
+       <item row="12" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_7">
          <item>
           <widget class="QCheckBox" name="check360Video">
@@ -308,7 +321,7 @@
          </item>
         </layout>
        </item>
-       <item row="10" column="0">
+       <item row="13" column="0">
         <spacer name="horizontalSpacer_12">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -321,7 +334,7 @@
          </property>
         </spacer>
        </item>
-       <item row="10" column="1">
+       <item row="13" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_9">
          <item>
           <widget class="QCheckBox" name="checkScheduledLater">
@@ -335,7 +348,7 @@
          </item>
         </layout>
        </item>
-       <item row="11" column="0">
+       <item row="14" column="0">
         <spacer name="horizontalSpacer_6">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -348,7 +361,7 @@
          </property>
         </spacer>
        </item>
-       <item row="11" column="1">
+       <item row="14" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_6">
          <item>
           <widget class="QCheckBox" name="checkAutoStart">
@@ -397,7 +410,7 @@
          </item>
         </layout>
        </item>
-       <item row="12" column="0">
+       <item row="15" column="0">
         <spacer name="horizontalSpacer_3">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -410,7 +423,7 @@
          </property>
         </spacer>
        </item>
-       <item row="12" column="1">
+       <item row="15" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_4">
          <item>
           <widget class="QCheckBox" name="checkAutoStop">
@@ -427,7 +440,7 @@
          </item>
         </layout>
        </item>
-       <item row="13" column="0">
+       <item row="16" column="0">
         <spacer name="horizontalSpacer_13">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -440,7 +453,7 @@
          </property>
         </spacer>
        </item>
-       <item row="13" column="1">
+       <item row="16" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_10">
          <item>
           <widget class="QDateTimeEdit" name="scheduledTime">
@@ -460,7 +473,7 @@
          </item>
         </layout>
        </item>
-       <item row="14" column="1">
+       <item row="17" column="1">
         <layout class="QHBoxLayout" name="horizontalLayout_12">
          <item>
           <widget class="QCheckBox" name="checkRememberSettings">
@@ -471,7 +484,7 @@
          </item>
         </layout>
        </item>
-       <item row="14" column="0">
+       <item row="17" column="0">
         <spacer name="horizontalSpacer">
          <property name="orientation">
           <enum>Qt::Horizontal</enum>
@@ -484,6 +497,64 @@
          </property>
         </spacer>
        </item>
+       <item row="8" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_13">
+         <property name="rightMargin">
+          <number>0</number>
+         </property>
+         <property name="bottomMargin">
+          <number>0</number>
+         </property>
+         <item>
+          <widget class="QPushButton" name="selectFileButton">
+           <property name="text">
+            <string>YouTube.Actions.Thumbnail.SelectFile</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="selectedFileName">
+           <property name="text">
+            <string>YouTube.Actions.Thumbnail.NoFileSelected</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="6" column="1">
+        <widget class="ClickableLabel" name="thumbnailPreview">
+         <property name="enabled">
+          <bool>true</bool>
+         </property>
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="minimumSize">
+          <size>
+           <width>162</width>
+           <height>92</height>
+          </size>
+         </property>
+         <property name="maximumSize">
+          <size>
+           <width>162</width>
+           <height>92</height>
+          </size>
+         </property>
+         <property name="frameShape">
+          <enum>QFrame::Box</enum>
+         </property>
+         <property name="text">
+          <string/>
+         </property>
+         <property name="alignment">
+          <set>Qt::AlignCenter</set>
+         </property>
+        </widget>
+       </item>
       </layout>
      </widget>
      <widget class="QWidget" name="ytEventList">
@@ -504,7 +575,7 @@
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>524</width>
+            <width>555</width>
             <height>192</height>
            </rect>
           </property>
@@ -633,6 +704,13 @@
    </item>
   </layout>
  </widget>
+ <customwidgets>
+  <customwidget>
+   <class>ClickableLabel</class>
+   <extends>QLabel</extends>
+   <header>clickable-label.hpp</header>
+  </customwidget>
+ </customwidgets>
  <resources/>
  <connections/>
 </ui>

+ 91 - 0
UI/window-youtube-actions.cpp

@@ -8,6 +8,9 @@
 #include <QToolTip>
 #include <QDateTime>
 #include <QDesktopServices>
+#include <QFileInfo>
+#include <QStandardPaths>
+#include <QImageReader>
 
 const QString SchedulDateAndTimeFormat = "yyyy-MM-dd'T'hh:mm:ss'Z'";
 const QString RepresentSchedulDateAndTimeFormat = "dddd, MMMM d, yyyy h:m";
@@ -91,6 +94,55 @@ OBSYoutubeActions::OBSYoutubeActions(QWidget *parent, Auth *auth,
 
 	ui->scheduledTime->setDateTime(QDateTime::currentDateTime());
 
+	auto thumbSelectionHandler = [&]() {
+		if (thumbnailFile.isEmpty()) {
+			QString filePath = OpenFile(
+				this,
+				QTStr("YouTube.Actions.Thumbnail.SelectFile"),
+				QStandardPaths::writableLocation(
+					QStandardPaths::PicturesLocation),
+				QString("Images (*.png *.jpg *.jpeg *.gif)"));
+
+			if (!filePath.isEmpty()) {
+				QFileInfo tFile(filePath);
+				if (!tFile.exists()) {
+					return ShowErrorDialog(
+						this,
+						QTStr("YouTube.Actions.Error.FileMissing"));
+				} else if (tFile.size() > 2 * 1024 * 1024) {
+					return ShowErrorDialog(
+						this,
+						QTStr("YouTube.Actions.Error.FileTooLarge"));
+				}
+
+				thumbnailFile = filePath;
+				ui->selectedFileName->setText(thumbnailFile);
+				ui->selectFileButton->setText(QTStr(
+					"YouTube.Actions.Thumbnail.ClearFile"));
+
+				QImageReader imgReader(filePath);
+				imgReader.setAutoTransform(true);
+				const QImage newImage = imgReader.read();
+				ui->thumbnailPreview->setPixmap(
+					QPixmap::fromImage(newImage).scaled(
+						160, 90, Qt::KeepAspectRatio));
+			}
+		} else {
+			thumbnailFile.clear();
+			ui->selectedFileName->setText(QTStr(
+				"YouTube.Actions.Thumbnail.NoFileSelected"));
+			ui->selectFileButton->setText(
+				QTStr("YouTube.Actions.Thumbnail.SelectFile"));
+			ui->thumbnailPreview->setPixmap(
+				GetPlaceholder().pixmap(QSize(16, 16)));
+		}
+	};
+
+	connect(ui->selectFileButton, &QPushButton::clicked, this,
+		thumbSelectionHandler);
+	connect(ui->thumbnailPreview, &ClickableLabel::clicked, this,
+		thumbSelectionHandler);
+
 	if (!apiYouTube) {
 		blog(LOG_DEBUG, "YouTube API auth NOT found.");
 		Cancel();
@@ -245,6 +297,14 @@ OBSYoutubeActions::OBSYoutubeActions(QWidget *parent, Auth *auth,
 	valid = true;
 }
 
+void OBSYoutubeActions::showEvent(QShowEvent *event)
+{
+	QDialog::showEvent(event);
+	if (thumbnailFile.isEmpty())
+		ui->thumbnailPreview->setPixmap(
+			GetPlaceholder().pixmap(QSize(16, 16)));
+}
+
 OBSYoutubeActions::~OBSYoutubeActions()
 {
 	workerThread->stop();
@@ -421,6 +481,15 @@ bool OBSYoutubeActions::CreateEventAction(YoutubeApiWrappers *api,
 		blog(LOG_DEBUG, "No category set.");
 		return false;
 	}
+	if (!thumbnailFile.isEmpty()) {
+		blog(LOG_INFO, "Uploading thumbnail file \"%s\"...",
+		     thumbnailFile.toStdString().c_str());
+		if (!apiYouTube->SetVideoThumbnail(broadcast.id,
+						   thumbnailFile)) {
+			blog(LOG_DEBUG, "No thumbnail set.");
+			return false;
+		}
+	}
 
 	if (!stream_later || ready_broadcast) {
 		stream = {"", "", "OBS Studio Video Stream"};
@@ -664,6 +733,8 @@ void OBSYoutubeActions::SaveSettings(BroadcastDescription &broadcast)
 			broadcast.schedul_for_later);
 	config_set_string(main->basicConfig, "YouTube", "Projection",
 			  QT_TO_UTF8(broadcast.projection));
+	config_set_string(main->basicConfig, "YouTube", "ThumbnailFile",
+			  QT_TO_UTF8(thumbnailFile));
 	config_set_bool(main->basicConfig, "YouTube", "RememberSettings", true);
 }
 
@@ -724,6 +795,26 @@ void OBSYoutubeActions::LoadSettings()
 		else
 			ui->check360Video->setChecked(false);
 	}
+
+	const char *thumbFile = config_get_string(main->basicConfig, "YouTube",
+						  "ThumbnailFile");
+	if (thumbFile && *thumbFile) {
+		QFileInfo tFile(thumbFile);
+		// Re-check validity before setting path again
+		if (tFile.exists() && tFile.size() <= 2 * 1024 * 1024) {
+			thumbnailFile = tFile.absoluteFilePath();
+			ui->selectedFileName->setText(thumbnailFile);
+			ui->selectFileButton->setText(
+				QTStr("YouTube.Actions.Thumbnail.ClearFile"));
+
+			QImageReader imgReader(thumbnailFile);
+			imgReader.setAutoTransform(true);
+			const QImage newImage = imgReader.read();
+			ui->thumbnailPreview->setPixmap(
+				QPixmap::fromImage(newImage).scaled(
+					160, 90, Qt::KeepAspectRatio));
+		}
+	}
 }
 
 void OBSYoutubeActions::OpenYouTubeDashboard()

+ 8 - 0
UI/window-youtube-actions.hpp

@@ -30,6 +30,8 @@ signals:
 
 class OBSYoutubeActions : public QDialog {
 	Q_OBJECT
+	Q_PROPERTY(QIcon thumbPlaceholder READ GetPlaceholder WRITE
+			   SetPlaceholder DESIGNABLE true)
 
 	std::unique_ptr<Ui::OBSYoutubeActions> ui;
 
@@ -38,6 +40,7 @@ signals:
 		bool autostop, bool start_now);
 
 protected:
+	void showEvent(QShowEvent *event);
 	void UpdateOkButtonStatus();
 
 	bool CreateEventAction(YoutubeApiWrappers *api,
@@ -65,10 +68,15 @@ private:
 	void SaveSettings(BroadcastDescription &broadcast);
 	void LoadSettings();
 
+	QIcon GetPlaceholder() { return thumbPlaceholder; }
+	void SetPlaceholder(const QIcon &icon) { thumbPlaceholder = icon; }
+
 	QString selectedBroadcast;
 	bool autostart, autostop;
 	bool valid = false;
 	bool broadcastReady = false;
 	YoutubeApiWrappers *apiYouTube;
 	WorkerThread *workerThread;
+	QString thumbnailFile;
+	QIcon thumbPlaceholder;
 };

+ 45 - 6
UI/youtube-api-wrappers.cpp

@@ -1,6 +1,8 @@
 #include "youtube-api-wrappers.hpp"
 
 #include <QUrl>
+#include <QMimeDatabase>
+#include <QFile>
 
 #include <string>
 #include <iostream>
@@ -27,6 +29,8 @@ using namespace json11;
 #define YOUTUBE_LIVE_TOKEN_URL "https://oauth2.googleapis.com/token"
 #define YOUTUBE_LIVE_VIDEOCATEGORIES_URL YOUTUBE_LIVE_API_URL "/videoCategories"
 #define YOUTUBE_LIVE_VIDEOS_URL YOUTUBE_LIVE_API_URL "/videos"
+#define YOUTUBE_LIVE_THUMBNAIL_URL \
+	"https://www.googleapis.com/upload/youtube/v3/thumbnails/set"
 
 #define DEFAULT_BROADCASTS_PER_QUERY \
 	"50" // acceptable values are 0 to 50, inclusive
@@ -58,22 +62,25 @@ bool YoutubeApiWrappers::TryInsertCommand(const char *url,
 					  const char *content_type,
 					  std::string request_type,
 					  const char *data, Json &json_out,
-					  long *error_code)
+					  long *error_code, int data_size)
 {
 	long httpStatusCode = 0;
 
 #ifdef _DEBUG
 	blog(LOG_DEBUG, "YouTube API command URL: %s", url);
-	blog(LOG_DEBUG, "YouTube API command data: %s", data);
+	if (data && data[0] == '{') // only log JSON data
+		blog(LOG_DEBUG, "YouTube API command data: %s", data);
 #endif
 	if (token.empty())
 		return false;
 	std::string output;
 	std::string error;
+	// Increase timeout by the time it takes to transfer `data_size` at 1 Mbps
+	int timeout = 5 + data_size / 125000;
 	bool success = GetRemoteFile(url, output, error, &httpStatusCode,
 				     content_type, request_type, data,
 				     {"Authorization: Bearer " + token},
-				     nullptr, 5, false);
+				     nullptr, timeout, false, data_size);
 	if (error_code)
 		*error_code = httpStatusCode;
 
@@ -126,18 +133,20 @@ bool YoutubeApiWrappers::UpdateAccessToken()
 bool YoutubeApiWrappers::InsertCommand(const char *url,
 				       const char *content_type,
 				       std::string request_type,
-				       const char *data, Json &json_out)
+				       const char *data, Json &json_out,
+				       int data_size)
 {
 	long error_code;
 	bool success = TryInsertCommand(url, content_type, request_type, data,
-					json_out, &error_code);
+					json_out, &error_code, data_size);
 
 	if (error_code == 401) {
 		// Attempt to update access token and try again
 		if (!UpdateAccessToken())
 			return false;
 		success = TryInsertCommand(url, content_type, request_type,
-					   data, json_out, &error_code);
+					   data, json_out, &error_code,
+					   data_size);
 	}
 
 	if (json_out.object_items().find("error") !=
@@ -361,6 +370,36 @@ bool YoutubeApiWrappers::SetVideoCategory(const QString &video_id,
 			     data.dump().c_str(), json_out);
 }
 
+bool YoutubeApiWrappers::SetVideoThumbnail(const QString &video_id,
+					   const QString &thumbnail_file)
+{
+	lastErrorMessage.clear();
+	lastErrorReason.clear();
+
+	// Make sure the file hasn't been deleted since originally selecting it
+	if (!QFile::exists(thumbnail_file)) {
+		lastErrorMessage = QTStr("YouTube.Actions.Error.FileMissing");
+		return false;
+	}
+
+	QFile thumbFile(thumbnail_file);
+	if (!thumbFile.open(QFile::ReadOnly)) {
+		lastErrorMessage =
+			QTStr("YouTube.Actions.Error.FileOpeningFailed");
+		return false;
+	}
+
+	const QByteArray fileContents = thumbFile.readAll();
+	const QString mime =
+		QMimeDatabase().mimeTypeForData(fileContents).name();
+
+	const QString url = YOUTUBE_LIVE_THUMBNAIL_URL "?videoId=" + video_id;
+	Json json_out;
+	return InsertCommand(QT_TO_UTF8(url), QT_TO_UTF8(mime), "POST",
+			     fileContents.constData(), json_out,
+			     fileContents.size());
+}
+
 bool YoutubeApiWrappers::StartBroadcast(const QString &broadcast_id)
 {
 	lastErrorMessage.clear();

+ 5 - 2
UI/youtube-api-wrappers.hpp

@@ -44,11 +44,12 @@ class YoutubeApiWrappers : public YoutubeAuth {
 
 	bool TryInsertCommand(const char *url, const char *content_type,
 			      std::string request_type, const char *data,
-			      json11::Json &ret, long *error_code = nullptr);
+			      json11::Json &ret, long *error_code = nullptr,
+			      int data_size = 0);
 	bool UpdateAccessToken();
 	bool InsertCommand(const char *url, const char *content_type,
 			   std::string request_type, const char *data,
-			   json11::Json &ret);
+			   json11::Json &ret, int data_size = 0);
 
 public:
 	YoutubeApiWrappers(const Def &d);
@@ -65,6 +66,8 @@ public:
 			      const QString &video_title,
 			      const QString &video_description,
 			      const QString &categorie_id);
+	bool SetVideoThumbnail(const QString &video_id,
+			       const QString &thumbnail_file);
 	bool StartBroadcast(const QString &broadcast_id);
 	bool StopBroadcast(const QString &broadcast_id);
 	bool ResetBroadcast(const QString &broadcast_id);