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:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unversioned
|
## 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 "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: 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)
|
- Major: Color mentions to match the mentioned users. You can disable this by unchecking "Color @usernames" under `Settings -> General -> Advanced (misc.)`. (#1963, #2284)
|
||||||
|
|||||||
@@ -452,6 +452,19 @@ void CommandController::initialize(Settings &, Paths &paths)
|
|||||||
|
|
||||||
return "";
|
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()
|
void CommandController::save()
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public:
|
|||||||
AutoModDeny,
|
AutoModDeny,
|
||||||
OpenAccountsPage,
|
OpenAccountsPage,
|
||||||
JumpToChannel,
|
JumpToChannel,
|
||||||
Reconnect
|
Reconnect,
|
||||||
|
CopyToClipboard,
|
||||||
};
|
};
|
||||||
|
|
||||||
Link();
|
Link();
|
||||||
|
|||||||
@@ -35,8 +35,19 @@
|
|||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int TITLE_REFRESH_PERIOD = 10;
|
|
||||||
constexpr char MAGIC_MESSAGE_SUFFIX[] = u8" \U000E0000";
|
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
|
// 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
|
// 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();
|
.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(
|
boost::optional<EmotePtr> TwitchChannel::twitchBadge(
|
||||||
const QString &set, const QString &version) const
|
const QString &set, const QString &version) const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public:
|
|||||||
virtual bool canReconnect() const override;
|
virtual bool canReconnect() const override;
|
||||||
virtual void reconnect() override;
|
virtual void reconnect() override;
|
||||||
void refreshTitle();
|
void refreshTitle();
|
||||||
|
void createClip();
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const QString &subscriptionUrl();
|
const QString &subscriptionUrl();
|
||||||
@@ -188,6 +189,8 @@ private:
|
|||||||
QTimer liveStatusTimer_;
|
QTimer liveStatusTimer_;
|
||||||
QTimer chattersListTimer_;
|
QTimer chattersListTimer_;
|
||||||
QTime titleRefreshedTime_;
|
QTime titleRefreshedTime_;
|
||||||
|
QTime timeNextClipCreationAllowed_{QTime().currentTime()};
|
||||||
|
bool isClipCreationInProgress{false};
|
||||||
|
|
||||||
friend class TwitchIrcServer;
|
friend class TwitchIrcServer;
|
||||||
friend class TwitchMessageBuilder;
|
friend class TwitchMessageBuilder;
|
||||||
|
|||||||
@@ -359,6 +359,60 @@ void Helix::unfollowUser(QString userId, QString targetId,
|
|||||||
.execute();
|
.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)
|
NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery)
|
||||||
{
|
{
|
||||||
assert(!url.startsWith("/"));
|
assert(!url.startsWith("/"));
|
||||||
|
|||||||
@@ -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
|
class Helix final : boost::noncopyable
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -185,14 +202,22 @@ public:
|
|||||||
void getGameById(QString gameId, ResultCallback<HelixGame> successCallback,
|
void getGameById(QString gameId, ResultCallback<HelixGame> successCallback,
|
||||||
HelixFailureCallback failureCallback);
|
HelixFailureCallback failureCallback);
|
||||||
|
|
||||||
|
// https://dev.twitch.tv/docs/api/reference#create-user-follows
|
||||||
void followUser(QString userId, QString targetId,
|
void followUser(QString userId, QString targetId,
|
||||||
std::function<void()> successCallback,
|
std::function<void()> successCallback,
|
||||||
HelixFailureCallback failureCallback);
|
HelixFailureCallback failureCallback);
|
||||||
|
|
||||||
|
// https://dev.twitch.tv/docs/api/reference#delete-user-follows
|
||||||
void unfollowUser(QString userId, QString targetlId,
|
void unfollowUser(QString userId, QString targetlId,
|
||||||
std::function<void()> successCallback,
|
std::function<void()> successCallback,
|
||||||
HelixFailureCallback failureCallback);
|
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);
|
void update(QString clientId, QString oauthToken);
|
||||||
|
|
||||||
static void initialize();
|
static void initialize();
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ Requires `user:edit:follows` scope
|
|||||||
* `widgets/dialogs/UserInfoPopup.cpp` to unfollow a user by unticking follow checkbox in usercard
|
* `widgets/dialogs/UserInfoPopup.cpp` to unfollow a user by unticking follow checkbox in usercard
|
||||||
* `controllers/commands/CommandController.cpp` in /unfollow command
|
* `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
|
## TMI
|
||||||
The TMI api is undocumented.
|
The TMI api is undocumented.
|
||||||
|
|
||||||
|
|||||||
@@ -2083,6 +2083,10 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case Link::CopyToClipboard: {
|
||||||
|
crossPlatformCopy(link.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case Link::Reconnect: {
|
case Link::Reconnect: {
|
||||||
this->underlyingChannel_.get()->reconnect();
|
this->underlyingChannel_.get()->reconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ KeyboardSettingsPage::KeyboardSettingsPage()
|
|||||||
form->addRow(new QLabel("F5"),
|
form->addRow(new QLabel("F5"),
|
||||||
new QLabel("Reload subscriber and channel emotes"));
|
new QLabel("Reload subscriber and channel emotes"));
|
||||||
form->addRow(new QLabel("Ctrl + F5"), new QLabel("Reconnect channels"));
|
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->addItem(new QSpacerItem(16, 16));
|
||||||
form->addRow(new QLabel("PageUp"), new QLabel("Scroll up"));
|
form->addRow(new QLabel("PageUp"), new QLabel("Scroll up"));
|
||||||
|
|||||||
@@ -121,6 +121,19 @@ Split::Split(QWidget *parent)
|
|||||||
// CTRL+F5: reconnect
|
// CTRL+F5: reconnect
|
||||||
createShortcut(this, "CTRL+F5", &Split::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
|
// F10
|
||||||
createShortcut(this, "F10", [] {
|
createShortcut(this, "F10", [] {
|
||||||
auto *popup = new DebugPopup;
|
auto *popup = new DebugPopup;
|
||||||
|
|||||||
@@ -359,7 +359,10 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
|
|||||||
});
|
});
|
||||||
#endif
|
#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);
|
menu->addAction(OPEN_IN_BROWSER, this->split_, &Split::openInBrowser);
|
||||||
#ifndef USEWEBENGINE
|
#ifndef USEWEBENGINE
|
||||||
@@ -375,6 +378,18 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
|
|||||||
&Split::openWithCustomScheme);
|
&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())
|
if (this->split_->getChannel()->hasModRights())
|
||||||
{
|
{
|
||||||
menu->addAction(OPEN_MOD_VIEW_IN_BROWSER, this->split_,
|
menu->addAction(OPEN_MOD_VIEW_IN_BROWSER, this->split_,
|
||||||
@@ -398,7 +413,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
|
|||||||
QKeySequence("Ctrl+F5"));
|
QKeySequence("Ctrl+F5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
|
if (twitchChannel)
|
||||||
{
|
{
|
||||||
menu->addAction("Reload channel emotes", this,
|
menu->addAction("Reload channel emotes", this,
|
||||||
SLOT(reloadChannelEmotes()), QKeySequence("F5"));
|
SLOT(reloadChannelEmotes()), QKeySequence("F5"));
|
||||||
@@ -442,7 +457,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
|
|||||||
moreMenu->addAction(action);
|
moreMenu->addAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
|
if (twitchChannel)
|
||||||
{
|
{
|
||||||
moreMenu->addAction("Show viewer list", this->split_,
|
moreMenu->addAction("Show viewer list", this->split_,
|
||||||
&Split::showViewerList);
|
&Split::showViewerList);
|
||||||
@@ -465,7 +480,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
|
|||||||
moreMenu->addAction(action);
|
moreMenu->addAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dynamic_cast<TwitchChannel *>(this->split_->getChannel().get()))
|
if (twitchChannel)
|
||||||
{
|
{
|
||||||
auto action = new QAction(this);
|
auto action = new QAction(this);
|
||||||
action->setText("Mute highlight sound");
|
action->setText("Mute highlight sound");
|
||||||
|
|||||||
Reference in New Issue
Block a user