1
0
Fork 0
mirror of https://github.com/HenkKalkwater/harbour-sailfin.git synced 2024-05-18 12:02:41 +00:00

Models get updated when userData changes at server

The websocket now notifies the ApiClient, on which several models and
items are listening, when the userData for an user has changed. The UI
on the qml side may automatically updates without any extra effort.

This also resolves a bug where videos didn't resume after +/- 3:40 due
to an integer overflow.
This commit is contained in:
Chris Josten 2020-10-09 02:33:08 +02:00
parent 1e80ceb697
commit d81fa50715
17 changed files with 304 additions and 44 deletions

View file

@ -127,4 +127,4 @@ private:
QSettings m_settings; QSettings m_settings;
}; };
#endif #endif // CREDENTIAL_MANAGER_H

View file

@ -38,6 +38,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include "credentialmanager.h" #include "credentialmanager.h"
#include "jellyfindeviceprofile.h" #include "jellyfindeviceprofile.h"
#include "jellyfinitem.h"
#include "jellyfinwebsocket.h" #include "jellyfinwebsocket.h"
namespace Jellyfin { namespace Jellyfin {
@ -144,6 +145,15 @@ signals:
void itemFetched(const QString &itemId, const QJsonObject &result); void itemFetched(const QString &itemId, const QJsonObject &result);
void itemFetchFailed(const QString &itemId, const QNetworkReply::NetworkError error); void itemFetchFailed(const QString &itemId, const QNetworkReply::NetworkError error);
/**
* @brief onUserDataChanged Emitted when the user data of an item is changed on the server.
* @param itemId The id of the item being changed
* @param userData The new user data
*
* Note: only Jellyfin::UserData should connect to this signal, they will update themselves!
*/
void userDataChanged(const QString &itemId, QSharedPointer<UserData> userData);
public slots: public slots:
/** /**
* @brief Tries to access credentials and connect to a server. If nothing has been configured yet, * @brief Tries to access credentials and connect to a server. If nothing has been configured yet,
@ -172,6 +182,7 @@ public slots:
protected slots: protected slots:
void defaultNetworkErrorHandler(QNetworkReply::NetworkError error); void defaultNetworkErrorHandler(QNetworkReply::NetworkError error);
void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> newData);
protected: protected:
/** /**

View file

@ -129,7 +129,7 @@ public:
* responseHasRecords should be true * responseHasRecords should be true
*/ */
explicit ApiModel(QString path, bool responseHasRecords, bool passUserId = false, QObject *parent = nullptr); explicit ApiModel(QString path, bool responseHasRecords, bool passUserId = false, QObject *parent = nullptr);
Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient NOTIFY apiClientChanged)
Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged) Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged)
// Query properties // Query properties
@ -171,6 +171,7 @@ public:
} }
signals: signals:
void apiClientChanged(ApiClient *newApiClient);
void statusChanged(ModelStatus newStatus); void statusChanged(ModelStatus newStatus);
void limitChanged(int newLimit); void limitChanged(int newLimit);
void parentIdChanged(QString newParentId); void parentIdChanged(QString newParentId);
@ -242,6 +243,9 @@ private:
*/ */
void generateFields(); void generateFields();
QString sortByToString(SortOptions::SortBy sortBy); QString sortByToString(SortOptions::SortBy sortBy);
void convertToCamelCase(QJsonValueRef val);
QString convertToCamelCaseHelper(const QString &str);
}; };
/** /**
@ -253,40 +257,53 @@ public:
: ApiModel ("/users/public", false, false, parent) { } : ApiModel ("/users/public", false, false, parent) { }
}; };
/**
* @brief Base class for each model that works with items.
*
* Listens for updates in the library and updates the model accordingly.
*/
class ItemModel : public ApiModel {
Q_OBJECT
public:
explicit ItemModel (QString path, bool responseHasRecords, bool replaceUser, QObject *parent = nullptr);
public slots:
void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData);
};
class UserViewModel : public ApiModel { class UserViewModel : public ApiModel {
public: public:
explicit UserViewModel (QObject *parent = nullptr) explicit UserViewModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Views", true, false, parent) {} : ApiModel ("/Users/{{user}}/Views", true, false, parent) {}
}; };
class UserItemModel : public ApiModel { class UserItemModel : public ItemModel {
public: public:
explicit UserItemModel (QObject *parent = nullptr) explicit UserItemModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Items", true, false, parent) {} : ItemModel ("/Users/{{user}}/Items", true, false, parent) {}
}; };
class UserItemResumeModel : public ApiModel { class UserItemResumeModel : public ItemModel {
public: public:
explicit UserItemResumeModel (QObject *parent = nullptr) explicit UserItemResumeModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Items/Resume", true, false, parent) {} : ItemModel ("/Users/{{user}}/Items/Resume", true, false, parent) {}
}; };
class UserItemLatestModel : public ApiModel { class UserItemLatestModel : public ItemModel {
public: public:
explicit UserItemLatestModel (QObject *parent = nullptr) explicit UserItemLatestModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Items/Latest", false, false, parent) {} : ItemModel ("/Users/{{user}}/Items/Latest", false, false, parent) {}
}; };
class ShowSeasonsModel : public ApiModel { class ShowSeasonsModel : public ItemModel {
public: public:
explicit ShowSeasonsModel (QObject *parent = nullptr) explicit ShowSeasonsModel (QObject *parent = nullptr)
: ApiModel ("/Shows/{{show}}/Seasons", true, true, parent) {} : ItemModel ("/Shows/{{show}}/Seasons", true, true, parent) {}
}; };
class ShowEpisodesModel : public ApiModel { class ShowEpisodesModel : public ItemModel {
public: public:
explicit ShowEpisodesModel (QObject *parent = nullptr) explicit ShowEpisodesModel (QObject *parent = nullptr)
: ApiModel ("/Shows/{{show}}/Episodes", true, true, parent) {} : ItemModel ("/Shows/{{show}}/Episodes", true, true, parent) {}
}; };

