Merge pull request #678 from apa420/apa-notification-on-live

Notification when a stream goes live
This commit is contained in:
pajlada 2018-09-16 17:44:39 +02:00 committed by GitHub
commit 45988fc510
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 922 additions and 7 deletions

4
.gitmodules vendored
View file

@ -20,3 +20,7 @@
[submodule "lib/qBreakpad"] [submodule "lib/qBreakpad"]
path = lib/qBreakpad path = lib/qBreakpad
url = https://github.com/jiakuan/qBreakpad.git url = https://github.com/jiakuan/qBreakpad.git
[submodule "lib/WinToast"]
path = lib/WinToast
url = https://github.com/mohabouje/WinToast

View file

@ -52,6 +52,7 @@ include(dependencies/libcommuni.pri)
include(dependencies/websocketpp.pri) include(dependencies/websocketpp.pri)
include(dependencies/openssl.pri) include(dependencies/openssl.pri)
include(dependencies/boost.pri) include(dependencies/boost.pri)
include(dependencies/wintoast.pri)
# Optional feature: QtWebEngine # Optional feature: QtWebEngine
#exists ($(QTDIR)/include/QtWebEngine/QtWebEngine) { #exists ($(QTDIR)/include/QtWebEngine/QtWebEngine) {
@ -124,6 +125,7 @@ SOURCES += \
src/controllers/highlights/UserHighlightModel.cpp \ src/controllers/highlights/UserHighlightModel.cpp \
src/controllers/ignores/IgnoreController.cpp \ src/controllers/ignores/IgnoreController.cpp \
src/controllers/ignores/IgnoreModel.cpp \ src/controllers/ignores/IgnoreModel.cpp \
src/controllers/notifications/NotificationController.cpp \
src/controllers/taggedusers/TaggedUser.cpp \ src/controllers/taggedusers/TaggedUser.cpp \
src/controllers/taggedusers/TaggedUsersController.cpp \ src/controllers/taggedusers/TaggedUsersController.cpp \
src/controllers/taggedusers/TaggedUsersModel.cpp \ src/controllers/taggedusers/TaggedUsersModel.cpp \
@ -205,6 +207,7 @@ SOURCES += \
src/widgets/settingspages/KeyboardSettingsPage.cpp \ src/widgets/settingspages/KeyboardSettingsPage.cpp \
src/widgets/settingspages/LogsPage.cpp \ src/widgets/settingspages/LogsPage.cpp \
src/widgets/settingspages/ModerationPage.cpp \ src/widgets/settingspages/ModerationPage.cpp \
src/widgets/settingspages/NotificationPage.cpp \
src/widgets/settingspages/SettingsPage.cpp \ src/widgets/settingspages/SettingsPage.cpp \
src/widgets/settingspages/SpecialChannelsPage.cpp \ src/widgets/settingspages/SpecialChannelsPage.cpp \
src/widgets/splits/Split.cpp \ src/widgets/splits/Split.cpp \
@ -250,6 +253,9 @@ SOURCES += \
src/BrowserExtension.cpp \ src/BrowserExtension.cpp \
src/util/FormatTime.cpp \ src/util/FormatTime.cpp \
src/util/FunctionEventFilter.cpp \ src/util/FunctionEventFilter.cpp \
src/controllers/notifications/NotificationModel.cpp \
src/singletons/Toasts.cpp \
src/common/DownloadManager.cpp \
src/widgets/helper/EffectLabel.cpp \ src/widgets/helper/EffectLabel.cpp \
src/widgets/helper/Button.cpp \ src/widgets/helper/Button.cpp \
src/messages/MessageContainer.cpp \ src/messages/MessageContainer.cpp \
@ -292,6 +298,7 @@ HEADERS += \
src/controllers/ignores/IgnoreController.hpp \ src/controllers/ignores/IgnoreController.hpp \
src/controllers/ignores/IgnoreModel.hpp \ src/controllers/ignores/IgnoreModel.hpp \
src/controllers/ignores/IgnorePhrase.hpp \ src/controllers/ignores/IgnorePhrase.hpp \
src/controllers/notifications/NotificationController.hpp \
src/controllers/taggedusers/TaggedUser.hpp \ src/controllers/taggedusers/TaggedUser.hpp \
src/controllers/taggedusers/TaggedUsersController.hpp \ src/controllers/taggedusers/TaggedUsersController.hpp \
src/controllers/taggedusers/TaggedUsersModel.hpp \ src/controllers/taggedusers/TaggedUsersModel.hpp \
@ -394,6 +401,7 @@ HEADERS += \
src/widgets/settingspages/KeyboardSettingsPage.hpp \ src/widgets/settingspages/KeyboardSettingsPage.hpp \
src/widgets/settingspages/LogsPage.hpp \ src/widgets/settingspages/LogsPage.hpp \
src/widgets/settingspages/ModerationPage.hpp \ src/widgets/settingspages/ModerationPage.hpp \
src/widgets/settingspages/NotificationPage.hpp \
src/widgets/settingspages/SettingsPage.hpp \ src/widgets/settingspages/SettingsPage.hpp \
src/widgets/settingspages/SpecialChannelsPage.hpp \ src/widgets/settingspages/SpecialChannelsPage.hpp \
src/widgets/splits/Split.hpp \ src/widgets/splits/Split.hpp \
@ -446,6 +454,9 @@ HEADERS += \
src/BrowserExtension.hpp \ src/BrowserExtension.hpp \
src/util/FormatTime.hpp \ src/util/FormatTime.hpp \
src/util/FunctionEventFilter.hpp \ src/util/FunctionEventFilter.hpp \
src/controllers/notifications/NotificationModel.hpp \
src/singletons/Toasts.hpp \
src/common/DownloadManager.hpp \
src/widgets/helper/EffectLabel.hpp \ src/widgets/helper/EffectLabel.hpp \
src/util/LayoutHelper.hpp \ src/util/LayoutHelper.hpp \
src/widgets/helper/Button.hpp \ src/widgets/helper/Button.hpp \

