diff --git a/harbour-sailfin.pro b/harbour-sailfin.pro index 7df1b53..a0e7986 100644 --- a/harbour-sailfin.pro +++ b/harbour-sailfin.pro @@ -39,6 +39,7 @@ DISTFILES += \ qml/components/RemoteImage.qml \ qml/components/UserGridDelegate.qml \ qml/components/VideoPlayer.qml \ + qml/components/itemdetails/CollectionFolder.qml \ qml/components/itemdetails/EpisodeDetails.qml \ qml/components/itemdetails/FilmDetails.qml \ qml/components/itemdetails/PlayToolbar.qml \ diff --git a/qml/components/itemdetails/CollectionFolder.qml b/qml/components/itemdetails/CollectionFolder.qml new file mode 100644 index 0000000..9c36e13 --- /dev/null +++ b/qml/components/itemdetails/CollectionFolder.qml @@ -0,0 +1,5 @@ +import QtQuick 2.0 + +Item { + +} diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml index 220f649..ee6da55 100644 --- a/qml/cover/CoverPage.qml +++ b/qml/cover/CoverPage.qml @@ -61,7 +61,6 @@ CoverBackground { width: height source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": row1.height}) fillMode: Image.PreserveAspectCrop - Component.onCompleted: console.log(JSON.stringify(model.imageTags)) } } @@ -105,7 +104,6 @@ CoverBackground { width: height source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": row1.height}) fillMode: Image.PreserveAspectCrop - Component.onCompleted: console.log(JSON.stringify(model.imageTags)) } } diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 5e7fd55..922fedf 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -47,14 +47,14 @@ Page { apiClient: ApiClient } - MoreSection { + MoreSection { text: qsTr("Resume watching") clickable: false - } - MoreSection { + } + MoreSection { text: qsTr("Next up") clickable: false - } + } UserViewModel { id: mediaLibraryModel @@ -95,7 +95,7 @@ Page { property string id: model.id title: model.name 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"] : ""*/ landscape: !Utils.usePortraitCover(model.type) @@ -113,6 +113,7 @@ Page { Connections { target: mediaLibraryModel onStatusChanged: { + console.log("MediaLibraryModel status " + status) if (status == ApiModel.Ready) { userItemModel.reload() } @@ -165,5 +166,5 @@ Page { _modelsLoaded = true; mediaLibraryModel.reload() } - } + } } diff --git a/src/credentialmanager.cpp b/src/credentialmanager.cpp index 8f2404c..8efc00b 100644 --- a/src/credentialmanager.cpp +++ b/src/credentialmanager.cpp @@ -1,6 +1,6 @@ #include "credentialmanager.h" -CredentialsManager * CredentialsManager::getInstance(QObject *parent) { +CredentialsManager * CredentialsManager::newInstance(QObject *parent) { return new FallbackCredentialsManager(parent); } diff --git a/src/credentialmanager.h b/src/credentialmanager.h index b3ab328..b1a660a 100644 --- a/src/credentialmanager.h +++ b/src/credentialmanager.h @@ -7,6 +7,13 @@ #include #include +/** + * @brief The CredentialsManager class stores credentials for users. + * + * You can get an instance using ::instance(), which may depend on the platform being + * used. Since the implementation may be asynchronous, the methods won't return anything, + * they emit a corresponding signal instead. + */ class CredentialsManager : public QObject { Q_OBJECT public: @@ -61,9 +68,11 @@ public: /** * @brief Retrieves an implementation which can store this token. * @param The parent to set the implementations QObject parent to + * + * This method is always guaranteed to return an instance. * @return An implementation of this interface (may vary acrros platform). */ - static CredentialsManager *getInstance(QObject *parent = nullptr); + static CredentialsManager *newInstance(QObject *parent = nullptr); /** * @return if the implementation of this interface stores the token in a secure place. diff --git a/src/jellyfinapiclient.cpp b/src/jellyfinapiclient.cpp index 4978b7d..29facbe 100644 --- a/src/jellyfinapiclient.cpp +++ b/src/jellyfinapiclient.cpp @@ -5,7 +5,7 @@ ApiClient::ApiClient(QObject *parent) : QObject(parent) { m_deviceName = QHostInfo::localHostName(); m_deviceId = QUuid::createUuid().toString(); // TODO: make this not random? - m_credManager = CredentialsManager::getInstance(this); + m_credManager = CredentialsManager::newInstance(this); generateDeviceProfile(); } diff --git a/src/jellyfinapimodel.cpp b/src/jellyfinapimodel.cpp index aaebb65..16b3bbe 100644 --- a/src/jellyfinapimodel.cpp +++ b/src/jellyfinapimodel.cpp @@ -1,15 +1,27 @@ #include "jellyfinapimodel.h" namespace Jellyfin { -ApiModel::ApiModel(QString path, QString subfield, bool addUserId, QObject *parent) +ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) : QAbstractListModel (parent), m_path(path), - m_subfield(subfield), + m_hasRecordResponse(hasRecordResponse), m_addUserId(addUserId){ } void ApiModel::reload() { - this->setStatus(Loading); + load(RELOAD); +} + +void ApiModel::load(LoadType type) { + qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); + switch(type) { + case RELOAD: + this->setStatus(Loading); + break; + case LOAD_MORE: + this->setStatus(LoadingMore); + break; + } if (m_apiClient == nullptr) { qWarning() << "Please set the apiClient property before (re)loading"; return; @@ -23,6 +35,11 @@ void ApiModel::reload() { QUrlQuery query; if (m_limit >= 0) { query.addQueryItem("Limit", QString::number(m_limit)); + } else { + query.addQueryItem("Limit", QString::number(DEFAULT_LIMIT)); + } + if (m_startIndex > 0) { + query.addQueryItem("StartIndex", QString::number(m_startIndex)); } if (!m_parentId.isEmpty()) { query.addQueryItem("ParentId", m_parentId); @@ -45,10 +62,11 @@ void ApiModel::reload() { if (m_recursive) { query.addQueryItem("Recursive", "true"); } + addQueryParameters(query); QNetworkReply *rep = m_apiClient->get(m_path, query); - connect(rep, &QNetworkReply::finished, this, [this, rep]() { + connect(rep, &QNetworkReply::finished, this, [this, type, rep]() { QJsonDocument doc = QJsonDocument::fromJson(rep->readAll()); - if (m_subfield.trimmed().isEmpty()) { + if (!m_hasRecordResponse) { if (!doc.isArray()) { qWarning() << "Object is not an array!"; this->setStatus(Error); @@ -62,19 +80,45 @@ void ApiModel::reload() { return; } QJsonObject obj = doc.object(); - if (!obj.contains(m_subfield)) { - qWarning() << "Object doesn't contain required subfield!"; + if (!obj.contains("Items")) { + qWarning() << "Object doesn't contain items!"; this->setStatus(Error); return; } - if (!obj[m_subfield].isArray()) { - qWarning() << "Object's subfield is not an array!"; + if (m_limit < 0) { + // Javascript is beautiful + if (obj.contains("TotalRecordCount") && obj["TotalRecordCount"].isDouble()) { + m_totalRecordCount = obj["TotalRecordCount"].toInt(); + m_startIndex += DEFAULT_LIMIT; + } else { + qWarning() << "Record-response does not have a total record count"; + this->setStatus(Error); + return; + } + } + if (!obj["Items"].isArray()) { + qWarning() << "Items is not an array!"; this->setStatus(Error); return; } - this->m_array = obj[m_subfield].toArray(); + QJsonArray items = obj["Items"].toArray(); + switch(type) { + case RELOAD: + this->m_array = items; + break; + case LOAD_MORE: + this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1); + // QJsonArray apparently doesn't allow concatenating lists like QList or std::vector + foreach (const QJsonValue &val, items) { + m_array.append(val); + } + this->endInsertRows(); + break; + } + } + if (type == RELOAD) { + generateFields(); } - generateFields(); this->setStatus(Ready); rep->deleteLater(); }); @@ -123,6 +167,32 @@ QVariant ApiModel::data(const QModelIndex &index, int role) const { return QVariant(); } +bool ApiModel::canFetchMore(const QModelIndex &parent) const { + if (parent.isValid()) return false; + switch(m_status) { + case Uninitialised: + case Loading: + return false; + default: + break; + } + + if (m_limit < 0) { + return m_startIndex <= m_totalRecordCount; + } else { + return false; + } + +} + +void ApiModel::fetchMore(const QModelIndex &parent) { + if (parent.isValid()) return; + load(LOAD_MORE); +} + +void ApiModel::addQueryParameters(QUrlQuery &query) { Q_UNUSED(query)} + + void registerModels(const char *URI) { qmlRegisterUncreatableType(URI, 1, 0, "ApiModel", "Is enum and base class"); qmlRegisterUncreatableType(URI, 1, 0, "SortOrder", "Is enum"); diff --git a/src/jellyfinapimodel.h b/src/jellyfinapimodel.h index 157cd14..4c442f2 100644 --- a/src/jellyfinapimodel.h +++ b/src/jellyfinapimodel.h @@ -66,7 +66,8 @@ public: Uninitialised, Loading, Ready, - Error + Error, + LoadingMore }; Q_ENUM(ModelStatus) @@ -81,19 +82,24 @@ public: * @code{.json} * [{...}, {...}, {...}] * @endcode - * subfield should be left empty + * + * or + * @code{.json} + * {...} + * @endcode + * responseHasRecords should be false * * If the response looks something like this: * @code{.json} * { - * "offset": 0, - * "count": 20, - * "data": [{...}, {...}, {...}, ..., {...}] + * "Offset": 0, + * "Count": 20, + * "Items": [{...}, {...}, {...}, ..., {...}] * } * @endcode - * Subfield should be set to "data" in this example. + * responseHasRecords should be true */ - explicit ApiModel(QString path, QString subfield, 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(ModelStatus status READ status NOTIFY statusChanged) @@ -109,16 +115,19 @@ public: // Path properties Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged) + // Standard QAbstractItemModel overrides int rowCount(const QModelIndex &index) const override { if (!index.isValid()) return m_array.size(); return 0; } QHash roleNames() const override { return m_roles; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; ModelStatus status() const { return m_status; } + // Helper methods template QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); } @@ -130,6 +139,7 @@ public: } return result; } + signals: void statusChanged(ModelStatus newStatus); void limitChanged(int newLimit); @@ -146,18 +156,39 @@ public slots: */ void reload(); protected: + + enum LoadType { + RELOAD, + LOAD_MORE + }; + + void load(LoadType loadType); + /** + * @brief Adds parameters to the query + * @param query The query to add parameters to + * + * This method is intended to be overrided by subclasses. It gets called + * before a request is made to the server and can be used to enable + * query types specific for a certain model to be available. + */ + virtual void addQueryParameters(QUrlQuery &query); ApiClient *m_apiClient = nullptr; ModelStatus m_status = Uninitialised; QString m_path; - QString m_subfield; QJsonArray m_array; + bool m_hasRecordResponse; // Path properties QString m_show; - // Query properties + // Query/record controlling properties int m_limit = -1; + int m_startIndex = 0; + int m_totalRecordCount = 0; + const int DEFAULT_LIMIT = 100; + + // Query properties bool m_addUserId = false; QString m_parentId; QString m_seasonId; @@ -167,7 +198,6 @@ protected: bool m_recursive; QHash m_roles; - //QHash m_reverseRoles; void setStatus(ModelStatus newStatus) { this->m_status = newStatus; @@ -200,24 +230,24 @@ public: class UserItemModel : public ApiModel { public: explicit UserItemModel (QObject *parent = nullptr) - : ApiModel ("/Users/{{user}}/Items", "Items", false, parent) {} + : ApiModel ("/Users/{{user}}/Items", true, false, parent) {} }; class UserItemLatestModel : public ApiModel { public: explicit UserItemLatestModel (QObject *parent = nullptr) - : ApiModel ("/Users/{{user}}/Items/Latest", "", false, parent) {} + : ApiModel ("/Users/{{user}}/Items/Latest", false, false, parent) {} }; class ShowSeasonsModel : public ApiModel { public: explicit ShowSeasonsModel (QObject *parent = nullptr) - : ApiModel ("/Shows/{{show}}/Seasons", "Items", true, parent) {} + : ApiModel ("/Shows/{{show}}/Seasons", true, true, parent) {} }; class ShowEpisodesModel : public ApiModel { public: explicit ShowEpisodesModel (QObject *parent = nullptr) - : ApiModel ("/Shows/{{show}}/Episodes", "Items", true, parent) {} + : ApiModel ("/Shows/{{show}}/Episodes", true, true, parent) {} };