View file

@ -1,5 +1,24 @@
#ifndef JELLYFINITEM_H /*
#define JELLYFINITEM_H Sailfin: a Jellyfin client written using Qt
Copyright (C) 2020 Chris Josten
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifndef JELLYFIN_ITEM_H
#define JELLYFIN_ITEM_H
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
@ -23,7 +42,7 @@
#include "jellyfinapiclient.h" #include "jellyfinapiclient.h"
namespace Jellyfin { namespace Jellyfin {
class ApiClient;
/** /**
* @brief Base class for a serializable object. * @brief Base class for a serializable object.
* *
@ -40,7 +59,7 @@ public:
* @param obj The data to load into this object. * @param obj The data to load into this object.
*/ */
void deserialize(const QJsonObject &obj); void deserialize(const QJsonObject &obj);
QJsonObject serialize() const; QJsonObject serialize(bool capitalize = true) const;
private: private:
QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root); QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root);
QJsonValue variantToJson(const QVariant var) const; QJsonValue variantToJson(const QVariant var) const;
@ -56,7 +75,7 @@ private:
* @param str The string to modify * @param str The string to modify
* @return THe modified string * @return THe modified string
*/ */
static QString toPascalCase(QString str); static QString toPascalCase(QString st);
static const QRegularExpression m_listExpression; static const QRegularExpression m_listExpression;
/** /**
@ -157,6 +176,54 @@ private:
int m_index = -1; int m_index = -1;
}; };
class UserData : public JsonSerializable {
Q_OBJECT
public:
Q_INVOKABLE explicit UserData(QObject *parent = nullptr);
Q_PROPERTY(double playedPercentage READ playedPercentage WRITE setPlayedPercentage RESET resetPlayedPercentage NOTIFY playedPercentageChanged)
Q_PROPERTY(qint64 playbackPositionTicks READ playbackPositionTicks WRITE setPlaybackPositionTicks NOTIFY playbackPositionTicksChanged)
Q_PROPERTY(bool isFavorite READ isFavorite WRITE setIsFavorite NOTIFY isFavoriteChanged)
Q_PROPERTY(bool likes READ likes WRITE setLikes RESET resetLikes NOTIFY likesChanged)
Q_PROPERTY(bool played READ played WRITE setPlayed NOTIFY playedChanged)
Q_PROPERTY(QString itemId READ itemId MEMBER m_itemId);
double playedPercentage() const { return m_playedPercentage.value_or(0.0); }
void setPlayedPercentage(double newPlayedPercentage) { m_playedPercentage = newPlayedPercentage; emit playedPercentageChanged(newPlayedPercentage); }
void resetPlayedPercentage() { m_playedPercentage = std::nullopt; emit playedPercentageChanged(0.0); updateOnServer(); }
qint64 playbackPositionTicks() const { return m_playbackPositionTicks; }
void setPlaybackPositionTicks(qint64 newPlaybackPositionTicks) { m_playbackPositionTicks = newPlaybackPositionTicks; emit playbackPositionTicksChanged(newPlaybackPositionTicks); }
bool played() const { return m_played; }
void setPlayed(bool newPlayed) { m_played = newPlayed; emit playedChanged(newPlayed); updateOnServer(); }
bool likes() const { return m_likes.value_or(false); }
void setLikes(bool newLikes) { m_likes = newLikes; emit likesChanged(newLikes); }
void resetLikes() { m_likes = std::nullopt; emit likesChanged(false); updateOnServer(); }
bool isFavorite() const { return m_isFavorite; }
void setIsFavorite(bool newIsFavorite) { m_isFavorite = newIsFavorite; emit isFavoriteChanged(newIsFavorite); updateOnServer(); }
const QString &itemId() const { return m_itemId; }
signals:
void playedPercentageChanged(double newPlayedPercentage);
void playbackPositionTicksChanged(qint64 playbackPositionTicks);
void isFavoriteChanged(bool newIsFavorite);
void likesChanged(bool newLikes);
void playedChanged(bool newPlayed);
public slots:
void updateOnServer();
void onUpdated(QSharedPointer<UserData> other);
private:
std::optional<double> m_playedPercentage = std::nullopt;
qint64 m_playbackPositionTicks = 0;
bool m_isFavorite = false;
std::optional<bool> m_likes = std::nullopt;
bool m_played;
QString m_itemId;
};
class Item : public RemoteData { class Item : public RemoteData {
Q_OBJECT Q_OBJECT
public: public:
@ -206,6 +273,7 @@ public:
Q_PROPERTY(int indexNumberEnd READ indexNumberEnd WRITE setIndexNumberEnd NOTIFY indexNumberEndChanged) Q_PROPERTY(int indexNumberEnd READ indexNumberEnd WRITE setIndexNumberEnd NOTIFY indexNumberEndChanged)
Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged) Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged)
Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged) Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged)
Q_PROPERTY(UserData *userData MEMBER m_userData NOTIFY userDataChanged)
Q_PROPERTY(QString seriesName MEMBER m_seriesName NOTIFY seriesNameChanged) Q_PROPERTY(QString seriesName MEMBER m_seriesName NOTIFY seriesNameChanged)
Q_PROPERTY(QString seasonName MEMBER m_seasonName NOTIFY seasonNameChanged) Q_PROPERTY(QString seasonName MEMBER m_seasonName NOTIFY seasonNameChanged)
Q_PROPERTY(QList<MediaStream *> __list__mediaStreams MEMBER __list__m_mediaStreams NOTIFY mediaStreamsChanged) Q_PROPERTY(QList<MediaStream *> __list__mediaStreams MEMBER __list__m_mediaStreams NOTIFY mediaStreamsChanged)
@ -283,6 +351,7 @@ signals:
void indexNumberEndChanged(int newIndexNumberEnd); void indexNumberEndChanged(int newIndexNumberEnd);
void isFolderChanged(bool newIsFolder); void isFolderChanged(bool newIsFolder);
void typeChanged(const QString &newType); void typeChanged(const QString &newType);
void userDataChanged(UserData *newUserData);
void seriesNameChanged(const QString &newSeriesName); void seriesNameChanged(const QString &newSeriesName);
void seasonNameChanged(const QString &newSeasonName); void seasonNameChanged(const QString &newSeasonName);
void mediaStreamsChanged(/*const QList<MediaStream *> &newMediaStreams*/); void mediaStreamsChanged(/*const QList<MediaStream *> &newMediaStreams*/);
@ -292,6 +361,7 @@ public slots:
* @brief (Re)loads the item from the Jellyfin server. * @brief (Re)loads the item from the Jellyfin server.
*/ */
void reload() override; void reload() override;
void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData);
protected: protected:
QString m_id; QString m_id;
QString m_name; QString m_name;
@ -327,6 +397,7 @@ protected:
std::optional<int> m_indexNumberEnd = std::nullopt; std::optional<int> m_indexNumberEnd = std::nullopt;
std::optional<bool> m_isFolder = std::nullopt; std::optional<bool> m_isFolder = std::nullopt;
QString m_type; QString m_type;
UserData *m_userData = nullptr;
QString m_seriesName; QString m_seriesName;
QString m_seasonName; QString m_seasonName;
QList<MediaStream *> __list__m_mediaStreams; QList<MediaStream *> __list__m_mediaStreams;
@ -351,4 +422,4 @@ protected:
void registerSerializableJsonTypes(const char* URI); void registerSerializableJsonTypes(const char* URI);
} }
#endif // JELLYFINITEM_H #endif // JELLYFIN_ITEM_H