5
dependencies/wintoast.pri vendored Normal file
View file

@ -0,0 +1,5 @@
win32 {
INCLUDEPATH += $$PWD/../lib/wintoast/src/
SOURCES += \
$$PWD/../lib/WinToast/src/wintoastlib.cpp
}

View file

@ -5,6 +5,7 @@
#include "controllers/highlights/HighlightController.hpp" #include "controllers/highlights/HighlightController.hpp"
#include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnoreController.hpp"
#include "controllers/moderationactions/ModerationActions.hpp" #include "controllers/moderationactions/ModerationActions.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "controllers/taggedusers/TaggedUsersController.hpp" #include "controllers/taggedusers/TaggedUsersController.hpp"
#include "debug/Log.hpp" #include "debug/Log.hpp"
#include "messages/MessageBuilder.hpp" #include "messages/MessageBuilder.hpp"
@ -21,6 +22,7 @@
#include "singletons/Resources.hpp" #include "singletons/Resources.hpp"
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/Theme.hpp" #include "singletons/Theme.hpp"
#include "singletons/Toasts.hpp"
#include "singletons/WindowManager.hpp" #include "singletons/WindowManager.hpp"
#include "util/IsBigEndian.hpp" #include "util/IsBigEndian.hpp"
#include "util/PostToThread.hpp" #include "util/PostToThread.hpp"
@ -45,16 +47,19 @@ Application::Application(Settings &_settings, Paths &_paths)
, fonts(&this->emplace<Fonts>()) , fonts(&this->emplace<Fonts>())
, emotes(&this->emplace<Emotes>()) , emotes(&this->emplace<Emotes>())
, windows(&this->emplace<WindowManager>()) , windows(&this->emplace<WindowManager>())
, toasts(&this->emplace<Toasts>())
, accounts(&this->emplace<AccountController>()) , accounts(&this->emplace<AccountController>())
, commands(&this->emplace<CommandController>()) , commands(&this->emplace<CommandController>())
, highlights(&this->emplace<HighlightController>()) , highlights(&this->emplace<HighlightController>())
, notifications(&this->emplace<NotificationController>())
, ignores(&this->emplace<IgnoreController>()) , ignores(&this->emplace<IgnoreController>())
, taggedUsers(&this->emplace<TaggedUsersController>()) , taggedUsers(&this->emplace<TaggedUsersController>())
, moderationActions(&this->emplace<ModerationActions>()) , moderationActions(&this->emplace<ModerationActions>())
, twitch2(&this->emplace<TwitchServer>()) , twitch2(&this->emplace<TwitchServer>())
, chatterinoBadges(&this->emplace<ChatterinoBadges>()) , chatterinoBadges(&this->emplace<ChatterinoBadges>())
, logging(&this->emplace<Logging>()) , logging(&this->emplace<Logging>())
{ {
this->instance = this; this->instance = this;

View file

@ -16,6 +16,7 @@ class IgnoreController;
class TaggedUsersController; class TaggedUsersController;
class AccountController; class AccountController;
class ModerationActions; class ModerationActions;
class NotificationController;
class Theme; class Theme;
class WindowManager; class WindowManager;
@ -26,6 +27,7 @@ class Emotes;
class Settings; class Settings;
class Fonts; class Fonts;
class Resources2; class Resources2;
class Toasts;
class ChatterinoBadges; class ChatterinoBadges;
class Application class Application
@ -53,10 +55,12 @@ public:
Fonts *const fonts{}; Fonts *const fonts{};
Emotes *const emotes{}; Emotes *const emotes{};
WindowManager *const windows{}; WindowManager *const windows{};
Toasts *const toasts{};
AccountController *const accounts{}; AccountController *const accounts{};
CommandController *const commands{}; CommandController *const commands{};
HighlightController *const highlights{}; HighlightController *const highlights{};
NotificationController *const notifications{};
IgnoreController *const ignores{}; IgnoreController *const ignores{};
TaggedUsersController *const taggedUsers{}; TaggedUsersController *const taggedUsers{};
ModerationActions *const moderationActions{}; ModerationActions *const moderationActions{};

View file

@ -17,6 +17,7 @@ enum class HighlightState {
None, None,
Highlighted, Highlighted,
NewMessage, NewMessage,
Notification,
}; };
inline QString qS(const std::string &string) inline QString qS(const std::string &string)

View file

@ -0,0 +1,79 @@
#include "DownloadManager.hpp"
#include "singletons/Paths.hpp"
#include <QDesktopServices>
namespace chatterino {
DownloadManager::DownloadManager(QObject *parent)
: QObject(parent)
{
manager = new QNetworkAccessManager;
}
DownloadManager::~DownloadManager()
{
manager->deleteLater();
}
void DownloadManager::setFile(QString fileURL, const QString &channelName)
{
QString filePath = fileURL;
QString saveFilePath;
QStringList filePathList = filePath.split('/');
saveFilePath =
getPaths()->twitchProfileAvatars + "/twitch/" + channelName + ".png";
QNetworkRequest request;
request.setUrl(QUrl(fileURL));
reply = manager->get(request);
file = new QFile;
file->setFileName(saveFilePath);
file->open(QIODevice::WriteOnly);
connect(reply, SIGNAL(downloadProgress(qint64, qint64)), this,
SLOT(onDownloadProgress(qint64, qint64)));
connect(manager, SIGNAL(finished(QNetworkReply *)), this,
SLOT(onFinished(QNetworkReply *)));
connect(reply, SIGNAL(readyRead()), this, SLOT(onReadyRead()));
connect(reply, SIGNAL(finished()), this, SLOT(onReplyFinished()));
}
void DownloadManager::onDownloadProgress(qint64 bytesRead, qint64 bytesTotal)
{
qDebug(QString::number(bytesRead).toLatin1() + " - " +
QString::number(bytesTotal).toLatin1());
}
void DownloadManager::onFinished(QNetworkReply *reply)
{
switch (reply->error()) {
case QNetworkReply::NoError: {
qDebug("file is downloaded successfully.");
} break;
default: {
qDebug(reply->errorString().toLatin1());
};
}
if (file->isOpen()) {
file->close();
file->deleteLater();
}
emit downloadComplete();
}
void DownloadManager::onReadyRead()
{
file->write(reply->readAll());
}
void DownloadManager::onReplyFinished()
{
if (file->isOpen()) {
file->close();
file->deleteLater();
}
}
} // namespace chatterino

View file

@ -0,0 +1,36 @@
#pragma once
#include "Application.hpp"
#include <QFile>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QObject>
#include <QStringList>
namespace chatterino {
class DownloadManager : public QObject
{
Q_OBJECT
public:
explicit DownloadManager(QObject *parent = nullptr);
virtual ~DownloadManager();
void setFile(QString fileURL, const QString &channelName);
private:
QNetworkAccessManager *manager;
QNetworkReply *reply;
QFile *file;
private slots:
void onDownloadProgress(qint64, qint64);
void onFinished(QNetworkReply *);
void onReadyRead();
void onReplyFinished();
signals:
void downloadComplete();
};
} // namespace chatterino

View file

@ -0,0 +1,203 @@
#include "controllers/notifications/NotificationController.hpp"
#include "Application.hpp"
#include "common/NetworkRequest.hpp"
#include "common/Outcome.hpp"
#include "controllers/notifications/NotificationModel.hpp"
#include "debug/Log.hpp"
#include "providers/twitch/TwitchApi.hpp"
#include "providers/twitch/TwitchServer.hpp"
#include "singletons/Toasts.hpp"
#include "singletons/WindowManager.hpp"
#include "widgets/Window.hpp"
#ifdef Q_OS_WIN
# include <wintoastlib.h>
#endif
#include <QDesktopServices>
#include <QDir>
#include <QMediaPlayer>
#include <QUrl>
namespace chatterino {
void NotificationController::initialize(Settings &settings, Paths &paths)
{
this->initialized_ = true;
for (const QString &channelName : this->twitchSetting_.getValue()) {
this->channelMap[Platform::Twitch].appendItem(channelName);
}
this->channelMap[Platform::Twitch].delayedItemsChanged.connect([this] { //
this->twitchSetting_.setValue(
this->channelMap[Platform::Twitch].getVector());
});
/*
for (const QString &channelName : this->mixerSetting_.getValue()) {
this->channelMap[Platform::Mixer].appendItem(channelName);
}
this->channelMap[Platform::Mixer].delayedItemsChanged.connect([this] { //
this->mixerSetting_.setValue(
this->channelMap[Platform::Mixer].getVector());
});*/
liveStatusTimer_ = new QTimer();
this->fetchFakeChannels();
QObject::connect(this->liveStatusTimer_, &QTimer::timeout,
[=] { this->fetchFakeChannels(); });
this->liveStatusTimer_->start(60 * 1000);
}
void NotificationController::updateChannelNotification(
const QString &channelName, Platform p)
{
if (isChannelNotified(channelName, p)) {
removeChannelNotification(channelName, p);
} else {
addChannelNotification(channelName, p);
}
}
bool NotificationController::isChannelNotified(const QString &channelName,
Platform p)
{
for (const auto &channel : this->channelMap[p].getVector()) {
if (channelName.toLower() == channel.toLower()) {
return true;
}
}
return false;
}
void NotificationController::addChannelNotification(const QString &channelName,
Platform p)
{
channelMap[p].appendItem(channelName);
}
void NotificationController::removeChannelNotification(
const QString &channelName, Platform p)
{
for (std::vector<int>::size_type i = 0;
i != channelMap[p].getVector().size(); i++) {
if (channelMap[p].getVector()[i].toLower() == channelName.toLower()) {
channelMap[p].removeItem(i);
i--;
}
}
}
void NotificationController::playSound()
{
static auto player = new QMediaPlayer;
static QUrl currentPlayerUrl;
QUrl highlightSoundUrl;
if (getSettings()->notificationCustomSound) {
highlightSoundUrl = QUrl::fromLocalFile(
getSettings()->notificationPathSound.getValue());
} else {
highlightSoundUrl = QUrl("qrc:/sounds/ping2.wav");
}
if (currentPlayerUrl != highlightSoundUrl) {
player->setMedia(highlightSoundUrl);
currentPlayerUrl = highlightSoundUrl;
}
player->play();
}
NotificationModel *NotificationController::createModel(QObject *parent,
Platform p)
{
NotificationModel *model = new NotificationModel(parent);
model->init(&this->channelMap[p]);
return model;
}
void NotificationController::fetchFakeChannels()
{
for (std::vector<int>::size_type i = 0;
i != channelMap[Platform::Twitch].getVector().size(); i++) {
auto chan = getApp()->twitch.server->getChannelOrEmpty(
channelMap[Platform::Twitch].getVector()[i]);
if (chan->isEmpty()) {
getFakeTwitchChannelLiveStatus(
channelMap[Platform::Twitch].getVector()[i]);
}
}
}
void NotificationController::getFakeTwitchChannelLiveStatus(
const QString &channelName)
{
TwitchApi::findUserId(channelName, [channelName, this](QString roomID) {
if (roomID.isEmpty()) {
log("[TwitchChannel:{}] Refreshing live status (Missing ID)",
channelName);
removeFakeChannel(channelName);
return;
}
log("[TwitchChannel:{}] Refreshing live status", channelName);
QString url("https://api.twitch.tv/kraken/streams/" + roomID);
auto request = NetworkRequest::twitchRequest(url);
request.setCaller(QThread::currentThread());
request.onSuccess([this, channelName](auto result) -> Outcome {
rapidjson::Document document = result.parseRapidJson();
if (!document.IsObject()) {
log("[TwitchChannel:refreshLiveStatus]root is not an object");
return Failure;
}
if (!document.HasMember("stream")) {
log("[TwitchChannel:refreshLiveStatus] Missing stream in root");
return Failure;
}
const auto &stream = document["stream"];
if (!stream.IsObject()) {
// Stream is offline (stream is most likely null)
// removeFakeChannel(channelName);
return Failure;
}
// Stream is live
auto i = std::find(fakeTwitchChannels.begin(),
fakeTwitchChannels.end(), channelName);
if (!(i != fakeTwitchChannels.end())) {
fakeTwitchChannels.push_back(channelName);
if (Toasts::isEnabled()) {
getApp()->toasts->sendChannelNotification(channelName,
Platform::Twitch);
}
if (getSettings()->notificationPlaySound) {
getApp()->notifications->playSound();
}
if (getSettings()->notificationFlashTaskbar) {
QApplication::alert(
getApp()->windows->getMainWindow().window(), 2500);
}
}
return Success;
});
request.execute();
});
}
void NotificationController::removeFakeChannel(const QString channelName)
{
auto i = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(),
channelName);
if (i != fakeTwitchChannels.end()) {
fakeTwitchChannels.erase(i);
}
}
} // namespace chatterino

