Deserialized a list! Restructured project!

I finally got deserializing lists working. Exposing them to QML was not
a trivial task either. Note that I didn't do it the clean way. Nested
lists are not supported. But it works!

Because I got so frustarted at one point trying to implement things the
right way, I restructured the project to seperate the Sailfish code from
the Qt code and created a new, empty desktop project. The Qt code has
been transformed into a happy little library, to which the Sailfish OS
application links.

Note that QMake doesn't seem to strip the library for some reason.
This commit is contained in:
Chris Josten 2020-10-08 03:00:08 +02:00
parent 4e3395c4e5
commit 1e80ceb697
77 changed files with 507 additions and 213 deletions

32
core/core.pro Normal file
View File

@ -0,0 +1,32 @@
TEMPLATE = lib
QT += qml multimedia network websockets
include(defines.pri)
include(../harbour-sailfin.pri)
SOURCES += \
src/credentialmanager.cpp \
src/jellyfin.cpp \
src/jellyfinapiclient.cpp \
src/jellyfinapimodel.cpp \
src/jellyfindeviceprofile.cpp \
src/jellyfinitem.cpp \
src/jellyfinplaybackmanager.cpp \
src/jellyfinwebsocket.cpp \
src/serverdiscoverymodel.cpp
HEADERS += \
include/credentialmanager.h \
include/jellyfin.h \
include/jellyfinapiclient.h \
include/jellyfinapimodel.h \
include/jellyfindeviceprofile.h \
include/jellyfinitem.h \
include/jellyfinplaybackmanager.h \
include/jellyfinwebsocket.h \
include/serverdiscoverymodel.h
VERSION = $$SAILFIN_VERSION
TARGET = jellyfin-qt
DESTDIR = lib

2
core/defines.pri Normal file
View File

@ -0,0 +1,2 @@
message(Including $$_FILE_ from $$IN_PWD)
INCLUDEPATH += $$IN_PWD/include

16
core/include/jellyfin.h Normal file
View File

@ -0,0 +1,16 @@
#ifndef JELLYFIN_H
#define JELLYFIN_H
#include <QtQml>
#include "jellyfinapiclient.h"
#include "jellyfinapimodel.h"
#include "jellyfinitem.h"
#include "serverdiscoverymodel.h"
#include "jellyfinplaybackmanager.h"
namespace Jellyfin {
void registerTypes();
}
#endif // JELLYFIN_H

View File

@ -26,6 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <QJsonParseError>
#include <QJsonValue>
#include <QHostInfo>
#include <QObject>
#include <QString>
#include <QSysInfo>
@ -68,7 +69,6 @@ class WebSocket;
* These steps might change. I'm considering decoupling CredentialsManager from this class to clean some code up.
*/
class ApiClient : public QObject {
friend class MediaSource;
friend class WebSocket;
Q_OBJECT
public:
@ -105,7 +105,19 @@ public:
QString &userId() { return m_userId; }
QJsonObject &deviceProfile() { return m_deviceProfile; }
QJsonObject &playbackDeviceProfile() { return m_playbackDeviceProfile; }
QString version() const { return QString(m_version); }
QString version() const;
/**
* @brief Sets the error handler of a reply to this classes default error handler
* @param rep The reply to set the error handler on.
*
* Motivation for this helper is because I forget the correct signature each time, with all the
* funky casts.
*/
void setDefaultErrorHandler(QNetworkReply *rep) {
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, &ApiClient::defaultNetworkErrorHandler);
}
signals:
/*
* Emitted when the server requires authentication. Please authenticate your user via authenticate.
@ -193,7 +205,6 @@ protected:
private:
QNetworkAccessManager m_naManager;
const char *m_version = SAILFIN_VERSION;
/*
* State information
*/
@ -238,17 +249,6 @@ private:
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
/**
* @brief Sets the error handler of a reply to this classes default error handler
* @param rep The reply to set the error handler on.
*
* Motivation for this helper is because I forget the correct signature each time, with all the
* funky casts.
*/
void setDefaultErrorHandler(QNetworkReply *rep) {
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, &ApiClient::defaultNetworkErrorHandler);
}
};
} // NS Jellyfin

View File

@ -26,6 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtQml>
#include <QVariant>
#include "jellyfinapiclient.h"

View File