View file

@ -32,9 +32,17 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <QtWebSockets/QWebSocket> #include <QtWebSockets/QWebSocket>
#include "jellyfinapiclient.h" #include "jellyfinapiclient.h"
#include "jellyfinitem.h"
namespace Jellyfin { namespace Jellyfin {
class ApiClient; class ApiClient;
class UserData;
/**
* @brief Keeps a connection with the Jellyfin server to receive real time updates.
*
* This class will parse these messages and send them to ApiClient, which will emit signals for
* the interested classes.
*/
class WebSocket : public QObject { class WebSocket : public QObject {
Q_OBJECT Q_OBJECT
public: public:
@ -47,7 +55,8 @@ public:
explicit WebSocket(ApiClient *client); explicit WebSocket(ApiClient *client);
enum MessageType { enum MessageType {
ForceKeepAlive, ForceKeepAlive,
KeepAlive KeepAlive,
UserDataChanged
}; };
Q_ENUM(MessageType) Q_ENUM(MessageType)
public slots: public slots:

View file

@ -275,6 +275,10 @@ void ApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) {
rep->deleteLater(); rep->deleteLater();
} }
void ApiClient::onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData) {
userDataChanged(itemId, userData);
}
void ApiClient::setAuthenticated(bool authenticated) { void ApiClient::setAuthenticated(bool authenticated) {
this->m_authenticated = authenticated; this->m_authenticated = authenticated;
if (authenticated) m_webSocket->open(); if (authenticated) m_webSocket->open();

View file

@ -125,6 +125,9 @@ void ApiModel::load(LoadType type) {
case LOAD_MORE: case LOAD_MORE:
this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1); this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1);
// QJsonArray apparently doesn't allow concatenating lists like QList or std::vector // QJsonArray apparently doesn't allow concatenating lists like QList or std::vector
for (auto it = items.begin(); it != items.end(); it++) {
convertToCamelCase(*it);
}
foreach (const QJsonValue &val, items) { foreach (const QJsonValue &val, items) {
m_array.append(val); m_array.append(val);
} }
@ -159,9 +162,11 @@ void ApiModel::generateFields() {
QByteArray keyArr = keyName.toUtf8(); QByteArray keyArr = keyName.toUtf8();
if (!m_roles.values().contains(keyArr)) { if (!m_roles.values().contains(keyArr)) {
m_roles.insert(i++, keyArr); m_roles.insert(i++, keyArr);
//qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
} }
} }
for (auto it = m_array.begin(); it != m_array.end(); it++){
convertToCamelCase(*it);
}
this->endResetModel(); this->endResetModel();
} }
@ -174,8 +179,7 @@ QVariant ApiModel::data(const QModelIndex &index, int role) const {
QJsonObject obj = m_array.at(index.row()).toObject(); QJsonObject obj = m_array.at(index.row()).toObject();
QString key = m_roles[role]; const QString &key = m_roles[role];
key[0] = key[0].toUpper();
if (obj.contains(key)) { if (obj.contains(key)) {
return obj[key].toVariant(); return obj[key].toVariant();
} }
@ -208,6 +212,64 @@ void ApiModel::fetchMore(const QModelIndex &parent) {
void ApiModel::addQueryParameters(QUrlQuery &query) { Q_UNUSED(query)} void ApiModel::addQueryParameters(QUrlQuery &query) { Q_UNUSED(query)}
void ApiModel::convertToCamelCase(QJsonValueRef val) {
switch(val.type()) {
case QJsonValue::Object: {
QJsonObject obj = val.toObject();
for(const QString &key: obj.keys()) {
QJsonValueRef ref = obj[key];
convertToCamelCase(ref);
obj[convertToCamelCaseHelper(key)] = ref;
obj.remove(key);
}
val = obj;
break;
}
case QJsonValue::Array: {
QJsonArray arr = val.toArray();
for (auto it = arr.begin(); it != arr.end(); it++) {
convertToCamelCase(*it);
}
val = arr;
break;
}
default:
break;
}
}
QString ApiModel::convertToCamelCaseHelper(const QString &str) {
QString res(str);
res[0] = res[0].toLower();
return res;
}
// Itemmodel
ItemModel::ItemModel(QString path, bool hasRecordFields, bool replaceUser, QObject *parent)
: ApiModel (path, hasRecordFields, replaceUser, parent){
connect(this, &ApiModel::apiClientChanged, this, [this](ApiClient *newApiClient) {
connect(newApiClient, &ApiClient::userDataChanged, this, &ItemModel::onUserDataChanged);
});
}
void ItemModel::onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData) {
int i = 0;
for (QJsonValueRef val: m_array) {
QJsonObject item = val.toObject();
if (item.contains("id") && item["id"].toString() == itemId) {
if (item.contains("userData")) {
QModelIndex cell = this->index(i);
item["userData"] = userData->serialize(false);
val = item;
this->dataChanged(cell, cell);
}
}
i++;
}
}
void registerModels(const char *URI) { void registerModels(const char *URI) {
qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class"); qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class");

View file

@ -1,3 +1,22 @@
/*
Sailfin: a Jellyfin client written using Qt
Copyright (C) 2020 Chris Josten
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "jellyfinitem.h" #include "jellyfinitem.h"
namespace Jellyfin { namespace Jellyfin {
@ -15,7 +34,6 @@ void JsonSerializable::deserialize(const QJsonObject &jObj) {
if (!prop.isStored()) continue; if (!prop.isStored()) continue;
if (!prop.isWritable()) continue; if (!prop.isWritable()) continue;
qDebug() << toPascalCase(prop.name());
// Hardcoded exception for the property id, since its special inside QML // Hardcoded exception for the property id, since its special inside QML
if (QString(prop.name()) == "jellyfinId" && jObj.contains("Id")) { if (QString(prop.name()) == "jellyfinId" && jObj.contains("Id")) {
QJsonValue val = jObj["Id"]; QJsonValue val = jObj["Id"];
@ -30,17 +48,13 @@ void JsonSerializable::deserialize(const QJsonObject &jObj) {
// the actual QList<SubclassOfQobject *>, so that qml can access the object with its real name. // the actual QList<SubclassOfQobject *>, so that qml can access the object with its real name.
QString realName = toPascalCase(prop.name() + 8); QString realName = toPascalCase(prop.name() + 8);
if (!jObj.contains(realName)) { if (!jObj.contains(realName)) {
qDebug() << "Ignoring " << realName << " - " << prop.name();
continue; continue;
} }
QJsonValue val = jObj[realName]; QJsonValue val = jObj[realName];
qDebug() << realName << " - " << prop.name() << ": " << val;
QMetaProperty realProp = obj->property(obj->indexOfProperty(prop.name() + 8)); QMetaProperty realProp = obj->property(obj->indexOfProperty(prop.name() + 8));
if (!realProp.write(this, jsonToVariant(prop, val, jObj))) { if (!realProp.write(this, jsonToVariant(prop, val, jObj))) {
qDebug() << "Write to " << prop.name() << "failed"; qDebug() << "Write to " << prop.name() << "failed";
}; };
} else {
qDebug() << "Ignored " << prop.name() << " while deserializing";
} }
} }
} }
@ -51,19 +65,24 @@ QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &v
case QJsonValue::Undefined: case QJsonValue::Undefined:
return QVariant(); return QVariant();
case QJsonValue::Bool: case QJsonValue::Bool:
case QJsonValue::Double:
case QJsonValue::String: case QJsonValue::String:
return val.toVariant(); return val.toVariant();
case QJsonValue::Double:
if (prop.type() == QVariant::LongLong) {
return static_cast<qint64>(val.toDouble(-1));
} if (prop.type() == QVariant::Int) {
return val.toInt();
} else {
return val.toDouble();
}
case QJsonValue::Array: case QJsonValue::Array:
{ {
QJsonArray arr = val.toArray(); QJsonArray arr = val.toArray();
QVariantList varArr; QVariantList varArr;
for (auto it = arr.constBegin(); it < arr.constEnd(); it++) { for (auto it = arr.constBegin(); it < arr.constEnd(); it++) {
QVariant variant = jsonToVariant(prop, *it, root); QVariant variant = jsonToVariant(prop, *it, root);
qDebug() << variant;
varArr.append(variant); varArr.append(variant);
} }
qDebug() << prop.name() << ": " << varArr.count();
return QVariant(varArr); return QVariant(varArr);
} }
case QJsonValue::Object: case QJsonValue::Object:
@ -104,7 +123,7 @@ QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &v
return QVariant(); return QVariant();
} }
QJsonObject JsonSerializable::serialize() const { QJsonObject JsonSerializable::serialize(bool capitalize) const {
QJsonObject result; QJsonObject result;
const QMetaObject *obj = this->metaObject(); const QMetaObject *obj = this->metaObject();
for (int i = 0; i < obj->propertyCount(); i++) { for (int i = 0; i < obj->propertyCount(); i++) {
@ -112,7 +131,7 @@ QJsonObject JsonSerializable::serialize() const {
if (QString(prop.name()) == "jellyfinId") { if (QString(prop.name()) == "jellyfinId") {
result["Id"] = variantToJson(prop.read(this)); result["Id"] = variantToJson(prop.read(this));
} else { } else {
result[toPascalCase(prop.name())] = variantToJson(prop.read(this)); result[capitalize ? toPascalCase(prop.name()) : prop.name()] = variantToJson(prop.read(this));
} }
} }
return result; return result;
@ -206,9 +225,47 @@ bool MediaStream::operator==(const MediaStream &other) {
&& m_index == other.m_index; && m_index == other.m_index;
} }
// UserData
UserData::UserData(QObject *parent) : JsonSerializable (parent) {}
void UserData::updateOnServer() {
//TODO: implement
}
void UserData::onUpdated(QSharedPointer<UserData> other) {
// The reason I'm not using setLikes and similar is that they don't work with std::nullopt,
// since QML does not like it.
// THe other reason is that the setLikes method will send a post request to the server, to update the contents
// we don't want that to happen, obviously, since the application could end in an infinite loop.
if (this->m_playedPercentage != other->m_playedPercentage) {
this->m_playedPercentage = other->m_playedPercentage;
emit playedPercentageChanged(playedPercentage());
}
if (m_playbackPositionTicks!= other->m_playbackPositionTicks) {
this->m_playbackPositionTicks = other->m_playbackPositionTicks;
emit playbackPositionTicksChanged(this->m_playbackPositionTicks);
}
if (m_isFavorite != other->m_isFavorite) {
this->m_isFavorite = other->m_isFavorite;
emit isFavoriteChanged(this->m_isFavorite);
}
if (this->m_likes != other->m_likes) {
this->m_likes = other->m_likes;
emit likesChanged(likes());
}
if (this->m_played != other->m_played) {
this->m_played = other->m_played;
emit playedChanged(this->m_played);
}
}
// Item // Item
Item::Item(QObject *parent) : RemoteData(parent) {} Item::Item(QObject *parent) : RemoteData(parent) {
connect(this, &RemoteData::apiClientChanged, this, [this](ApiClient *newApiClient) {
connect(newApiClient, &ApiClient::userDataChanged, this, &Item::onUserDataChanged);
});
}
void Item::setJellyfinId(QString newId) { void Item::setJellyfinId(QString newId) {
@ -256,8 +313,14 @@ void Item::reload() {
}); });
} }
void Item::onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData) {
if (itemId != m_id || m_userData == nullptr) return;
m_userData->onUpdated(userData);
}
void registerSerializableJsonTypes(const char* URI) { void registerSerializableJsonTypes(const char* URI) {
qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream"); qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream");
qmlRegisterType<UserData>(URI, 1, 0, "UserData");
qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem"); qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem");
} }
} }

View file

@ -69,6 +69,9 @@ void WebSocket::textMessageReceived(const QString &message) {
MessageType messageType = static_cast<MessageType>(QMetaEnum::fromType<WebSocket::MessageType>().keyToValue(messageTypeStr.toLatin1(), &ok)); MessageType messageType = static_cast<MessageType>(QMetaEnum::fromType<WebSocket::MessageType>().keyToValue(messageTypeStr.toLatin1(), &ok));
if (!ok) { if (!ok) {
qWarning() << "Unknown message arrived: " << messageTypeStr; qWarning() << "Unknown message arrived: " << messageTypeStr;
if (messageRoot.contains("Data")) {
qDebug() << "with data: " << QJsonDocument(messageRoot["Data"].toObject()).toJson();
}
return; return;
} }
@ -82,6 +85,21 @@ void WebSocket::textMessageReceived(const QString &message) {
case KeepAlive: case KeepAlive:
//TODO: do something? //TODO: do something?
break; break;
case UserDataChanged: {
QJsonObject data2 = data.toObject();
if (data2["UserId"] != m_apiClient->userId()) {
qDebug() << "Received UserDataCHanged for other user";
break;
}
QJsonArray userDataList = data2["UserDataList"].toArray();
for (QJsonValue val: userDataList) {
QSharedPointer<UserData> userData(new UserData, &QObject::deleteLater);
userData->deserialize(val.toObject());
m_apiClient->onUserDataChanged(userData->itemId(), userData);
}
}
break;
} }
} }

View file

@ -86,5 +86,6 @@ BackgroundItem {
height: Theme.paddingSmall height: Theme.paddingSmall
color: Theme.highlightColor color: Theme.highlightColor
width: root.progress * parent.width width: root.progress * parent.width
Behavior on width { SmoothedAnimation {} }
} }
} }

View file

@ -24,6 +24,7 @@ Column {
property alias imageSource : playImage.source property alias imageSource : playImage.source
property real imageAspectRatio: 1.0 property real imageAspectRatio: 1.0
property real playProgress: 0.0 property real playProgress: 0.0
property bool favourited: false
signal playPressed(bool startFromBeginning) signal playPressed(bool startFromBeginning)
spacing: Theme.paddingLarge spacing: Theme.paddingLarge
@ -73,7 +74,8 @@ Column {
} }
IconButton { IconButton {
id: favouriteButton id: favouriteButton
icon.source: "image://theme/icon-m-favorite" icon.source: favourited ? "image://theme/icon-m-favorite-selected"
: "image://theme/icon-m-favorite"
} }
} }

View file

@ -39,7 +39,7 @@ SilicaItem {
readonly property bool hudVisible: !hud.hidden || player.error !== MediaPlayer.NoError readonly property bool hudVisible: !hud.hidden || player.error !== MediaPlayer.NoError
property alias audioTrack: mediaSource.audioIndex property alias audioTrack: mediaSource.audioIndex
property alias subtitleTrack: mediaSource.subtitleIndex property alias subtitleTrack: mediaSource.subtitleIndex
property int startTicks: 0 property real startTicks: 0
// Force a Light on Dark theme since I doubt that there are persons who are willing to watch a Video // Force a Light on Dark theme since I doubt that there are persons who are willing to watch a Video
// on a white background. // on a white background.

View file

@ -198,12 +198,12 @@ Page {
delegate: LibraryItemDelegate { delegate: LibraryItemDelegate {
property string id: model.id property string id: model.id
title: model.name title: model.name
poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["primary"], "Primary", {"maxHeight": height})
/*model.imageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + model.id /*model.imageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + model.id
+ "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"]
: ""*/ : ""*/
landscape: !Utils.usePortraitCover(collectionType) landscape: !Utils.usePortraitCover(collectionType)
progress: (typeof model.userData !== "undefined") ? model.userData.PlayedPercentage / 100 : 0.0 progress: (typeof model.userData !== "undefined") ? model.userData.playedPercentage / 100 : 0.0
onClicked: { onClicked: {
pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id}) pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id})