View file

@ -0,0 +1,57 @@
#pragma once
#include "common/SignalVector.hpp"
#include "common/Singleton.hpp"
#include "singletons/Settings.hpp"
#include <QTimer>
namespace chatterino {
class Settings;
class Paths;
class NotificationModel;
enum class Platform : uint8_t {
Twitch, // 0
// Mixer, // 1
};
class NotificationController final : public Singleton, private QObject
{
public:
virtual void initialize(Settings &settings, Paths &paths) override;
bool isChannelNotified(const QString &channelName, Platform p);
void updateChannelNotification(const QString &channelName, Platform p);
void addChannelNotification(const QString &channelName, Platform p);
void removeChannelNotification(const QString &channelName, Platform p);
void playSound();
UnsortedSignalVector<QString> getVector(Platform p);
std::map<Platform, UnsortedSignalVector<QString>> channelMap;
NotificationModel *createModel(QObject *parent, Platform p);
private:
bool initialized_ = false;
void fetchFakeChannels();
void removeFakeChannel(const QString channelName);
void getFakeTwitchChannelLiveStatus(const QString &channelName);
std::vector<QString> fakeTwitchChannels;
QTimer *liveStatusTimer_;
ChatterinoSetting<std::vector<QString>> twitchSetting_ = {
"/notifications/twitch"};
/*
ChatterinoSetting<std::vector<QString>> mixerSetting_ = {
"/notifications/mixer"};
*/
};
} // namespace chatterino

