diff --git a/CHANGELOG.md b/CHANGELOG.md index ad469a0b..1fe942cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added clip creation support. You can create clips with `/clip` command, `Alt+X` keybind or `Create a clip` option in split header's context menu. This requires a new authentication scope so re-authentication will be required to use it. (#2271) - Major: Added "Channel Filters". See https://wiki.chatterino.com/Filters/ for how they work or how to configure them. (#1748, #2083, #2090, #2200) - Major: Added Streamer Mode configuration (under `Settings -> General`), where you can select which features of Chatterino should behave differently when you are in Streamer Mode. (#2001, #2316, #2342) - Major: Color mentions to match the mentioned users. You can disable this by unchecking "Color @usernames" under `Settings -> General -> Advanced (misc.)`. (#1963, #2284) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 979cc118..54daf991 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -452,6 +452,19 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/clip", [](const auto &words, auto channel) { + if (!channel->isTwitchChannel()) + { + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + + twitchChannel->createClip(); + + return ""; + }); } void CommandController::save() diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index a3fb283f..616ecf87 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -21,7 +21,8 @@ public: AutoModDeny, OpenAccountsPage, JumpToChannel, - Reconnect + Reconnect, + CopyToClipboard, }; Link(); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 828b9dde..d4c2c509 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -35,8 +35,19 @@ namespace chatterino { namespace { - constexpr int TITLE_REFRESH_PERIOD = 10; constexpr char MAGIC_MESSAGE_SUFFIX[] = u8" \U000E0000"; + constexpr int TITLE_REFRESH_PERIOD = 10; + constexpr int CLIP_CREATION_COOLDOWN = 5000; + const QString CLIPS_LINK("https://clips.twitch.tv/%1"); + const QString CLIPS_FAILURE_CLIPS_DISABLED_TEXT( + "Failed to create a clip - the streamer has clips disabled entirely or " + "requires a certain subscriber or follower status to create clips."); + const QString CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT( + "Failed to create a clip - you need to re-authenticate."); + const QString CLIPS_FAILURE_UNKNOWN_ERROR_TEXT( + "Failed to create a clip - an unknown error occurred."); + const QString LOGIN_PROMPT_TEXT("Click here to add your account again."); + const Link ACCOUNTS_LINK(Link::OpenAccountsPage, QString()); // convertClearchatToNotice takes a Communi::IrcMessage that is a CLEARCHAT command and converts it to a readable NOTICE message // This has historically been done in the Recent Messages API, but this functionality is being moved to Chatterino instead @@ -938,6 +949,102 @@ void TwitchChannel::refreshCheerEmotes() .execute(); } +void TwitchChannel::createClip() +{ + if (!this->isLive()) + { + this->addMessage(makeSystemMessage( + "Cannot create clip while the channel is offline!")); + return; + } + + if (QTime().currentTime() < this->timeNextClipCreationAllowed_ || + this->isClipCreationInProgress) + { + return; + } + + this->addMessage(makeSystemMessage("Creating clip...")); + this->isClipCreationInProgress = true; + + getHelix()->createClip( + this->roomId(), + // successCallback + [this](const HelixClip &clip) { + MessageBuilder builder; + builder.message().flags.set(MessageFlag::System); + + builder.emplace(); + // text + builder.emplace("Clip created!", + MessageElementFlag::Text, + MessageColor::System); + // clip link + builder + .emplace("Copy link to clipboard", + MessageElementFlag::Text, + MessageColor::Link) + ->setLink(Link(Link::CopyToClipboard, CLIPS_LINK.arg(clip.id))); + // separator text + builder.emplace("or", MessageElementFlag::Text, + MessageColor::System); + // edit link + builder + .emplace("edit it in browser.", + MessageElementFlag::Text, + MessageColor::Link) + ->setLink(Link(Link::Url, clip.editUrl)); + + this->addMessage(builder.release()); + }, + // failureCallback + [this](auto error) { + MessageBuilder builder; + builder.message().flags.set(MessageFlag::System); + + builder.emplace(); + + switch (error) + { + case HelixClipError::ClipsDisabled: { + builder.emplace( + CLIPS_FAILURE_CLIPS_DISABLED_TEXT, + MessageElementFlag::Text, MessageColor::System); + } + break; + + case HelixClipError::UserNotAuthenticated: { + builder.emplace( + CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT, + MessageElementFlag::Text, MessageColor::System); + builder + .emplace(LOGIN_PROMPT_TEXT, + MessageElementFlag::Text, + MessageColor::Link) + ->setLink(ACCOUNTS_LINK); + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixClipError::Unknown: + default: { + builder.emplace( + CLIPS_FAILURE_UNKNOWN_ERROR_TEXT, + MessageElementFlag::Text, MessageColor::System); + } + break; + } + + this->addMessage(builder.release()); + }, + // finallyCallback - this will always execute, so clip creation won't ever be stuck + [this] { + this->timeNextClipCreationAllowed_ = + QTime().currentTime().addMSecs(CLIP_CREATION_COOLDOWN); + this->isClipCreationInProgress = false; + }); +} + boost::optional TwitchChannel::twitchBadge( const QString &set, const QString &version) const { diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index c24105f1..f29eee4f 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -73,6 +73,7 @@ public: virtual bool canReconnect() const override; virtual void reconnect() override; void refreshTitle(); + void createClip(); // Data const QString &subscriptionUrl(); @@ -188,6 +189,8 @@ private: QTimer liveStatusTimer_; QTimer chattersListTimer_; QTime titleRefreshedTime_; + QTime timeNextClipCreationAllowed_{QTime().currentTime()}; + bool isClipCreationInProgress{false}; friend class TwitchIrcServer; friend class TwitchMessageBuilder; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 3187d074..4272b6f2 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -359,6 +359,60 @@ void Helix::unfollowUser(QString userId, QString targetId, .execute(); } +void Helix::createClip(QString channelId, + ResultCallback successCallback, + std::function failureCallback, + std::function finallyCallback) +{ + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", channelId); + + this->makeRequest("clips", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(HelixClipError::Unknown); + return Failure; + } + + HelixClip clip(data.toArray()[0].toObject()); + + successCallback(clip); + return Success; + }) + .onError([failureCallback](NetworkResult result) { + switch (result.status()) + { + case 503: { + // Channel has disabled clip-creation, or channel has made cliops only creatable by followers and the user is not a follower (or subscriber) + failureCallback(HelixClipError::ClipsDisabled); + } + break; + + case 401: { + // User does not have the required scope to be able to create clips, user must reauthenticate + failureCallback(HelixClipError::UserNotAuthenticated); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Failed to create a clip: " << result.status() + << result.getData(); + failureCallback(HelixClipError::Unknown); + } + break; + } + }) + .finally(finallyCallback) + .execute(); +} + NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) { assert(!url.startsWith("/")); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index e9730a43..581843f9 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -135,6 +135,23 @@ struct HelixGame { } }; +struct HelixClip { + QString id; // clip slug + QString editUrl; + + explicit HelixClip(QJsonObject jsonObject) + : id(jsonObject.value("id").toString()) + , editUrl(jsonObject.value("edit_url").toString()) + { + } +}; + +enum class HelixClipError { + Unknown, + ClipsDisabled, + UserNotAuthenticated, +}; + class Helix final : boost::noncopyable { public: @@ -185,14 +202,22 @@ public: void getGameById(QString gameId, ResultCallback successCallback, HelixFailureCallback failureCallback); + // https://dev.twitch.tv/docs/api/reference#create-user-follows void followUser(QString userId, QString targetId, std::function successCallback, HelixFailureCallback failureCallback); + // https://dev.twitch.tv/docs/api/reference#delete-user-follows void unfollowUser(QString userId, QString targetlId, std::function successCallback, HelixFailureCallback failureCallback); + // https://dev.twitch.tv/docs/api/reference#create-clip + void createClip(QString channelId, + ResultCallback successCallback, + std::function failureCallback, + std::function finallyCallback); + void update(QString clientId, QString oauthToken); static void initialize(); diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index 21a98e66..37703224 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -90,7 +90,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-streams * `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for ### Follow User -URL: https://dev.twitch.tv/docs/api/reference#create-user-follows +URL: https://dev.twitch.tv/docs/api/reference#create-user-follows Requires `user:edit:follows` scope * We implement this in `providers/twitch/api/Helix.cpp followUser` @@ -99,7 +99,7 @@ Requires `user:edit:follows` scope * `controllers/commands/CommandController.cpp` in /follow command ### Unfollow User -URL: https://dev.twitch.tv/docs/api/reference#delete-user-follows +URL: https://dev.twitch.tv/docs/api/reference#delete-user-follows Requires `user:edit:follows` scope * We implement this in `providers/twitch/api/Helix.cpp unfollowUser` @@ -107,6 +107,14 @@ Requires `user:edit:follows` scope * `widgets/dialogs/UserInfoPopup.cpp` to unfollow a user by unticking follow checkbox in usercard * `controllers/commands/CommandController.cpp` in /unfollow command +### Create Clip +URL: https://dev.twitch.tv/docs/api/reference#create-clip +Requires `clips:edit` scope + + * We implement this in `providers/twitch/api/Helix.cpp createClip` + Used in: + * `TwitchChannel` to create a clip of a live broadcast + ## TMI The TMI api is undocumented. diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 657e03ab..3e11fffe 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -2083,6 +2083,10 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, } } break; + case Link::CopyToClipboard: { + crossPlatformCopy(link.value); + } + break; case Link::Reconnect: { this->underlyingChannel_.get()->reconnect(); } diff --git a/src/widgets/settingspages/KeyboardSettingsPage.cpp b/src/widgets/settingspages/KeyboardSettingsPage.cpp index 5158c88a..0b624e09 100644 --- a/src/widgets/settingspages/KeyboardSettingsPage.cpp +++ b/src/widgets/settingspages/KeyboardSettingsPage.cpp @@ -67,6 +67,7 @@ KeyboardSettingsPage::KeyboardSettingsPage() form->addRow(new QLabel("F5"), new QLabel("Reload subscriber and channel emotes")); form->addRow(new QLabel("Ctrl + F5"), new QLabel("Reconnect channels")); + form->addRow(new QLabel("Alt + X"), new QLabel("Create a clip")); form->addItem(new QSpacerItem(16, 16)); form->addRow(new QLabel("PageUp"), new QLabel("Scroll up")); diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 4a37dd9e..5e463901 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -121,6 +121,19 @@ Split::Split(QWidget *parent) // CTRL+F5: reconnect createShortcut(this, "CTRL+F5", &Split::reconnect); + // Alt+X: create clip LUL + createShortcut(this, "Alt+X", [this] { + if (!this->getChannel()->isTwitchChannel()) + { + return; + } + + auto *twitchChannel = + dynamic_cast(this->getChannel().get()); + + twitchChannel->createClip(); + }); + // F10 createShortcut(this, "F10", [] { auto *popup = new DebugPopup; diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 903405b0..9c6d3b62 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -359,7 +359,10 @@ std::unique_ptr SplitHeader::createMainMenu() }); #endif - if (dynamic_cast(this->split_->getChannel().get())) + auto *twitchChannel = + dynamic_cast(this->split_->getChannel().get()); + + if (twitchChannel) { menu->addAction(OPEN_IN_BROWSER, this->split_, &Split::openInBrowser); #ifndef USEWEBENGINE @@ -375,6 +378,18 @@ std::unique_ptr SplitHeader::createMainMenu() &Split::openWithCustomScheme); } + auto clipButton = menu->addAction( + "Create a clip", this->split_, + [twitchChannel] { + twitchChannel->createClip(); + }, + QKeySequence("Alt+X")); + clipButton->setVisible(this->split_->getChannel()->isLive()); + this->managedConnect( + twitchChannel->liveStatusChanged, [this, clipButton] { + clipButton->setVisible(this->split_->getChannel()->isLive()); + }); + if (this->split_->getChannel()->hasModRights()) { menu->addAction(OPEN_MOD_VIEW_IN_BROWSER, this->split_, @@ -398,7 +413,7 @@ std::unique_ptr SplitHeader::createMainMenu() QKeySequence("Ctrl+F5")); } - if (dynamic_cast(this->split_->getChannel().get())) + if (twitchChannel) { menu->addAction("Reload channel emotes", this, SLOT(reloadChannelEmotes()), QKeySequence("F5")); @@ -442,7 +457,7 @@ std::unique_ptr SplitHeader::createMainMenu() moreMenu->addAction(action); } - if (dynamic_cast(this->split_->getChannel().get())) + if (twitchChannel) { moreMenu->addAction("Show viewer list", this->split_, &Split::showViewerList); @@ -465,7 +480,7 @@ std::unique_ptr SplitHeader::createMainMenu() moreMenu->addAction(action); } - if (dynamic_cast(this->split_->getChannel().get())) + if (twitchChannel) { auto action = new QAction(this); action->setText("Mute highlight sound");