From 39bbbb41dc68e24fd444ba1c3a7ec524ed64e5fc Mon Sep 17 00:00:00 2001 From: derrod Date: Sun, 12 Sep 2021 11:44:00 +0200 Subject: [PATCH] UI: Add thumbnail option to YouTube broadcast setup --- UI/data/locale/en-US.ini | 8 +++ UI/data/themes/Acri.qss | 8 +++ UI/data/themes/Dark.qss | 4 ++ UI/data/themes/Rachni.qss | 4 ++ UI/data/themes/System.qss | 4 ++ UI/forms/OBSYoutubeActions.ui | 120 ++++++++++++++++++++++++++++------ UI/window-youtube-actions.cpp | 91 ++++++++++++++++++++++++++ UI/window-youtube-actions.hpp | 8 +++ UI/youtube-api-wrappers.cpp | 51 +++++++++++++-- UI/youtube-api-wrappers.hpp | 7 +- 10 files changed, 276 insertions(+), 29 deletions(-) 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 +
clickable-label.hpp
+
+
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);