View file

@ -0,0 +1,28 @@
#include "NotificationModel.hpp"
#include "Application.hpp"
#include "singletons/Settings.hpp"
#include "util/StandardItemHelper.hpp"
namespace chatterino {
NotificationModel::NotificationModel(QObject *parent)
: SignalVectorModel<QString>(1, parent)
{
}
// turn a vector item into a model row
QString NotificationModel::getItemFromRow(std::vector<QStandardItem *> &row,
const QString &original)
{
return QString(row[0]->data(Qt::DisplayRole).toString());
}
// turn a model
void NotificationModel::getRowFromItem(const QString &item,
std::vector<QStandardItem *> &row)
{
setStringItem(row[0], item);
}
} // namespace chatterino

View file

@ -0,0 +1,28 @@
#pragma once
#include <QObject>
#include "common/SignalVectorModel.hpp"
#include "controllers/notifications/NotificationController.hpp"
namespace chatterino {
class NotificationController;
class NotificationModel : public SignalVectorModel<QString>
{
explicit NotificationModel(QObject *parent);
protected:
// turn a vector item into a model row
virtual QString getItemFromRow(std::vector<QStandardItem *> &row,
const QString &original) override;
// turns a row in the model into a vector item
virtual void getRowFromItem(const QString &item,
std::vector<QStandardItem *> &row) override;
friend class NotificationController;
};
} // namespace chatterino

View file

@ -26,6 +26,7 @@ enum class MessageFlag : uint16_t {
Untimeout = (1 << 9), Untimeout = (1 << 9),
PubSub = (1 << 10), PubSub = (1 << 10),
Subscription = (1 << 11), Subscription = (1 << 11),
Notification = (1 << 12),
}; };
using MessageFlags = FlagsEnum<MessageFlag>; using MessageFlags = FlagsEnum<MessageFlag>;

View file