@ -11,13 +11,15 @@
#include <QMetaProperty>
#include <QDateTime>
#include <QObject>
#include <QRegularExpression>
#include <QtQml>
#include <QNetworkReply>
#include <QtQml>
#include <optional>
#include <cmath>
#include "jellyfinapiclient.h"
namespace Jellyfin {
@ -32,6 +34,7 @@ class JsonSerializable : public QObject {
Q_OBJECT
public:
Q_INVOKABLE JsonSerializable(QObject *parent);
/**
* @brief Sets this objects properties based on obj.
* @param obj The data to load into this object.
@ -39,7 +42,7 @@ public:
void deserialize(const QJsonObject &obj);
QJsonObject serialize() const;
private:
QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root) const;
QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root);
QJsonValue variantToJson(const QVariant var) const;
/**
@ -54,8 +57,15 @@ private:
* @return THe modified string
*/
static QString toPascalCase(QString str);
static const QRegularExpression m_listExpression;
/**
* @brief Qt is doing weird. I'll keep track of the metatypes myself.
*/
QHash<QString, const QMetaType *> m_nameMetatypeMap;
};
/**
* @brief An "interface" for a remote data source
*
@ -108,6 +118,45 @@ private:
QString m_errorString;
};
class MediaStream : public JsonSerializable {
Q_OBJECT
public:
Q_INVOKABLE explicit MediaStream(QObject *parent = nullptr);
MediaStream(const MediaStream &other);
bool operator==(const MediaStream &other);
virtual ~MediaStream() { qDebug() << "MediaStream destroyed"; }
enum MediaStreamType {
Undefined,
Audio,
Video,
Subtitle,
EmbeddedImage
};
Q_ENUM(MediaStreamType)
Q_PROPERTY(QString codec MEMBER m_codec NOTIFY codecChanged)
Q_PROPERTY(QString codecTag MEMBER m_codecTag NOTIFY codecTagChanged)
Q_PROPERTY(QString language MEMBER m_language NOTIFY languageChanged)
Q_PROPERTY(QString displayTitle MEMBER m_displayTitle NOTIFY displayTitleChanged)
Q_PROPERTY(MediaStreamType type MEMBER m_type NOTIFY typeChanged)
Q_PROPERTY(int index MEMBER m_index NOTIFY indexChanged)
signals:
void codecChanged(const QString &newCodec);
void codecTagChanged(const QString &newCodecTag);
void languageChanged(const QString &newLanguage);
void displayTitleChanged(const QString &newDisplayTitle);
void typeChanged(MediaStreamType newType);
void indexChanged(int newIndex);
private:
QString m_codec;
QString m_codecTag;
QString m_language;
QString m_displayTitle;
MediaStreamType m_type = Undefined;
int m_index = -1;
};
class Item : public RemoteData {
Q_OBJECT
public:
@ -146,11 +195,21 @@ public:
Q_PROPERTY(QDateTime premiereData MEMBER m_premiereDate NOTIFY premiereDateChanged)
//SKIP: ExternalUrls
//SKIP: MediaSources
Q_PROPERTY(float criticRating READ criticRating WRITE setCriticRating NOTIFY criticRatingChanged)
Q_PROPERTY(QStringList productionLocations MEMBER m_productionLocations NOTIFY productionLocationsChanged)
// Handpicked, important ones
Q_PROPERTY(qint64 runTimeTicks READ runTimeTicks WRITE setRunTimeTicks NOTIFY runTimeTicksChanged)
Q_PROPERTY(QString overview MEMBER m_overview NOTIFY overviewChanged)
Q_PROPERTY(int productionYear READ productionYear WRITE setProductionYear NOTIFY productionYearChanged)
Q_PROPERTY(int indexNumber READ indexNumber WRITE setProductionYear NOTIFY indexNumberChanged)
Q_PROPERTY(int indexNumber READ indexNumber WRITE setIndexNumber NOTIFY indexNumberChanged)
Q_PROPERTY(int indexNumberEnd READ indexNumberEnd WRITE setIndexNumberEnd NOTIFY indexNumberEndChanged)
Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged)
Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged)
Q_PROPERTY(QString seriesName MEMBER m_seriesName NOTIFY seriesNameChanged)
Q_PROPERTY(QString seasonName MEMBER m_seasonName NOTIFY seasonNameChanged)
Q_PROPERTY(QList<MediaStream *> __list__mediaStreams MEMBER __list__m_mediaStreams NOTIFY mediaStreamsChanged)
Q_PROPERTY(QVariantList mediaStreams MEMBER m_mediaStreams NOTIFY mediaStreamsChanged STORED false)
QString jellyfinId() const { return m_id; }
void setJellyfinId(QString newId);
@ -170,12 +229,24 @@ public:
void setHasSubtitles(bool newHasSubtitles) { m_hasSubtitles = newHasSubtitles; emit hasSubtitlesChanged(newHasSubtitles); }
bool supportsSync() const { return m_supportsSync.value_or(false); }
void setSupportsSync(bool newSupportsSync) { m_supportsSync = newSupportsSync; emit supportsSyncChanged(newSupportsSync); }
float criticRating() const { return m_criticRating.value_or(std::nanf("")); }
void setCriticRating(float newCriticRating) { m_criticRating = newCriticRating; emit criticRatingChanged(newCriticRating); }
// Handpicked, important ones
qint64 runTimeTicks() const { return m_runTimeTicks.value_or(-1); }
void setRunTimeTicks(qint64 newRunTimeTicks) { m_runTimeTicks = newRunTimeTicks; emit runTimeTicksChanged(newRunTimeTicks); }
int productionYear() const { return m_productionYear.value_or(-1); }
void setProductionYear(int newProductionYear) { m_productionYear = newProductionYear; emit productionYearChanged(newProductionYear); }
void setProductionYear(int newProductionYear) { m_productionYear = std::optional<int>(newProductionYear); emit productionYearChanged(newProductionYear); }
int indexNumber() const { return m_indexNumber.value_or(-1); }
void setIndexNumber(int newIndexNumber) { m_indexNumber = newIndexNumber; emit indexNumberChanged(newIndexNumber); }
void setIndexNumber(int newIndexNumber) { m_indexNumber = std::optional<int>(newIndexNumber); emit indexNumberChanged(newIndexNumber); }
int indexNumberEnd() const { return m_indexNumberEnd.value_or(-1); }
void setIndexNumberEnd(int newIndexNumberEnd) { m_indexNumberEnd = std::optional<int>(newIndexNumberEnd); emit indexNumberEndChanged(newIndexNumberEnd); }
bool isFolder() const { return m_isFolder.value_or(false); }
void setIsFolder(bool newIsFolder) { m_isFolder = newIsFolder; emit isFolderChanged(newIsFolder); }
//QQmlListProperty<MediaStream> mediaStreams() { return toReadOnlyQmlListProperty<MediaStream>(m_mediaStreams); }
//QList<QObject *> mediaStreams() { return *reinterpret_cast<QList<QObject *> *>(&m_mediaStreams); }
QVariantList mediaStreams() { QVariantList l; for (auto e: m_mediaStreams) l.append(QVariant::fromValue(e)); return l;}
signals:
void jellyfinIdChanged(const QString &newId);
@ -201,11 +272,20 @@ signals:
void sortNameChanged(const QString &newSortName);
void forcedSortNameChanged(const QString &newForcedSortName);
void premiereDateChanged(QDateTime newPremiereDate);
void criticRatingChanged(float newCriticRating);
void productionLocationsChanged(QStringList newProductionLocations);
// Handpicked, important ones
void runTimeTicksChanged(qint64 newRunTimeTicks);
void overviewChanged(const QString &newOverview);
void productionYearChanged(int newProductionYear);
void indexNumberChanged(int newIndexNumber);
void indexNumberEndChanged(int newIndexNumberEnd);
void isFolderChanged(bool newIsFolder);
void typeChanged(const QString &newType);
void seriesNameChanged(const QString &newSeriesName);
void seasonNameChanged(const QString &newSeasonName);
void mediaStreamsChanged(/*const QList<MediaStream *> &newMediaStreams*/);
public slots:
/**
@ -236,11 +316,36 @@ protected:
QString m_sortName;
QString m_forcedSortName;
QDateTime m_premiereDate;
std::optional<float> m_criticRating = std::nullopt;
QStringList m_productionLocations;
// Handpicked, important ones
std::optional<qint64> m_runTimeTicks = std::nullopt;
QString m_overview;
std::optional<int> m_productionYear = std::nullopt;
std::optional<int> m_indexNumber = std::nullopt;
std::optional<int> m_indexNumberEnd = std::nullopt;
std::optional<bool> m_isFolder = std::nullopt;
QString m_type;
QString m_seriesName;
QString m_seasonName;
QList<MediaStream *> __list__m_mediaStreams;
QVariantList m_mediaStreams;
template<typename T>
QQmlListProperty<T> toReadOnlyQmlListProperty(QList<T *> &list) {
return QQmlListProperty<T>(this, std::addressof(list), &qlist_count, &qlist_at);
}
template<typename T>
static int qlist_count(QQmlListProperty<T> *p) {
return reinterpret_cast<QList<T *> *>(p->data)->count();
}
template<typename T>
static T *qlist_at(QQmlListProperty<T> *p, int idx) {
return reinterpret_cast<QList<T *> *>(p->data)->at(idx);
}
};
void registerSerializableJsonTypes(const char* URI);

View File

@ -34,7 +34,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
namespace Jellyfin {
class MediaSource : public QObject {
class PlaybackManager : public QObject {
Q_OBJECT
public:
enum PlayMethod {
@ -44,7 +44,7 @@ public:
};
Q_ENUM(PlayMethod)
explicit MediaSource(QObject *parent = nullptr);
explicit PlaybackManager(QObject *parent = nullptr);
Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient)
Q_PROPERTY(QString itemId READ itemId WRITE setItemId NOTIFY itemIdChanged)
Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY streamUrlChanged)

View File

@ -55,6 +55,7 @@ public slots:
private slots:
void textMessageReceived(const QString &message);
void onConnected();
void onDisconnected();
void sendKeepAlive();
signals:

View File

@ -76,8 +76,8 @@ private:
const QByteArray MAGIC_PACKET = "who is JellyfinServer?";
const quint16 BROADCAST_PORT = 7359;
QUdpSocket m_socket;
std::vector<ServerDiscovery> m_discoveredServers;
QUdpSocket m_socket;
};
}
#endif //SERVER_DISCOVERY_MODEL_H

19
core/src/jellyfin.cpp Normal file
View File

@ -0,0 +1,19 @@
#include "jellyfin.h"
namespace Jellyfin {
void registerTypes() {
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
// Singletons are perhaps bad, but they are convenient :)
qmlRegisterSingletonType<Jellyfin::ApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
Q_UNUSED(eng)
Q_UNUSED(js)
return dynamic_cast<QObject*>(new Jellyfin::ApiClient());
});
qmlRegisterType<Jellyfin::ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
qmlRegisterType<Jellyfin::PlaybackManager>(QML_NAMESPACE, 1, 0, "PlaybackManager");
// API models
Jellyfin::registerModels(QML_NAMESPACE);
Jellyfin::registerSerializableJsonTypes(QML_NAMESPACE);
}
}

View File

@ -20,6 +20,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include "jellyfinapiclient.h"
namespace Jellyfin {
ApiClient::ApiClient(QObject *parent)
: QObject(parent),
m_webSocket(new WebSocket(this)) {
@ -30,6 +31,10 @@ ApiClient::ApiClient(QObject *parent)
generateDeviceProfile();
}
QString ApiClient::version() const {
return QString(SAILFIN_VERSION);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// BASE HTTP METHODS //
////////////////////////////////////////////////////////////////////////////////////////////////////
@ -38,7 +43,7 @@ ApiClient::ApiClient(QObject *parent)
void ApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery &params) {
addTokenHeader(request);
request.setRawHeader("Accept", "application/json;"); // profile=\"CamelCase\"");
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(m_version));
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(version()));
QString url = this->m_baseUrl + path;
if (!params.isEmpty()) url += "?" + params.toString();
request.setUrl(url);
@ -49,7 +54,7 @@ void ApiClient::addTokenHeader(QNetworkRequest &request) {
authentication += "Client=\"Sailfin\"";
authentication += ", Device=\"" + m_deviceName + "\"";
authentication += ", DeviceId=\"" + m_deviceId + "\"";
authentication += ", Version=\"" + QString(m_version) + "\"";
authentication += ", Version=\"" + version() + "\"";
if (m_authenticated) {
authentication += ", token=\"" + m_token + "\"";
}

View File

@ -1,7 +1,9 @@
#include "jellyfinitem.h"
namespace Jellyfin {
JsonSerializable::JsonSerializable(QObject *parent) : QObject(parent) {}
const QRegularExpression JsonSerializable::m_listExpression = QRegularExpression("^QList<\\s*([a-zA-Z0-9]*)\\s*\\*?\\s*>$");
JsonSerializable::JsonSerializable(QObject *parent) : QObject(parent) {
}
void JsonSerializable::deserialize(const QJsonObject &jObj) {
const QMetaObject *obj = this->metaObject();
@ -21,13 +23,29 @@ void JsonSerializable::deserialize(const QJsonObject &jObj) {
} else if (jObj.contains(toPascalCase(prop.name()))) {
QJsonValue val = jObj[toPascalCase(prop.name())];
prop.write(this, jsonToVariant(prop, val, jObj));
} else if (QString(prop.name()).startsWith("__list__")) {
// QML doesn't like it if we expose properties of type QList<SubclassOfQobject *>
// we need to explicitly cast them to QList<QObject *> or QQmlListProperty, which sucks.
// That's why we have a special case for properties starting with __list__. This contains
// the actual QList<SubclassOfQobject *>, so that qml can access the object with its real name.
QString realName = toPascalCase(prop.name() + 8);
if (!jObj.contains(realName)) {
qDebug() << "Ignoring " << realName << " - " << prop.name();
continue;
}
QJsonValue val = jObj[realName];
qDebug() << realName << " - " << prop.name() << ": " << val;
QMetaProperty realProp = obj->property(obj->indexOfProperty(prop.name() + 8));
if (!realProp.write(this, jsonToVariant(prop, val, jObj))) {
qDebug() << "Write to " << prop.name() << "failed";
};
} else {
qDebug() << "Ignored " << prop.name() << " while deserializing";
}
}
}
QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root) const {
QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root) {
switch(val.type()) {
case QJsonValue::Null:
case QJsonValue::Undefined:
@ -37,25 +55,48 @@ QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &v
case QJsonValue::String:
return val.toVariant();
case QJsonValue::Array:
if (prop.type() == QVariant::List) {
{
QJsonArray arr = val.toArray();
QVariantList varArr;
for (auto it = arr.begin(); it < arr.end(); it++) {
varArr << jsonToVariant(prop, *it, root);
for (auto it = arr.constBegin(); it < arr.constEnd(); it++) {
QVariant variant = jsonToVariant(prop, *it, root);
qDebug() << variant;
varArr.append(variant);
}
qDebug() << prop.name() << ": " << varArr.count();
return QVariant(varArr);
} else {
qDebug() << prop.name() << " is not a " << prop.typeName();
return QVariant();
}
case QJsonValue::Object:
QJsonObject innerObj = val.toObject();
QObject *deserializedInnerObj = QMetaType::metaObjectForType(prop.userType())->newInstance();
int typeNo = prop.userType();
const QMetaObject *metaType = QMetaType::metaObjectForType(prop.userType());
if (metaType == nullptr) {
// Try to determine if the type is a qlist
QRegularExpressionMatch match = m_listExpression.match(prop.typeName());
if (match.hasMatch()) {
// It is a qList! Now extract the inner type
// There should be an easier way, shouldn't there?
QString listType = match.captured(1).prepend("Jellyfin::").append("*");
// UGLY CODE HERE WE COME
typeNo = QMetaType::type(listType.toUtf8());
if (typeNo == QMetaType::UnknownType) {
qDebug() << "Unknown type: " << listType;
return QVariant();
}
metaType = QMetaType::metaObjectForType(typeNo);
} else {
qDebug() << "No metaObject for " << prop.typeName() << ", " << prop.type() << ", " << prop.userType();
return QVariant();
}
}
QObject *deserializedInnerObj = metaType->newInstance();
deserializedInnerObj->setParent(this);
if (JsonSerializable *ser = dynamic_cast<JsonSerializable *>(deserializedInnerObj)) {
qDebug() << "Deserializing user type " << deserializedInnerObj->metaObject()->className();
ser->deserialize(innerObj);
return QVariant::fromValue(ser);
return QVariant(typeNo, &ser);
} else {
deserializedInnerObj->deleteLater();
qDebug() << "Object is not a serializable one!";
return QVariant();
}
@ -146,10 +187,28 @@ void RemoteData::setApiClient(ApiClient *newApiClient) {
reload();
}
// MediaStream
MediaStream::MediaStream(QObject *parent) : JsonSerializable (parent) {}
MediaStream::MediaStream(const MediaStream &other)
: JsonSerializable (other.parent()),
m_codec(other.m_codec),
m_codecTag(other.m_codecTag),
m_language(other.m_language),
m_displayTitle(other.m_displayTitle),
m_type(other.m_type),
m_index(other.m_index){
}
bool MediaStream::operator==(const MediaStream &other) {
// displayTitle is explicitly left out, since it's generated based on other properties
// in the Jellyfin source code.
return m_codec == other.m_codec && m_codecTag == other.m_codecTag
&& m_language == other.m_language && m_type == other.m_type
&& m_index == other.m_index;
}
// Item
Item::Item(QObject *parent) : RemoteData(parent) {
}
Item::Item(QObject *parent) : RemoteData(parent) {}
void Item::setJellyfinId(QString newId) {
@ -172,7 +231,9 @@ void Item::reload() {
rep->deleteLater();
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll(), &error);
QString data(rep->readAll());
data = data.normalized(QString::NormalizationForm_D);
QJsonDocument doc = QJsonDocument::fromJson(data.toUtf8(), &error);
if (doc.isNull()) {
this->setError(QNetworkReply::ProtocolFailure);
this->setErrorString(error.errorString());
@ -196,6 +257,7 @@ void Item::reload() {
}
void registerSerializableJsonTypes(const char* URI) {
qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream");
qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem");
}
}

View File

@ -17,18 +17,18 @@ 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 "jellyfinmediasource.h"
#include "jellyfinplaybackmanager.h"
namespace Jellyfin {
MediaSource::MediaSource(QObject *parent)
PlaybackManager::PlaybackManager(QObject *parent)
: QObject(parent) {
m_updateTimer.setInterval(10000); // 10 seconds
m_updateTimer.setSingleShot(false);
connect(&m_updateTimer, &QTimer::timeout, this, &MediaSource::updatePlaybackInfo);
connect(&m_updateTimer, &QTimer::timeout, this, &PlaybackManager::updatePlaybackInfo);
}
void MediaSource::fetchStreamUrl() {
void PlaybackManager::fetchStreamUrl() {
QUrlQuery params;
params.addQueryItem("UserId", m_apiClient->userId());
params.addQueryItem("StartTimeTicks", QString::number(m_position));
@ -62,7 +62,7 @@ void MediaSource::fetchStreamUrl() {
});
}
void MediaSource::setItemId(const QString &newItemId) {
void PlaybackManager::setItemId(const QString &newItemId) {
if (m_apiClient == nullptr) {
qWarning() << "apiClient is not set on this MediaSource instance! Aborting.";
return;
@ -76,12 +76,12 @@ void MediaSource::setItemId(const QString &newItemId) {
}
}
void MediaSource::setStreamUrl(const QString &streamUrl) {
void PlaybackManager::setStreamUrl(const QString &streamUrl) {
this->m_streamUrl = streamUrl;
emit streamUrlChanged(streamUrl);
}
void MediaSource::setPosition(qint64 position) {
void PlaybackManager::setPosition(qint64 position) {
if (position == 0 && m_position != 0) {
// Save the old position when stop gets called. The QMediaPlayer will try to set
// position to 0 when stopped, but we don't want to report that to Jellyfin. We
@ -92,7 +92,7 @@ void MediaSource::setPosition(qint64 position) {
emit positionChanged(position);
}
void MediaSource::setState(QMediaPlayer::State newState) {
void PlaybackManager::setState(QMediaPlayer::State newState) {
if (m_state == newState) return;
if (m_state == QMediaPlayer::StoppedState) {
// We're transitioning from stopped to either playing or paused.
@ -112,11 +112,11 @@ void MediaSource::setState(QMediaPlayer::State newState) {
emit this->stateChanged(newState);
}
void MediaSource::updatePlaybackInfo() {
void PlaybackManager::updatePlaybackInfo() {
postPlaybackInfo(Progress);
}
void MediaSource::postPlaybackInfo(PlaybackInfoType type) {
void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) {
QJsonObject root;
root["ItemId"] = m_itemId;

View File

@ -22,6 +22,7 @@ namespace Jellyfin {
WebSocket::WebSocket(ApiClient *client)
: QObject (client), m_apiClient(client){
connect(&m_webSocket, &QWebSocket::connected, this, &WebSocket::onConnected);
connect(&m_webSocket, &QWebSocket::disconnected, this, &WebSocket::onDisconnected);
connect(&m_webSocket, static_cast<void (QWebSocket::*)(QAbstractSocket::SocketError)>(&QWebSocket::error),
this, [this](QAbstractSocket::SocketError error) {
Q_UNUSED(error)
@ -45,6 +46,11 @@ void WebSocket::onConnected() {
connect(&m_webSocket, &QWebSocket::textMessageReceived, this, &WebSocket::textMessageReceived);
}
void WebSocket::onDisconnected() {
disconnect(&m_webSocket, &QWebSocket::textMessageReceived, this, &WebSocket::textMessageReceived);
m_keepAliveTimer.stop();
}
void WebSocket::textMessageReceived(const QString &message) {
QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8());
if (doc.isNull() || !doc.isObject()) {

21
desktop/.qmake.stash Normal file
View File

@ -0,0 +1,21 @@
QMAKE_CXX.QT_COMPILER_STDCXX = 201402L
QMAKE_CXX.QMAKE_GCC_MAJOR_VERSION = 10
QMAKE_CXX.QMAKE_GCC_MINOR_VERSION = 2
QMAKE_CXX.QMAKE_GCC_PATCH_VERSION = 0
QMAKE_CXX.COMPILER_MACROS = \
QT_COMPILER_STDCXX \
QMAKE_GCC_MAJOR_VERSION \
QMAKE_GCC_MINOR_VERSION \
QMAKE_GCC_PATCH_VERSION
QMAKE_CXX.INCDIRS = \
/usr/include/c++/10.2.0 \
/usr/include/c++/10.2.0/x86_64-pc-linux-gnu \
/usr/include/c++/10.2.0/backward \
/usr/lib/gcc/x86_64-pc-linux-gnu/10.2.0/include \
/usr/local/include \
/usr/lib/gcc/x86_64-pc-linux-gnu/10.2.0/include-fixed \
/usr/include
QMAKE_CXX.LIBDIRS = \
/usr/lib/gcc/x86_64-pc-linux-gnu/10.2.0 \
/usr/lib \
/lib

14
desktop/desktop.pro Normal file
View File

@ -0,0 +1,14 @@
TEMPLATE = app
SOURCES += \
src/main.cpp
include(../harbour-sailfin.pri)
# Include our library
LIBS += -L$$OUT_PWD/../core/lib -ljellyfin-qt
core.files += ../core/lib
core.path = /usr/share/$${TARGET}
INSTALLS += core

7
desktop/src/main.cpp Normal file
View File

@ -0,0 +1,7 @@
#include <QGuiApplication>
int main(int argc, char** argv) {
QGuiApplication app(argc, argv);
return app.exec();
}

7
harbour-sailfin.pri Normal file
View File

@ -0,0 +1,7 @@
!defined(SAILFIN_VERSION, var) {
SAILFIN_VERSION = "0.0.0-unknown"
}
QMAKE_CXXFLAGS += -std=c++17
# Help, something keeps eating my quotes and backslashes
DEFINES += "SAILFIN_VERSION=\"\\\"$$SAILFIN_VERSION\\\"\""

View File

@ -1,85 +1,20 @@
# NOTICE:
#
# Application name defined in TARGET has a corresponding QML filename.
# If name defined in TARGET is changed, the following needs to be done
# to match new name:
# - corresponding QML filename must be changed
# - desktop icon filename must be changed
# - desktop filename must be changed
# - icon definition filename in desktop file must be changed
# - translation filenames have to be changed
TEMPLATE = subdirs
SUBDIRS = core
# The name of your application
TARGET = harbour-sailfin
core.subdir = core
QT += multimedia websockets
CONFIG += sailfishapp # c++17
QMAKE_CXXFLAGS += -std=c++17
# Help, something keeps eating my quotes and backslashes
!defined(SAILFIN_VERSION, var) {
SAILFIN_VERSION = "(UNKNOWN VERSION)"
defined(OS_SAILFISHOS, var){
SUBDIRS += sailfish
sailfish.subdir = sailfish
sailfish.depends = core
}
defined(OS_DESKTOP, var) {
SUBDIRS += desktop
desktop.subdir = desktop
desktop.depends = core
}
DEFINES += "SAILFIN_VERSION=\"\\\"$$SAILFIN_VERSION\\\"\""
SOURCES += \
src/credentialmanager.cpp \
src/harbour-sailfin.cpp \
src/jellyfinapiclient.cpp \
src/jellyfinapimodel.cpp \
src/jellyfindeviceprofile.cpp \
src/jellyfinitem.cpp \
src/jellyfinmediasource.cpp \
src/jellyfinwebsocket.cpp \
src/serverdiscoverymodel.cpp
DISTFILES += \
qml/Constants.qml \
qml/Utils.js \
qml/components/GlassyBackground.qml \
qml/components/IconListItem.qml \
qml/components/LibraryItemDelegate.qml \
qml/components/MoreSection.qml \
qml/components/PlainLabel.qml \
qml/components/PlayToolbar.qml \
qml/components/RemoteImage.qml \
qml/components/Shim.qml \
qml/components/UserGridDelegate.qml \
qml/components/VideoPlayer.qml \
qml/components/VideoTrackSelector.qml \
qml/components/itemdetails/SeasonDetails.qml \
qml/components/videoplayer/VideoError.qml \
qml/components/videoplayer/VideoHud.qml \
qml/cover/CoverPage.qml \
qml/cover/PosterCover.qml \
qml/cover/VideoCover.qml \
qml/pages/LegalPage.qml \
qml/pages/MainPage.qml \
qml/pages/AboutPage.qml \
qml/harbour-sailfin.qml \
qml/pages/SettingsPage.qml \
qml/pages/VideoPage.qml \
qml/pages/itemdetails/BaseDetailPage.qml \
qml/pages/itemdetails/CollectionPage.qml \
qml/pages/itemdetails/EpisodePage.qml \
qml/pages/itemdetails/FilmPage.qml \
qml/pages/itemdetails/MusicAlbumPage.qml \
qml/pages/itemdetails/SeasonPage.qml \
qml/pages/itemdetails/SeriesPage.qml \
qml/pages/itemdetails/UnsupportedPage.qml \
qml/pages/itemdetails/VideoPage.qml \
qml/pages/setup/AddServerConnectingPage.qml \
qml/pages/setup/LoginDialog.qml \
qml/qmldir
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
# to disable building translations every time, comment out the
# following CONFIG line
CONFIG += sailfishapp_i18n
message($$SUBDIRS)
# German translation is enabled as an example. If you aren't
# planning to localize your app, remember to comment out the
@ -87,12 +22,4 @@ CONFIG += sailfishapp_i18n
# modify the localized app name in the the .desktop file.
# TRANSLATIONS += \
HEADERS += \
src/credentialmanager.h \
src/jellyfinapiclient.h \
src/jellyfinapimodel.h \
src/jellyfindeviceprofile.h \
src/jellyfinitem.h \
src/jellyfinmediasource.h \
src/jellyfinwebsocket.h \
src/serverdiscoverymodel.h

View File

@ -7,13 +7,12 @@ Release: 1
Group: Qt/Qt
URL: https://chris.netsoj.nl/projects/harbour-sailfin
License: LGPL-2.0-or-later
# This must be generated before uploading a package to a remote build service.
# Usually this line does not need to be modified.
# This must be generated before uploading a package to a remote build service. Usually this line does not need to be modified.
Sources:
- '%{name}-%{version}.tar.bz2'
Description: |
Play video's and music from your Jellyfin media player on your Sailfish device
Builder: qtc5
Builder: qmake5
# This section specifies build dependencies that are resolved using pkgconfig.
# This is the preferred way of specifying build dependencies for your package.
@ -39,8 +38,13 @@ Files:
- '%{_datadir}/%{name}'
- '%{_datadir}/applications/%{name}.desktop'
- '%{_datadir}/icons/hicolor/*/apps/%{name}.png'
Macros:
- '__provides_exclude_from; ^%{_datadir}/.*$'
- '__requires_exclude; ^libjellyfin-qt.*$'
QMakeOptions:
- OS_SAILFISHOS=1
- SAILFIN_VERSION='%{version}-%{release}'
# For more information about yaml and what's supported in Sailfish OS

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -51,7 +51,7 @@ SilicaItem {
color: "black"
}
MediaSource {
PlaybackManager {
id: mediaSource
apiClient: ApiClient
itemId: playerRoot.itemId

View File

@ -19,6 +19,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
Column {
property var tracks
readonly property int audioTrack: audioSelector.currentItem ? audioSelector.currentItem._index : 0
@ -40,8 +42,8 @@ Column {
Repeater {
model: audioModel
MenuItem {
readonly property int _index: model.Index
text: model.DisplayTitle
readonly property int _index: model.index
text: model.displayTitle
}
}
}
@ -60,8 +62,8 @@ Column {
Repeater {
model: subtitleModel
MenuItem {
readonly property int _index: model.Index
text: model.DisplayTitle
readonly property int _index: model.index
text: model.displayTitle
}
}
}
@ -70,17 +72,22 @@ Column {
onTracksChanged: {
audioModel.clear()
subtitleModel.clear()
if (typeof tracks === "undefined") return
if (typeof tracks === "undefined") {
console.log("tracks undefined")
return
}
console.log(tracks)
for(var i = 0; i < tracks.length; i++) {
var track = tracks[i];
switch(track.Type) {
case "Audio":
switch(track.type) {
case MediaStream.Audio:
audioModel.append(track)
break;
case "Subtitle":
case MediaStream.Subtitle:
subtitleModel.append(track)
break;
default:
console.log("Ignored " + track.displayTitle + "(" + track.type + ")")
break;
}
}

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -73,6 +73,7 @@ Page {
apiClient: ApiClient
onStatusChanged: {
console.log("Status changed: " + newStatus, JSON.stringify(jItem))
console.log(jItem.mediaStreams)
}
}
@ -82,28 +83,7 @@ Page {
//appWindow.itemData = ({})
}
if (status == PageStatus.Active) {
if (itemId) {
//ApiClient.fetchItem(itemId)
}
}
}
Connections {
target: ApiClient
onItemFetched: {
if (itemId === pageRoot.itemId) {
//console.log(JSON.stringify(result))
//pageRoot.itemData = result
pageRoot._loading = false
if (status == PageStatus.Active) {
if (itemData.Type === "CollectionFolder") {
appWindow.collectionId = itemData.Id
} else {
appWindow.itemData = result
}
}
}
}
}
}

View File

@ -30,7 +30,7 @@ BaseDetailPage {
UserItemModel {
id: collectionModel
apiClient: ApiClient
parentId: itemData.Id || ""
parentId: itemData.jellyfinId
sortBy: ["SortName"]
onParentIdChanged: reload()
}

View File

@ -26,7 +26,7 @@ import "../../"
VideoPage {
subtitle: {
if (typeof itemData.indexNumberEnd !== "undefined") {
if (itemData.indexNumberEnd >= 0) {
qsTr("Episode %1%2 | %3").arg(itemData.indexNumber)
.arg(itemData.indexNumberEnd)
.arg(itemData.seasonName)

View File

@ -29,8 +29,8 @@ BaseDetailPage {
ShowEpisodesModel {
id: episodeModel
apiClient: ApiClient
show: itemData.SeriesId
seasonId: itemData.Id
show: itemData.seriesId
seasonId: itemData.jellyfinId
fields: ["Overview"]
}
@ -38,8 +38,8 @@ BaseDetailPage {
anchors.fill: parent
contentHeight: content.height
header: PageHeader {
title: itemData.Name
description: itemData.SeriesName
title: itemData.name
description: itemData.seriesName
}
model: episodeModel
delegate: BackgroundItem {
@ -119,10 +119,19 @@ BaseDetailPage {
VerticalScrollDecorator {}
}
onItemDataChanged: {
Connections {
target: itemData
onStatusChanged: {
if (itemData.status == JellyfinItem.Ready) {
episodeModel.reload()
}
}
}
onStatusChanged: {
if (status == PageStatus.Active) {
console.log(JSON.stringify(itemData))
episodeModel.show = itemData.SeriesId
episodeModel.seasonId = itemData.Id
episodeModel.reload()
episodeModel.show = itemData.seriesId
episodeModel.seasonId = itemData.jellyfinId
}
}
}

View File

@ -66,6 +66,7 @@ BaseDetailPage {
id: showSeasonsModel
apiClient: ApiClient
show: itemData.jellyfinId
onShowChanged: reload()
}
SilicaListView {
@ -86,8 +87,17 @@ BaseDetailPage {
}
}
onItemDataChanged: {
showSeasonsModel.show = itemData.jellyfinId
showSeasonsModel.reload()
/*onStatusChanged: {
if (status == PageStatus.Active) {
showSeasonsModel.reload()
}
}*/
Connections {
target: itemData
onJellyfinIdChanged: {
console.log("Item id changed")
//showSeasonsModel.show = itemData.jellyfinId
}
}
}

View File

@ -46,7 +46,7 @@ BaseDetailPage {
PageHeader {
id: pageHeader
title: itemData.name
description: qsTr("Run time: %2").arg(Utils.ticksToText(itemData.RunTimeTicks))
description: qsTr("Run time: %2").arg(Utils.ticksToText(itemData.runTimeTicks))
}
PlayToolbar {
@ -70,4 +70,13 @@ BaseDetailPage {
}
}
}
Connections {
target: itemData
onStatusChanged: {
if (status == JellyfinItem.Ready) {
console.log(itemData.mediaStreams)
}
}
}
}

79
sailfish/sailfish.pro Normal file
View File

@ -0,0 +1,79 @@
# NOTICE:
#
# Application name defined in TARGET has a corresponding QML filename.
# If name defined in TARGET is changed, the following needs to be done
# to match new name:
# - corresponding QML filename must be changed
# - desktop icon filename must be changed
# - desktop filename must be changed
# - icon definition filename in desktop file must be changed
# - translation filenames have to be changed
# The name of your application
TARGET = harbour-sailfin
#INCLUDEPATH += ../core/include
#DEPENDPATH += ../core
#LIBS += -Lcore -lcore
include(../core/defines.pri)
include(../harbour-sailfin.pri)
# include our shared library and install it
LIBS += -L$$OUT_PWD/../core/lib -ljellyfin-qt
core.files += ../core/lib
core.path = /usr/share/$${TARGET}
INSTALLS += core
# Other configuration
CONFIG += sailfishapp # c++17
DISTFILES += \
qml/Constants.qml \
qml/Utils.js \
qml/components/GlassyBackground.qml \
qml/components/IconListItem.qml \
qml/components/LibraryItemDelegate.qml \
qml/components/MoreSection.qml \
qml/components/PlainLabel.qml \
qml/components/PlayToolbar.qml \
qml/components/RemoteImage.qml \
qml/components/Shim.qml \
qml/components/UserGridDelegate.qml \
qml/components/VideoPlayer.qml \
qml/components/VideoTrackSelector.qml \
qml/components/itemdetails/SeasonDetails.qml \
qml/components/videoplayer/VideoError.qml \
qml/components/videoplayer/VideoHud.qml \
qml/cover/CoverPage.qml \
qml/cover/PosterCover.qml \
qml/cover/VideoCover.qml \
qml/pages/LegalPage.qml \
qml/pages/MainPage.qml \
qml/pages/AboutPage.qml \
qml/harbour-sailfin.qml \
qml/pages/SettingsPage.qml \
qml/pages/VideoPage.qml \
qml/pages/itemdetails/BaseDetailPage.qml \
qml/pages/itemdetails/CollectionPage.qml \
qml/pages/itemdetails/EpisodePage.qml \
qml/pages/itemdetails/FilmPage.qml \
qml/pages/itemdetails/MusicAlbumPage.qml \
qml/pages/itemdetails/SeasonPage.qml \
qml/pages/itemdetails/SeriesPage.qml \
qml/pages/itemdetails/UnsupportedPage.qml \
qml/pages/itemdetails/VideoPage.qml \
qml/pages/setup/AddServerConnectingPage.qml \
qml/pages/setup/LoginDialog.qml \
qml/qmldir
SOURCES += \
src/harbour-sailfin.cpp
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
# to disable building translations every time, comment out the
# following CONFIG line
CONFIG += sailfishapp_i18n

View File

@ -28,28 +28,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include <sailfishapp.h>
#include "jellyfinapiclient.h"
#include "jellyfinapimodel.h"
#include "jellyfinitem.h"
#include "jellyfinmediasource.h"
#include "serverdiscoverymodel.h"
void registerQml() {
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
// Singletons are perhaps bad, but they are convenient :)
qmlRegisterSingletonType<Jellyfin::ApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
Q_UNUSED(eng)
Q_UNUSED(js)
return dynamic_cast<QObject*>(new Jellyfin::ApiClient());
});
qmlRegisterType<Jellyfin::ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
qmlRegisterType<Jellyfin::MediaSource>(QML_NAMESPACE, 1, 0, "MediaSource");
// API models
Jellyfin::registerModels(QML_NAMESPACE);
Jellyfin::registerSerializableJsonTypes(QML_NAMESPACE);
}
#include <jellyfin.h>
int main(int argc, char *argv[]) {
// SailfishApp::main() will display "qml/harbour-sailfin.qml", if you need more
@ -62,8 +41,7 @@ int main(int argc, char *argv[]) {
//
// To display the view, call "show()" (will show fullscreen on device).
QGuiApplication *app = SailfishApp::application(argc, argv);
registerQml();
Jellyfin::registerTypes();
QQuickView *view = SailfishApp::createView();
view->setSource(SailfishApp::pathToMainQml());
view->show();

View File

@ -141,13 +141,6 @@
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>Jellyfin::Item</name>
<message>
<source>Invalid response from the server: root element is not an object.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>LegalPage</name>
<message>