Added clip creation support 🎬 (#2271)

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.

Co-authored-by: Leon Richardt <leon.richardt@gmail.com>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
Paweł 2021-01-17 14:47:34 +01:00 committed by GitHub
parent 0542b81a03
commit cfcac99ae6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 253 additions and 8 deletions

View file

@ -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)

View file

@ -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<TwitchChannel *>(channel.get());
twitchChannel->createClip();
return "";
});
}
void CommandController::save()

View file

@ -21,7 +21,8 @@ public:
AutoModDeny,
OpenAccountsPage,
JumpToChannel,
Reconnect
Reconnect,
CopyToClipboard,
};
Link();

View file

@ -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<TimestampElement>();
// text
builder.emplace<TextElement>("Clip created!",
MessageElementFlag::Text,
MessageColor::System);
// clip link
builder
.emplace<TextElement>("Copy link to clipboard",
MessageElementFlag::Text,
MessageColor::Link)
->setLink(Link(Link::CopyToClipboard, CLIPS_LINK.arg(clip.id)));
// separator text
builder.emplace<TextElement>("or", MessageElementFlag::Text,
MessageColor::System);
// edit link
builder
.emplace<TextElement>("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<TimestampElement>();
switch (error)
{
case HelixClipError::ClipsDisabled: {
builder.emplace<TextElement>(
CLIPS_FAILURE_CLIPS_DISABLED_TEXT,
MessageElementFlag::Text, MessageColor::System);
}
break;
case HelixClipError::UserNotAuthenticated: {
builder.emplace<TextElement>(
CLIPS_FAILURE_NOT_AUTHENTICATED_TEXT,
MessageElementFlag::Text, MessageColor::System);
builder
.emplace<TextElement>(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<TextElement>(
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<EmotePtr> TwitchChannel::twitchBadge(
const QString &set, const QString &version) const
{

View file

@ -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;

View file

@ -359,6 +359,60 @@ void Helix::unfollowUser(QString userId, QString targetId,
.execute();
}
void Helix::createClip(QString channelId,
ResultCallback<HelixClip> successCallback,
std::function<void(HelixClipError)> failureCallback,
std::function<void()> 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("/"));

View file

@ -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<HelixGame> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#create-user-follows
void followUser(QString userId, QString targetId,
std::function<void()> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#delete-user-follows
void unfollowUser(QString userId, QString targetlId,
std::function<void()> successCallback,
HelixFailureCallback failureCallback);
// https://dev.twitch.tv/docs/api/reference#create-clip
void createClip(QString channelId,
ResultCallback<HelixClip> successCallback,
std::function<void(HelixClipError)> failureCallback,
std::function<void()> finallyCallback);
void update(QString clientId, QString oauthToken);
static void initialize();

View file

@ -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.

View file

@ -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();
}

View file

@ -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"));

View file

@ -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<TwitchChannel *>(this->getChannel().get());
twitchChannel->createClip();
});
// F10
createShortcut(this, "F10", [] {
auto *popup = new DebugPopup;

View file

@ -359,7 +359,10 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
});
#endif
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
auto *twitchChannel =
dynamic_cast<TwitchChannel *>(this->split_->getChannel().get());
if (twitchChannel)
{
menu->addAction(OPEN_IN_BROWSER, this->split_, &Split::openInBrowser);
#ifndef USEWEBENGINE
@ -375,6 +378,18 @@ std::unique_ptr<QMenu> 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<QMenu> SplitHeader::createMainMenu()
QKeySequence("Ctrl+F5"));
}
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
if (twitchChannel)
{
menu->addAction("Reload channel emotes", this,
SLOT(reloadChannelEmotes()), QKeySequence("F5"));
@ -442,7 +457,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
moreMenu->addAction(action);
}
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
if (twitchChannel)
{
moreMenu->addAction("Show viewer list", this->split_,
&Split::showViewerList);
@ -465,7 +480,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
moreMenu->addAction(action);
}
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
if (twitchChannel)
{
auto action = new QAction(this);
action->setText("Mute highlight sound");