@ -6,6 +6,7 @@
#include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchCommon.hpp"
#include <QString> #include <QString>
#include <QThread>
namespace chatterino { namespace chatterino {

View file

@ -4,6 +4,7 @@
#include "common/Common.hpp" #include "common/Common.hpp"
#include "common/NetworkRequest.hpp" #include "common/NetworkRequest.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "debug/Log.hpp" #include "debug/Log.hpp"
#include "messages/Message.hpp" #include "messages/Message.hpp"
#include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/BttvEmotes.hpp"
@ -14,7 +15,10 @@
#include "providers/twitch/TwitchParseCheerEmotes.hpp" #include "providers/twitch/TwitchParseCheerEmotes.hpp"
#include "singletons/Emotes.hpp" #include "singletons/Emotes.hpp"
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/Toasts.hpp"
#include "singletons/WindowManager.hpp"
#include "util/PostToThread.hpp" #include "util/PostToThread.hpp"
#include "widgets/Window.hpp"
#include <IrcConnection> #include <IrcConnection>
#include <QJsonArray> #include <QJsonArray>
@ -86,6 +90,12 @@ TwitchChannel::TwitchChannel(const QString &name,
{ {
log("[TwitchChannel:{}] Opened", name); log("[TwitchChannel:{}] Opened", name);
this->tabHighlightRequested.connect([](HighlightState state) {});
this->liveStatusChanged.connect([this]() {
if (this->isLive() == 1) {
}
});
this->managedConnect(getApp()->accounts->twitch.currentUserChanged, this->managedConnect(getApp()->accounts->twitch.currentUserChanged,
[=] { this->setMod(false); }); [=] { this->setMod(false); });
@ -385,6 +395,30 @@ void TwitchChannel::setLive(bool newLiveStatus)
auto guard = this->streamStatus_.access(); auto guard = this->streamStatus_.access();
if (guard->live != newLiveStatus) { if (guard->live != newLiveStatus) {
gotNewLiveStatus = true; gotNewLiveStatus = true;
if (newLiveStatus) {
if (getApp()->notifications->isChannelNotified(
this->getName(), Platform::Twitch)) {
if (Toasts::isEnabled()) {
getApp()->toasts->sendChannelNotification(
this->getName(), Platform::Twitch);
}
if (getSettings()->notificationPlaySound) {
getApp()->notifications->playSound();
}
if (getSettings()->notificationFlashTaskbar) {
QApplication::alert(
getApp()->windows->getMainWindow().window(), 2500);
}
}
auto live = makeSystemMessage(this->getName() + " is live");
this->addMessage(live);
this->tabHighlightRequested.invoke(
HighlightState::Notification);
} else {
auto offline =
makeSystemMessage(this->getName() + " is offline");
this->addMessage(offline);
}
guard->live = newLiveStatus; guard->live = newLiveStatus;
} }
} }
@ -466,7 +500,6 @@ Outcome TwitchChannel::parseLiveStatus(const rapidjson::Document &document)
{ {
auto status = this->streamStatus_.access(); auto status = this->streamStatus_.access();
status->live = true;
status->viewerCount = stream["viewers"].GetUint(); status->viewerCount = stream["viewers"].GetUint();
status->game = stream["game"].GetString(); status->game = stream["game"].GetString();
status->title = streamChannel["status"].GetString(); status->title = streamChannel["status"].GetString();
@ -495,7 +528,7 @@ Outcome TwitchChannel::parseLiveStatus(const rapidjson::Document &document)
} }
} }
} }
setLive(true);
// Signal all listeners that the stream status has been updated // Signal all listeners that the stream status has been updated
this->liveStatusChanged.invoke(); this->liveStatusChanged.invoke();

View file

@ -19,6 +19,8 @@
namespace chatterino { namespace chatterino {
enum class HighlightState;
struct Emote; struct Emote;
using EmotePtr = std::shared_ptr<const Emote>; using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap; class EmoteMap;
@ -90,6 +92,7 @@ public:
pajlada::Signals::NoArgSignal userStateChanged; pajlada::Signals::NoArgSignal userStateChanged;
pajlada::Signals::NoArgSignal liveStatusChanged; pajlada::Signals::NoArgSignal liveStatusChanged;
pajlada::Signals::NoArgSignal roomModesChanged; pajlada::Signals::NoArgSignal roomModesChanged;
pajlada::Signals::Signal<HighlightState> tabHighlightRequested;
protected: protected:
void addRecentChatter(const MessagePtr &message) override; void addRecentChatter(const MessagePtr &message) override;

View file

@ -130,6 +130,8 @@ void Paths::initSubDirectories()
this->cacheDirectory_ = makePath("Cache"); this->cacheDirectory_ = makePath("Cache");
this->messageLogDirectory = makePath("Logs"); this->messageLogDirectory = makePath("Logs");
this->miscDirectory = makePath("Misc"); this->miscDirectory = makePath("Misc");
this->twitchProfileAvatars = makePath("ProfileAvatars");
QDir().mkdir(this->twitchProfileAvatars + "/twitch");
} }
Paths *getPaths() Paths *getPaths()

View file

@ -28,6 +28,9 @@ public:
// Hash of QCoreApplication::applicationFilePath() // Hash of QCoreApplication::applicationFilePath()
QString applicationFilePathHash; QString applicationFilePathHash;
// Profile avatars for twitch <appDataDirectory>/cache/twitch
QString twitchProfileAvatars;
bool createFolder(const QString &folderPath); bool createFolder(const QString &folderPath);
bool isPortable(); bool isPortable();

View file

