mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-13 19:49:51 +01:00
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:
parent
0542b81a03
commit
cfcac99ae6
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -21,7 +21,8 @@ public:
|
|||
AutoModDeny,
|
||||
OpenAccountsPage,
|
||||
JumpToChannel,
|
||||
Reconnect
|
||||
Reconnect,
|
||||
CopyToClipboard,
|
||||
};
|
||||
|
||||
Link();
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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("/"));
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Reference in a new issue