diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini
index e578d8d04..bdcf0cb9b 100644
--- a/UI/data/locale/en-US.ini
+++ b/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'.
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"
diff --git a/UI/data/themes/Acri.qss b/UI/data/themes/Acri.qss
index 7d66b4cb9..4687de5bf 100644
--- a/UI/data/themes/Acri.qss
+++ b/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;
diff --git a/UI/data/themes/Dark.qss b/UI/data/themes/Dark.qss
index 25592bd9f..533c64bcc 100644
--- a/UI/data/themes/Dark.qss
+++ b/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;
}
diff --git a/UI/data/themes/Rachni.qss b/UI/data/themes/Rachni.qss
index b61baf53a..d9152a3fa 100644
--- a/UI/data/themes/Rachni.qss
+++ b/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 */
diff --git a/UI/data/themes/System.qss b/UI/data/themes/System.qss
index 0c4f18f7e..7d4d18ac6 100644
--- a/UI/data/themes/System.qss
+++ b/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;
diff --git a/UI/forms/OBSYoutubeActions.ui b/UI/forms/OBSYoutubeActions.ui
index de6c04a4f..d288b6332 100644
--- a/UI/forms/OBSYoutubeActions.ui
+++ b/UI/forms/OBSYoutubeActions.ui
@@ -6,8 +6,8 @@
0
0
- 585
- 536
+ 616
+ 645
@@ -22,6 +22,12 @@
0
+
+
+ 720
+ 880
+
+
YouTube.Actions.WindowTitle
@@ -37,7 +43,7 @@
0
-
+
YouTube.Actions.CreateNewEvent
@@ -175,13 +181,20 @@
-
+
+
+ YouTube.Actions.Thumbnail
+
+
+
+ -
YouTube.Actions.AdditionalSettings
- -
+
-
Qt::Horizontal
@@ -194,7 +207,7 @@
- -
+
-
Qt::Horizontal
@@ -207,7 +220,7 @@
- -
+
-
-
@@ -228,7 +241,7 @@
- -
+
-
Qt::Horizontal
@@ -241,7 +254,7 @@
- -
+
-
-
@@ -255,7 +268,7 @@
- -
+
-
Qt::Horizontal
@@ -268,7 +281,7 @@
- -
+
-
-
@@ -308,7 +321,7 @@
- -
+
-
Qt::Horizontal
@@ -321,7 +334,7 @@
- -
+
-
-
@@ -335,7 +348,7 @@
- -
+
-
Qt::Horizontal
@@ -348,7 +361,7 @@
- -
+
-
-
@@ -397,7 +410,7 @@
- -
+
-
Qt::Horizontal
@@ -410,7 +423,7 @@
- -
+
-
-
@@ -427,7 +440,7 @@
- -
+
-
Qt::Horizontal
@@ -440,7 +453,7 @@
- -
+
-
-
@@ -460,7 +473,7 @@
- -
+
-
-
@@ -471,7 +484,7 @@
- -
+
-
Qt::Horizontal
@@ -484,6 +497,64 @@
+ -
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ YouTube.Actions.Thumbnail.SelectFile
+
+
+
+ -
+
+
+ YouTube.Actions.Thumbnail.NoFileSelected
+
+
+
+
+
+ -
+
+
+ true
+
+
+
+ 0
+ 0
+
+
+
+
+ 162
+ 92
+
+
+
+
+ 162
+ 92
+
+
+
+ QFrame::Box
+
+
+
+
+
+ Qt::AlignCenter
+
+
+
@@ -504,7 +575,7 @@
0
0
- 524
+ 555
192
@@ -633,6 +704,13 @@
+
+
+ ClickableLabel
+ QLabel
+
+
+
diff --git a/UI/window-youtube-actions.cpp b/UI/window-youtube-actions.cpp
index d9a52b951..426f75c5e 100644
--- a/UI/window-youtube-actions.cpp
+++ b/UI/window-youtube-actions.cpp
@@ -8,6 +8,9 @@
#include
#include
#include
+#include
+#include
+#include
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()
diff --git a/UI/window-youtube-actions.hpp b/UI/window-youtube-actions.hpp
index f922e72cf..cb24bd49f 100644
--- a/UI/window-youtube-actions.hpp
+++ b/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;
@@ -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;
};
diff --git a/UI/youtube-api-wrappers.cpp b/UI/youtube-api-wrappers.cpp
index adce415b1..80d53a377 100644
--- a/UI/youtube-api-wrappers.cpp
+++ b/UI/youtube-api-wrappers.cpp
@@ -1,6 +1,8 @@
#include "youtube-api-wrappers.hpp"
#include
+#include
+#include
#include
#include
@@ -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();
diff --git a/UI/youtube-api-wrappers.hpp b/UI/youtube-api-wrappers.hpp
index 4634e7ba3..1a6bc4f26 100644
--- a/UI/youtube-api-wrappers.hpp
+++ b/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);