View file

@ -33,7 +33,7 @@ Page {
property var itemData property var itemData
property int audioTrack property int audioTrack
property int subtitleTrack property int subtitleTrack
property int startTicks: 0 property real startTicks: 0 // Why is this a real? Because an integer only goes to 3:44 when the ticks are converted to doubles
allowedOrientations: Orientation.All allowedOrientations: Orientation.All
showNavigationIndicator: videoPlayer.hudVisible showNavigationIndicator: videoPlayer.hudVisible
@ -43,7 +43,7 @@ Page {
anchors.fill: parent anchors.fill: parent
itemId: videoPage.itemId itemId: videoPage.itemId
player: appWindow.mediaPlayer player: appWindow.mediaPlayer
title: itemData.Name title: itemData.name
audioTrack: videoPage.audioTrack audioTrack: videoPage.audioTrack
subtitleTrack: videoPage.subtitleTrack subtitleTrack: videoPage.subtitleTrack
startTicks: videoPage.startTicks startTicks: videoPage.startTicks

View file

@ -53,7 +53,7 @@ BaseDetailPage {
} }
width: Constants.libraryDelegateWidth width: Constants.libraryDelegateWidth
height: Constants.libraryDelegateHeight height: Constants.libraryDelegateHeight
source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags.primary, "Primary", {"maxHeight": height})
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
clip: true clip: true
@ -78,7 +78,7 @@ BaseDetailPage {
bottom: parent.bottom bottom: parent.bottom
} }
height: Theme.paddingMedium height: Theme.paddingMedium
width: model.userData.PlayedPercentage * parent.width / 100 width: model.userData.playedPercentage * parent.width / 100
color: Theme.highlightColor color: Theme.highlightColor
} }
} }

