From 60bc90c5fad05f5fe9c92adc06acc1b1dcd225d2 Mon Sep 17 00:00:00 2001 From: Henk Kalkwater Date: Thu, 9 Sep 2021 02:18:10 +0200 Subject: [PATCH] Add support for server-side notifications --- core/include/JellyfinQt/apiclient.h | 2 +- core/include/JellyfinQt/eventbus.h | 4 +- core/include/JellyfinQt/support/jsonconv.h | 13 ++++++ core/include/JellyfinQt/websocket.h | 3 ++ core/src/apiclient.cpp | 1 - core/src/jellyfin.cpp | 1 + core/src/support/jsonconv.cpp | 2 +- core/src/websocket.cpp | 49 +++++++++++++++++---- qtquick/qml.qrc | 2 + qtquick/qml/components/Notification.qml | 28 ++++++++++++ qtquick/qml/components/NotificationList.qml | 49 +++++++++++++++++++++ qtquick/qml/main.qml | 18 ++++++++ qtquick/src/main.cpp | 2 + sailfish/qml/harbour-sailfin.qml | 19 +++++++- 14 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 qtquick/qml/components/Notification.qml create mode 100644 qtquick/qml/components/NotificationList.qml diff --git a/core/include/JellyfinQt/apiclient.h b/core/include/JellyfinQt/apiclient.h index 106ca85..a96a5ed 100644 --- a/core/include/JellyfinQt/apiclient.h +++ b/core/include/JellyfinQt/apiclient.h @@ -100,7 +100,7 @@ public: Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged) Q_PROPERTY(QJsonObject deviceProfile READ deviceProfileJson NOTIFY deviceProfileChanged) Q_PROPERTY(QString version READ version) - Q_PROPERTY(EventBus *eventbus READ eventbus FINAL) + Q_PROPERTY(Jellyfin::EventBus *eventbus READ eventbus FINAL) Q_PROPERTY(Jellyfin::WebSocket *websocket READ websocket FINAL) Q_PROPERTY(QVariantList supportedCommands READ supportedCommands WRITE setSupportedCommands NOTIFY supportedCommandsChanged) Q_PROPERTY(Jellyfin::ViewModel::Settings *settings READ settings NOTIFY settingsChanged) diff --git a/core/include/JellyfinQt/eventbus.h b/core/include/JellyfinQt/eventbus.h index f45bbfe..1558d04 100644 --- a/core/include/JellyfinQt/eventbus.h +++ b/core/include/JellyfinQt/eventbus.h @@ -45,9 +45,11 @@ signals: /** * @brief The server has requested to display an message to the user + * @param header The header of the message. * @param message The message to show. + * @param timeout Timeout in MS to show the message. -1: no timeout supplied. */ - void displayMessage(const QString &message); + void displayMessage(const QString &header, const QString &message, int timeout = -1); }; } diff --git a/core/include/JellyfinQt/support/jsonconv.h b/core/include/JellyfinQt/support/jsonconv.h index e778022..0ef4663 100644 --- a/core/include/JellyfinQt/support/jsonconv.h +++ b/core/include/JellyfinQt/support/jsonconv.h @@ -37,6 +37,19 @@ extern template QDateTime fromJsonValue(const QJsonValue &source, con extern template QVariant fromJsonValue(const QJsonValue &source, convertType); extern template QUuid fromJsonValue(const QJsonValue &source, convertType); +extern template QJsonValue toJsonValue(const int &source, convertType); +extern template QJsonValue toJsonValue(const qint64 &source, convertType); +extern template QJsonValue toJsonValue(const bool &source, convertType); +extern template QJsonValue toJsonValue(const QString &source, convertType); +extern template QJsonValue toJsonValue(const QStringList &source, convertType); +extern template QJsonValue toJsonValue(const QJsonObject &source, convertType); +extern template QJsonValue toJsonValue(const double &source, convertType); +extern template QJsonValue toJsonValue(const float &source, convertType); +extern template QJsonValue toJsonValue(const QDateTime &source, convertType); +extern template QJsonValue toJsonValue(const QVariant &source, convertType); +extern template QJsonValue toJsonValue(const QUuid &source, convertType); + + extern template QString toString(const QUuid &source, convertType); extern template QString toString(const qint32 &source, convertType); extern template QString toString(const qint64 &source, convertType); diff --git a/core/include/JellyfinQt/websocket.h b/core/include/JellyfinQt/websocket.h index c8490a2..889ba30 100644 --- a/core/include/JellyfinQt/websocket.h +++ b/core/include/JellyfinQt/websocket.h @@ -33,9 +33,12 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include "apiclient.h" +Q_DECLARE_LOGGING_CATEGORY(jellyfinWebSocket); + namespace Jellyfin { class ApiClient; + namespace DTO { class UserItemDataDto; using UserData = UserItemDataDto; diff --git a/core/src/apiclient.cpp b/core/src/apiclient.cpp index e3a72a3..47ac242 100644 --- a/core/src/apiclient.cpp +++ b/core/src/apiclient.cpp @@ -465,7 +465,6 @@ void ApiClient::onUserDataChanged(const QString &itemId, UserData *userData) { void ApiClient::setAuthenticated(bool authenticated) { Q_D(ApiClient); d->authenticated = authenticated; - if (authenticated) d->webSocket->open(); emit authenticatedChanged(authenticated); } diff --git a/core/src/jellyfin.cpp b/core/src/jellyfin.cpp index 3b4478a..4fcb3f3 100644 --- a/core/src/jellyfin.cpp +++ b/core/src/jellyfin.cpp @@ -27,6 +27,7 @@ #include "JellyfinQt/apiclient.h" #include "JellyfinQt/apimodel.h" +#include "JellyfinQt/eventbus.h" #include "JellyfinQt/serverdiscoverymodel.h" #include "JellyfinQt/websocket.h" #include "JellyfinQt/viewmodel/item.h" diff --git a/core/src/support/jsonconv.cpp b/core/src/support/jsonconv.cpp index 9710b49..349be26 100644 --- a/core/src/support/jsonconv.cpp +++ b/core/src/support/jsonconv.cpp @@ -28,7 +28,7 @@ QString uuidToString(const QUuid &source) { QString str = source.toString(); // Convert {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} (length: 38) // to xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (lenght: 32) - return QString(str.mid(1, 8) + str.mid(10, 4) + str.mid(15, 4) + str.mid(20, 4) + str.mid(25 + 12)); + return QString(str.mid(1, 8) + str.mid(10, 4) + str.mid(15, 4) + str.mid(20, 4) + str.mid(25, 12)); } QUuid stringToUuid(const QString &source) { if (source.size() != 32) throw ParseException("Error while trying to parse JSON value as QUid: invalid length"); diff --git a/core/src/websocket.cpp b/core/src/websocket.cpp index ea4e119..769ff6a 100644 --- a/core/src/websocket.cpp +++ b/core/src/websocket.cpp @@ -23,7 +23,10 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include #include +Q_LOGGING_CATEGORY(jellyfinWebSocket, "jellyfin.websocket"); + namespace Jellyfin { + WebSocket::WebSocket(ApiClient *client) : QObject (client), m_apiClient(client){ connect(&m_webSocket, &QWebSocket::connected, this, &WebSocket::onConnected); @@ -31,11 +34,17 @@ WebSocket::WebSocket(ApiClient *client) connect(&m_webSocket, static_cast(&QWebSocket::error), this, [this](QAbstractSocket::SocketError error) { Q_UNUSED(error) - qDebug() << "Connection error: " << m_webSocket.errorString(); + qCDebug(jellyfinWebSocket) << "Connection error: " << m_webSocket.errorString(); }); connect(&m_webSocket, &QWebSocket::stateChanged, this, &WebSocket::onWebsocketStateChanged); connect(&m_keepAliveTimer, &QTimer::timeout, this, &WebSocket::sendKeepAlive); connect(&m_retryTimer, &QTimer::timeout, this, &WebSocket::open); + connect(client, &ApiClient::authenticatedChanged, this, [this](bool isAuthenticated) { + if (isAuthenticated) { + this->m_reconnectAttempt = 0; + this->open(); + } + }); } void WebSocket::open() { @@ -48,7 +57,7 @@ void WebSocket::open() { connectionUrl.setQuery(query); m_webSocket.open(connectionUrl); m_reconnectAttempt++; - qDebug() << "Opening WebSocket connection to " << m_webSocket.requestUrl() << ", connect attempt " << m_reconnectAttempt; + qCDebug(jellyfinWebSocket) << "Opening WebSocket connection to " << m_webSocket.requestUrl() << ", connect attempt " << m_reconnectAttempt; } void WebSocket::onConnected() { @@ -66,25 +75,47 @@ void WebSocket::onDisconnected() { } void WebSocket::textMessageReceived(const QString &message) { - qDebug() << "WebSocket: message received: " << message; + qCDebug(jellyfinWebSocket) << "message received: " << message; QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8()); if (doc.isNull() || !doc.isObject()) { - qWarning() << "Malformed message received over WebSocket: parse error or root not an object."; + qCWarning(jellyfinWebSocket()) << "Malformed message received over WebSocket: parse error or root not an object."; return; } QJsonObject messageRoot = doc.object(); if (!messageRoot.contains("MessageType")) { - qWarning() << "Malformed message received over WebSocket: no MessageType set."; + qCWarning(jellyfinWebSocket) << "Malformed message received over WebSocket: no MessageType set."; return; } // Convert the type so we can use it in our enums. - QString messageTypeStr = messageRoot["MessageType"].toString(); + QString messageType = messageRoot["MessageType"].toString(); QJsonValue data = messageRoot["Data"]; - if (messageTypeStr == QStringLiteral("ForceKeepAlive")) { + if (messageType == QStringLiteral("ForceKeepAlive")) { setupKeepAlive(data.toInt()); + } else if (messageType == QStringLiteral("GeneralCommand")) { + try { + DTO::GeneralCommand command = DTO::GeneralCommand::fromJson(messageRoot["Data"].toObject()); + + // TODO: move command handling out of here + switch(command.name()) { + case DTO::GeneralCommandType::DisplayMessage: + { + QString header = command.arguments()["Header"].toString("Message from server"); + QString text = command.arguments()["Text"].toString(""); + int timeout = command.arguments()["TimeoutMs"].toInt(-1); + emit m_apiClient->eventbus()->displayMessage(header, text, timeout); + } + break; + default: + qCDebug(jellyfinWebSocket) << "Unhandled command: " << messageRoot["Data"]; + break; + } + + } catch(QException &e) { + qCWarning(jellyfinWebSocket()) << "Error while deserializing command: " << e.what(); + } } else { - qDebug() << messageTypeStr; + qCDebug(jellyfinWebSocket) << messageType; } bool ok; /*MessageType messageType = static_cast(QMetaEnum::fromType().keyToValue(messageTypeStr.toLatin1(), &ok)); @@ -145,6 +176,6 @@ void WebSocket::sendMessage(MessageType type, QJsonValue data) { root["Data"] = data; QString message = QJsonDocument(root).toJson(QJsonDocument::Compact); m_webSocket.sendTextMessage(message); - qDebug() << "Sent message: " << message; + qCDebug(jellyfinWebSocket) << "Sent message: " << message; } } diff --git a/qtquick/qml.qrc b/qtquick/qml.qrc index 8b7e0a5..8c6f1a1 100644 --- a/qtquick/qml.qrc +++ b/qtquick/qml.qrc @@ -18,5 +18,7 @@ qml/pages/DetailPage.qml qml/ApiClient.qml qml/qmldir + qml/components/NotificationList.qml + qml/components/Notification.qml diff --git a/qtquick/qml/components/Notification.qml b/qtquick/qml/components/Notification.qml new file mode 100644 index 0000000..0e80037 --- /dev/null +++ b/qtquick/qml/components/Notification.qml @@ -0,0 +1,28 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.6 + +Rectangle { + property alias title: titleText.text + property alias content: contentText.text + color: "black" + implicitHeight: column.height + implicitWidth: column.width + + Column { + id: column + width: parent.width + + Text { + id: titleText + width: parent.width + font.pointSize: 24 + color: "white" + } + + Text { + id: contentText + width: parent.width + color: "white" + } + } +} diff --git a/qtquick/qml/components/NotificationList.qml b/qtquick/qml/components/NotificationList.qml new file mode 100644 index 0000000..cd32764 --- /dev/null +++ b/qtquick/qml/components/NotificationList.qml @@ -0,0 +1,49 @@ +import QtQuick 2.6 + +Column { + id: notifList + ListModel { + id: notificationModel + ListElement { + title: "Test notification" + text: "Foo bar\n2000" + timeout: 10000 + } + } + + Repeater { + model: notificationModel + Notification { + id: notify + title: model.title + content: model.text + width: notifList.width + + Timer { + id: timer + interval: model.timeout + onTriggered: notificationModel.remove(index, 1) + } + Component.onCompleted: timer.start() + } + } + + add: Transition { + NumberAnimation { targets: ViewTransition.targetItems; properties: "opacity"; from: 0.0; to: 1.0; } + } + move: Transition { + NumberAnimation { + targets: ViewTransition.targetItems; + properties: "x"; + } + NumberAnimation { + targets: ViewTransition.targetItems; + properties: "y"; + } + } + + function addNotification(title, text, timeout) { + var realTimeout = Number(timeout) >= 0 ? Number(timeout) : 5000 + notificationModel.append({"title": title, "text": text, "timeout": realTimeout}); + } +} diff --git a/qtquick/qml/main.qml b/qtquick/qml/main.qml index 474d9eb..e58f26a 100644 --- a/qtquick/qml/main.qml +++ b/qtquick/qml/main.qml @@ -53,6 +53,24 @@ ApplicationWindow { Keys.onEscapePressed: pop() } + NotificationList { + id: notifList + anchors { + right: parent.right + bottom: parent.bottom + } + width: Math.min(parent.width, 400) + height: parent.height + + Connections { + target: ApiClient.eventbus + onDisplayMessage: { + console.log("Displaying message: ", header, ": ", message) + notifList.addNotification(header, message, timeout) + } + } + } + Connections { target: ApiClient onSetupRequired: { pageStack.replace(Qt.resolvedUrl("pages/setup/ServerSelectPage.qml")); } diff --git a/qtquick/src/main.cpp b/qtquick/src/main.cpp index ca27cc7..b2e53a5 100644 --- a/qtquick/src/main.cpp +++ b/qtquick/src/main.cpp @@ -18,6 +18,8 @@ int main(int argc, char** argv) { app.setOrganizationDomain("nl.netsoj.chris"); app.setOrganizationName("Chris Josten"); + qSetMessagePattern("[%{time yyyyMMdd h:mm:ss.zzz} %{if-debug}D%{endif}%{if-info}I%{endif}%{if-warning}W%{endif}%{if-critical}C%{endif}%{if-fatal}F%{endif}] %{if-category}<%{category}> %{endif} %{message}"); + #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) // Disable Qt nagging about "implicitly defined onFoo properties in connections are deprecated", // as we cannot yet move towards a newer version. diff --git a/sailfish/qml/harbour-sailfin.qml b/sailfish/qml/harbour-sailfin.qml index 7376d28..1b2e34c 100644 --- a/sailfish/qml/harbour-sailfin.qml +++ b/sailfish/qml/harbour-sailfin.qml @@ -55,7 +55,7 @@ ApplicationWindow { PlatformMediaControl { playbackManager: appWindow.playbackManager - canQuit: fasle + canQuit: false desktopFile: "harbour-sailfin" playerName: "Sailfin" canRaise: true @@ -100,6 +100,23 @@ ApplicationWindow { } } + Notification { + id: serverNotification + //: The application name for the notification + appName: qsTr("Sailfin") + appIcon: "harbour-sailfin" + isTransient: true + } + + Connections { + target: apiClient.eventbus + onDisplayMessage: { + serverNotification.summary = header + serverNotification.body = message + serverNotification.publish() + } + } + PlaybackManager { id: _playbackManager apiClient: appWindow.apiClient