@ -152,6 +152,18 @@ public:
BoolSetting inlineWhispers = {"/whispers/enableInlineWhispers", true}; BoolSetting inlineWhispers = {"/whispers/enableInlineWhispers", true};
/// Notifications
BoolSetting notificationFlashTaskbar = {"/notifications/enableFlashTaskbar",
false};
BoolSetting notificationPlaySound = {"/notifications/enablePlaySound",
false};
BoolSetting notificationCustomSound = {"/notifications/customPlaySound",
false};
QStringSetting notificationPathSound = {"/notifications/highlightSoundPath",
"qrc:/sounds/ping3.wav"};
BoolSetting notificationToast = {"/notifications/enableToast", false};
/// External tools /// External tools
// Streamlink // Streamlink
BoolSetting streamlinkUseCustomPath = {"/external/streamlink/useCustomPath", BoolSetting streamlinkUseCustomPath = {"/external/streamlink/useCustomPath",

View file

@ -105,6 +105,10 @@ void Theme::actuallyUpdate(double hue, double multiplier)
QColor("#000"), QColor("#000"),
{QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")},
{QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}}; {QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}};
this->tabs.notified = {
fg,
{QColor("#252525"), QColor("#252525"), QColor("#252525")},
{QColor("#F824A8"), QColor("#F824A8"), QColor("#F824A8")}};
} else { } else {
this->tabs.regular = { this->tabs.regular = {
QColor("#aaa"), QColor("#aaa"),
@ -123,6 +127,10 @@ void Theme::actuallyUpdate(double hue, double multiplier)
QColor("#fff"), QColor("#fff"),
{QColor("#555555"), QColor("#555555"), QColor("#555555")}, {QColor("#555555"), QColor("#555555"), QColor("#555555")},
{QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}}; {QColor("#00aeef"), QColor("#00aeef"), QColor("#00aeef")}};
this->tabs.notified = {
fg,
{QColor("#252525"), QColor("#252525"), QColor("#252525")},
{QColor("#F824A8"), QColor("#F824A8"), QColor("#F824A8")}};
} }
this->splits.input.focusedLine = highlighted; this->splits.input.focusedLine = highlighted;
@ -150,7 +158,7 @@ void Theme::actuallyUpdate(double hue, double multiplier)
// QColor("#777"), QColor("#666")}}; // QColor("#777"), QColor("#666")}};
this->tabs.bottomLine = this->tabs.selected.backgrounds.regular.color(); this->tabs.bottomLine = this->tabs.selected.backgrounds.regular.color();
} } // namespace chatterino
// Split // Split
bool flat = isLight_; bool flat = isLight_;
@ -232,7 +240,7 @@ void Theme::actuallyUpdate(double hue, double multiplier)
isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64);
this->updated.invoke(); this->updated.invoke();
} } // namespace chatterino
QColor Theme::blendColors(const QColor &color1, const QColor &color2, QColor Theme::blendColors(const QColor &color1, const QColor &color2,
qreal ratio) qreal ratio)

View file

@ -49,6 +49,7 @@ public:
TabColors newMessage; TabColors newMessage;
TabColors highlighted; TabColors highlighted;
TabColors selected; TabColors selected;
TabColors notified;
QColor border; QColor border;
QColor bottomLine; QColor bottomLine;
} tabs; } tabs;

192
src/singletons/Toasts.cpp Normal file
View file

@ -0,0 +1,192 @@
#include "Toasts.hpp"
#include "Application.hpp"
#include "common/DownloadManager.hpp"
#include "common/NetworkRequest.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchServer.hpp"
#ifdef Q_OS_WIN
# include <wintoastlib.h>
#endif
#include <QDesktopServices>
#include <QFileInfo>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QUrl>
#include <cstdlib>
namespace chatterino {
bool Toasts::isEnabled()
{
#ifdef Q_OS_WIN
return WinToastLib::WinToast::isCompatible() &&
getSettings()->notificationToast;
#endif
return false;
}
void Toasts::sendChannelNotification(const QString &channelName, Platform p)
{
// Fetch user profile avatar
if (p == Platform::Twitch) {
QFileInfo check_file(getPaths()->twitchProfileAvatars + "/twitch/" +
channelName + ".png");
if (check_file.exists() && check_file.isFile()) {
#ifdef Q_OS_WIN
this->sendWindowsNotification(channelName, p);
#endif
// OSX
// LINUX
} else {
this->fetchChannelAvatar(
channelName, [this, channelName, p](QString avatarLink) {
DownloadManager *manager = new DownloadManager();
manager->setFile(avatarLink, channelName);
manager->connect(
manager, &DownloadManager::downloadComplete,
[this, channelName, p]() {
#ifdef Q_OS_WIN
this->sendWindowsNotification(channelName, p);
#endif
// OSX
// LINUX
});
});
}
}
return;
}
#ifdef Q_OS_WIN
class CustomHandler : public WinToastLib::IWinToastHandler
{
private:
QString channelName_;
Platform platform_;
public:
CustomHandler(QString channelName, Platform p)
: channelName_(channelName)
, platform_(p)
{
}
void toastActivated() const
{
QString link;
if (platform_ == Platform::Twitch) {
link = "http://www.twitch.tv/" + channelName_;
}
QDesktopServices::openUrl(QUrl(link));
}
void toastActivated(int actionIndex) const
{
}
void toastFailed() const
{
}
void toastDismissed(WinToastDismissalReason state) const
{
}
};
void Toasts::sendWindowsNotification(const QString &channelName, Platform p)
{
WinToastLib::WinToastTemplate templ = WinToastLib::WinToastTemplate(
WinToastLib::WinToastTemplate::ImageAndText03);
QString str = channelName + " is live!";
std::string utf8_text = str.toUtf8().constData();
std::wstring widestr = std::wstring(utf8_text.begin(), utf8_text.end());
templ.setTextField(widestr, WinToastLib::WinToastTemplate::FirstLine);
templ.setTextField(L"Click here to open in browser",
WinToastLib::WinToastTemplate::SecondLine);
QString Path;
if (p == Platform::Twitch) {
Path = getPaths()->twitchProfileAvatars + "/twitch/" + channelName +
".png";
}
std::string temp_Utf8 = Path.toUtf8().constData();
std::wstring imagePath = std::wstring(temp_Utf8.begin(), temp_Utf8.end());
templ.setImagePath(imagePath);
if (getSettings()->notificationPlaySound) {
templ.setAudioOption(
WinToastLib::WinToastTemplate::AudioOption::Silent);
}
WinToastLib::WinToast::instance()->setAppName(L"Chatterino2");
int mbstowcs(wchar_t * aumi_version, const char *CHATTERINO_VERSION,
size_t size);
std::string(CHATTERINO_VERSION);
std::wstring aumi_version =
std::wstring(CHATTERINO_VERSION.begin(), CHATTERINO_VERSION.end());
WinToastLib::WinToast::instance()->setAppUserModelId(
WinToastLib::WinToast::configureAUMI(L"", L"Chatterino 2", L"",
aumi_version));
WinToastLib::WinToast::instance()->initialize();
WinToastLib::WinToast::instance()->showToast(
templ, new CustomHandler(channelName, p));
}
#endif
void Toasts::fetchChannelAvatar(const QString channelName,
std::function<void(QString)> successCallback)
{
QString requestUrl("https://api.twitch.tv/kraken/users?login=" +
channelName);
NetworkRequest request(requestUrl);
request.setCaller(QThread::currentThread());
request.makeAuthorizedV5(getDefaultClientID());
request.setTimeout(30000);
request.onSuccess([successCallback](auto result) mutable -> Outcome {
auto root = result.parseJson();
if (!root.value("users").isArray()) {
// log("API Error while getting user id, users is not an array");
successCallback("");
return Failure;
}
auto users = root.value("users").toArray();
if (users.size() != 1) {
// log("API Error while getting user id, users array size is not
// 1");
successCallback("");
return Failure;
}
if (!users[0].isObject()) {
// log("API Error while getting user id, first user is not an
// object");
successCallback("");
return Failure;
}
auto firstUser = users[0].toObject();
auto avatar = firstUser.value("logo");
if (!avatar.isString()) {
// log("API Error: while getting user avatar, first user object "
// "`avatar` key "
// "is not a "
// "string");
successCallback("");
return Failure;
}
successCallback(avatar.toString());
return Success;
});
request.execute();
}
} // namespace chatterino