View file

@ -79,7 +79,7 @@ BaseDetailPage {
leftMargin: Theme.horizontalPageMargin leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin rightMargin: Theme.horizontalPageMargin
delegate: LibraryItemDelegate { delegate: LibraryItemDelegate {
poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags.primary, "Primary", {"maxHeight": height})
title: model.name title: model.name
onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id}) onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id})
} }

View file

@ -32,6 +32,7 @@ import "../.."
BaseDetailPage { BaseDetailPage {
property alias subtitle: pageHeader.description property alias subtitle: pageHeader.description
default property alias _data: content.data default property alias _data: content.data
property real _playbackProsition: itemData.userData.playbackPositionTicks
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
contentHeight: content.height + Theme.paddingLarge contentHeight: content.height + Theme.paddingLarge
@ -54,13 +55,14 @@ BaseDetailPage {
width: parent.width width: parent.width
imageSource: Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width}) imageSource: Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width})
imageAspectRatio: Constants.horizontalVideoAspectRatio imageAspectRatio: Constants.horizontalVideoAspectRatio
playProgress: itemData.UserData.PlayedPercentage / 100 favourited: itemData.userData.isFavorite
playProgress: itemData.userData.playedPercentage / 100
onPlayPressed: pageStack.push(Qt.resolvedUrl("../VideoPage.qml"), onPlayPressed: pageStack.push(Qt.resolvedUrl("../VideoPage.qml"),
{"itemId": itemId, "itemData": itemData, {"itemId": itemId, "itemData": itemData,
"audioTrack": trackSelector.audioTrack, "audioTrack": trackSelector.audioTrack,
"subtitleTrack": trackSelector.subtitleTrack, "subtitleTrack": trackSelector.subtitleTrack,
"startTicks": startFromBeginning ? 0.0 "startTicks": startFromBeginning ? 0.0
: itemData.UserData.PlaybackPositionTicks }) : _playbackProsition })
} }
VideoTrackSelector { VideoTrackSelector {