25
src/singletons/Toasts.hpp Normal file
View file

@ -0,0 +1,25 @@
#pragma once
#include "Application.hpp"
#include "common/Singleton.hpp"
namespace chatterino {
enum class Platform : uint8_t;
class Toasts final : public Singleton
{
public:
void sendChannelNotification(const QString &channelName, Platform p);
static bool isEnabled();
private:
#ifdef Q_OS_WIN
void sendWindowsNotification(const QString &channelName, Platform p);
#endif
static void fetchChannelAvatar(
const QString channelName,
std::function<void(QString)> successCallback);
};
} // namespace chatterino

View file

@ -17,6 +17,7 @@
#include "widgets/settingspages/LogsPage.hpp" #include "widgets/settingspages/LogsPage.hpp"
#include "widgets/settingspages/LookPage.hpp" #include "widgets/settingspages/LookPage.hpp"
#include "widgets/settingspages/ModerationPage.hpp" #include "widgets/settingspages/ModerationPage.hpp"
#include "widgets/settingspages/NotificationPage.hpp"
#include "widgets/settingspages/SpecialChannelsPage.hpp" #include "widgets/settingspages/SpecialChannelsPage.hpp"
#include <QDialogButtonBox> #include <QDialogButtonBox>
@ -102,6 +103,7 @@ void SettingsDialog::addTabs()
this->addTab(new KeyboardSettingsPage); this->addTab(new KeyboardSettingsPage);
// this->addTab(new LogsPage); // this->addTab(new LogsPage);
this->addTab(new ModerationPage); this->addTab(new ModerationPage);
this->addTab(new NotificationPage);
// this->addTab(new SpecialChannelsPage); // this->addTab(new SpecialChannelsPage);
this->addTab(new BrowserExtensionPage); this->addTab(new BrowserExtensionPage);
this->addTab(new ExternalToolsPage); this->addTab(new ExternalToolsPage);

View file

@ -9,6 +9,7 @@
#include "messages/Message.hpp" #include "messages/Message.hpp"
#include "messages/MessageElement.hpp" #include "messages/MessageElement.hpp"
#include "messages/layouts/MessageLayout.hpp" #include "messages/layouts/MessageLayout.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "messages/layouts/MessageLayoutElement.hpp" #include "messages/layouts/MessageLayoutElement.hpp"
#include "providers/twitch/TwitchServer.hpp" #include "providers/twitch/TwitchServer.hpp"
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
@ -535,6 +536,14 @@ void ChannelView::setChannel(ChannelPtr newChannel)
this->layoutMessages(); this->layoutMessages();
this->queueUpdate(); this->queueUpdate();
// Notifications
TwitchChannel *tc = dynamic_cast<TwitchChannel *>(newChannel.get());
if (tc != nullptr) {
tc->tabHighlightRequested.connect([this](HighlightState state) {
this->tabHighlightRequested.invoke(HighlightState::Notification);
});
}
} }
void ChannelView::detachChannel() void ChannelView::detachChannel()

View file

@ -171,8 +171,8 @@ void NotebookTab::setHighlightState(HighlightState newHighlightStyle)
if (this->isSelected()) { if (this->isSelected()) {
return; return;
} }
if (this->highlightState_ != HighlightState::Highlighted &&
if (this->highlightState_ != HighlightState::Highlighted) { this->highlightState_ != HighlightState::Notification) {
this->highlightState_ = newHighlightStyle; this->highlightState_ = newHighlightStyle;
this->update(); this->update();
@ -239,6 +239,8 @@ void NotebookTab::paintEvent(QPaintEvent *)
colors = this->theme->tabs.selected; colors = this->theme->tabs.selected;
} else if (this->highlightState_ == HighlightState::Highlighted) { } else if (this->highlightState_ == HighlightState::Highlighted) {
colors = this->theme->tabs.highlighted; colors = this->theme->tabs.highlighted;
} else if (this->highlightState_ == HighlightState::Notification) {
colors = this->theme->tabs.notified;
} else if (this->highlightState_ == HighlightState::NewMessage) { } else if (this->highlightState_ == HighlightState::NewMessage) {
colors = this->theme->tabs.newMessage; colors = this->theme->tabs.newMessage;
} else { } else {

View file

@ -0,0 +1,123 @@
#include "NotificationPage.hpp"
#include "Application.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "controllers/notifications/NotificationModel.hpp"
#include "singletons/Settings.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/helper/EditableModelView.hpp"
#include <QCheckBox>
#include <QFileDialog>
#include <QGroupBox>
#include <QHeaderView>
#include <QLabel>
#include <QListView>
#include <QPushButton>
#include <QTableView>
#include <QTimer>
namespace chatterino {
NotificationPage::NotificationPage()
: SettingsPage("Notifications", "")
{
LayoutCreator<NotificationPage> layoutCreator(this);
auto layout = layoutCreator.emplace<QVBoxLayout>().withoutMargin();
{
auto tabs = layout.emplace<QTabWidget>();
{
auto settings = tabs.appendTab(new QVBoxLayout, "Options");
{
settings.emplace<QLabel>("Enable for selected channels");
settings.append(this->createCheckBox(
"Flash taskbar",
getSettings()->notificationFlashTaskbar));
settings.append(this->createCheckBox(
"Playsound (doesn't mute the Windows 8.x sound of toasts)",
getSettings()->notificationPlaySound));
#ifdef Q_OS_WIN
settings.append(this->createCheckBox(
"Enable toasts (currently only for windows 8.x or 10)",
getSettings()->notificationToast));
#endif
auto customSound =
layout.emplace<QHBoxLayout>().withoutMargin();
{
customSound.append(this->createCheckBox(
"Custom sound",
getSettings()->notificationCustomSound));
auto selectFile = customSound.emplace<QPushButton>(
"Select custom sound file");
QObject::connect(
selectFile.getElement(), &QPushButton::clicked, this,
[this] {
auto fileName = QFileDialog::getOpenFileName(
this, tr("Open Sound"), "",
tr("Audio Files (*.mp3 *.wav)"));
getSettings()->notificationPathSound =
fileName;
});
}
settings->addStretch(1);
}
auto twitchChannels = tabs.appendTab(new QVBoxLayout, "Twitch");
{
EditableModelView *view =
twitchChannels
.emplace<EditableModelView>(
getApp()->notifications->createModel(
nullptr, Platform::Twitch))
.getElement();
view->setTitles({"Twitch channels"});
view->getTableView()->horizontalHeader()->setSectionResizeMode(
QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
QTimer::singleShot(1, [view] {
view->getTableView()->resizeColumnsToContents();
view->getTableView()->setColumnWidth(0, 200);
});
view->addButtonPressed.connect([] {
getApp()
->notifications->channelMap[Platform::Twitch]
.appendItem("channel");
});
}
/*
auto mixerChannels = tabs.appendTab(new QVBoxLayout, "Mixer");
{
EditableModelView *view =
mixerChannels
.emplace<EditableModelView>(
getApp()->notifications->createModel(
nullptr, Platform::Mixer))
.getElement();
view->setTitles({"Mixer channels"});
view->getTableView()->horizontalHeader()->setSectionResizeMode(
QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
QTimer::singleShot(1, [view] {
view->getTableView()->resizeColumnsToContents();
view->getTableView()->setColumnWidth(0, 200);
});
view->addButtonPressed.connect([] {
getApp()
->notifications->channelMap[Platform::Mixer]
.appendItem("channel");
});
}
*/
}
}
}
} // namespace chatterino

View file

@ -0,0 +1,20 @@
#pragma once
#include "widgets/settingspages/SettingsPage.hpp"
class QPushButton;
class QListWidget;
class QVBoxLayout;
namespace chatterino {
class NotificationPage : public SettingsPage
{
public:
NotificationPage();
private:
};
} // namespace chatterino

View file

@ -2,6 +2,7 @@
#include "Application.hpp" #include "Application.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchServer.hpp" #include "providers/twitch/TwitchServer.hpp"
#include "singletons/Resources.hpp" #include "singletons/Resources.hpp"
@ -211,6 +212,22 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
&Split::openBrowserPlayer); &Split::openBrowserPlayer);
#endif #endif
menu->addAction("Open streamlink", this->split_, &Split::openInStreamlink); menu->addAction("Open streamlink", this->split_, &Split::openInStreamlink);
auto action = new QAction(this);
action->setText("Notify when live");
action->setCheckable(true);
QObject::connect(menu.get(), &QMenu::aboutToShow, this, [action, this]() {
action->setChecked(getApp()->notifications->isChannelNotified(
this->split_->getChannel()->getName(), Platform::Twitch));
});
action->connect(action, &QAction::triggered, this, [this]() {
getApp()->notifications->updateChannelNotification(
this->split_->getChannel()->getName(), Platform::Twitch);
});
menu->addAction(action);
menu->addSeparator(); menu->addSeparator();
menu->addAction("Reload channel emotes", this, SLOT(reloadChannelEmotes())); menu->addAction("Reload channel emotes", this, SLOT(reloadChannelEmotes()));
menu->addAction("Reconnect", this, SLOT(reconnect())); menu->addAction("Reconnect", this, SLOT(reconnect()));