diff --git a/core/include/jellyfinitem.h b/core/include/jellyfinitem.h index 4605635..7157459 100644 --- a/core/include/jellyfinitem.h +++ b/core/include/jellyfinitem.h @@ -126,9 +126,39 @@ signals: void apiClientChanged(ApiClient *newApiClient); void errorChanged(QNetworkReply::NetworkError newError); void errorStringChanged(QString newErrorString); + /** + * @brief Convenience signal for status == RemoteData.Ready. + */ + void ready(); public slots: - virtual void reload() = 0; + + /** + * @brief Overload this method to reimplement the fetching mechanism to + * populate the RemoteData with data from the server. + * + * The default implementation makes a GET request to getDataUrl() and parses the resulting JSON, + * which should be enough for most cases. Consider overriding getDataUrl() and + * canRelaod() if possible. Manual overrides need to make sure that + * they're calling setStatus(Status), setError(QNetworkReply::NetworkError) and + * setErrorString() to let the QML side know what this thing is up to. + */ + virtual void reload(); protected: + /** + * @brief Subclasses should implement this to determine if they can + * load data from the server. + * + * Usage cases include checking if the + * required properties, such as the item id are set. + */ + virtual bool canReload() const = 0; + + /** + * @brief Construct the URL to fetch the data from. + * @return The URL to load data from. + */ + virtual QString getDataUrl() const = 0; + void setStatus(Status newStatus); void setError(QNetworkReply::NetworkError error); void setErrorString(const QString &newErrorString); @@ -139,6 +169,33 @@ private: QString m_errorString; }; +class User : public RemoteData { + Q_OBJECT +public: + Q_INVOKABLE User(QObject *parent = nullptr); + + Q_PROPERTY(QString userId MEMBER m_userId WRITE setUserId NOTIFY userIdChanged) + Q_PROPERTY(QString name MEMBER m_name NOTIFY nameChanged) + Q_PROPERTY(QString primaryImageTag MEMBER m_primaryImageTag NOTIFY primaryImageTagChanged) + + void setUserId(const QString &newUserId) { + this->m_userId = newUserId; + emit userIdChanged(newUserId); + reload(); + } +signals: + void userIdChanged(const QString &newUserId); + void nameChanged(const QString &newName); + void primaryImageTagChanged(const QString &newPrimaryImageTag); +protected: + QString getDataUrl() const override; + bool canReload() const override; +private: + QString m_userId; + QString m_name; + QString m_primaryImageTag; +}; + class MediaStream : public JsonSerializable { Q_OBJECT public: @@ -365,12 +422,12 @@ signals: void imageBlurHashesChanged(); public slots: - /** - * @brief (Re)loads the item from the Jellyfin server. - */ - void reload() override; void onUserDataChanged(const QString &itemId, QSharedPointer userData); protected: + // Overrides + QString getDataUrl() const override; + bool canReload() const override; + QString m_id; QString m_name; QString m_originalTitle; diff --git a/core/src/jellyfinitem.cpp b/core/src/jellyfinitem.cpp index 888dbef..8eab9f0 100644 --- a/core/src/jellyfinitem.cpp +++ b/core/src/jellyfinitem.cpp @@ -198,6 +198,7 @@ RemoteData::RemoteData(QObject *parent) : JsonSerializable (parent) {} void RemoteData::setStatus(Status newStatus) { m_status = newStatus; emit statusChanged(newStatus); + if (newStatus == Ready) emit ready(); } void RemoteData::setError(QNetworkReply::NetworkError error) { @@ -216,6 +217,54 @@ void RemoteData::setApiClient(ApiClient *newApiClient) { reload(); } +void RemoteData::reload() { + if (!canReload() || m_apiClient == nullptr) { + setStatus(Uninitialised); + return; + } else { + setStatus(Loading); + } + QNetworkReply *rep = m_apiClient->get(getDataUrl()); + connect(rep, &QNetworkReply::finished, this, [this, rep]() { + rep->deleteLater(); + + QJsonParseError 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()); + return; + } + if (!doc.isObject()) { + this->setError(QNetworkReply::ProtocolFailure); + this->setErrorString(tr("Invalid response from the server: root element is not an object.")); + return; + } + this->deserialize(doc.object()); + this->setStatus(Ready); + }); + connect(rep, static_cast(&QNetworkReply::error), + this, [this, rep](QNetworkReply::NetworkError error) { + this->setError(error); + this->setErrorString(rep->errorString()); + this->setStatus(Error); + rep->deleteLater(); + }); +} + +// User +User::User(QObject *parent) : RemoteData (parent) {} + +QString User::getDataUrl() const { + return QString("/Users/") + m_apiClient->userId(); +} + +bool User::canReload() const { + return true; +} + // MediaStream MediaStream::MediaStream(QObject *parent) : JsonSerializable (parent) {} MediaStream::MediaStream(const MediaStream &other) @@ -277,6 +326,13 @@ Item::Item(QObject *parent) : RemoteData(parent) { }); } +QString Item::getDataUrl() const { + return QString("/Users/") + m_apiClient->userId() + "/Items/" + m_id; +} + +bool Item::canReload() const { + return !m_id.isNull(); +} void Item::setJellyfinId(QString newId) { m_id = newId.trimmed(); @@ -286,42 +342,7 @@ void Item::setJellyfinId(QString newId) { } } -void Item::reload() { - if (m_id.isEmpty() || m_apiClient == nullptr) { - setStatus(Uninitialised); - return; - } else { - setStatus(Loading); - } - QNetworkReply *rep = m_apiClient->get("/Users/" + m_apiClient->userId() + "/Items/" + m_id); - connect(rep, &QNetworkReply::finished, this, [this, rep]() { - rep->deleteLater(); - QJsonParseError 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()); - return; - } - if (!doc.isObject()) { - this->setError(QNetworkReply::ProtocolFailure); - this->setErrorString(tr("Invalid response from the server: root element is not an object.")); - return; - } - this->deserialize(doc.object()); - this->setStatus(Ready); - }); - connect(rep, static_cast(&QNetworkReply::error), - this, [this, rep](QNetworkReply::NetworkError error) { - rep->deleteLater(); - this->setError(error); - this->setErrorString(rep->errorString()); - this->setStatus(Error); - }); -} void Item::onUserDataChanged(const QString &itemId, QSharedPointer userData) { if (itemId != m_id || m_userData == nullptr) return; @@ -330,6 +351,7 @@ void Item::onUserDataChanged(const QString &itemId, QSharedPointer use void registerSerializableJsonTypes(const char* URI) { qmlRegisterType(URI, 1, 0, "MediaStream"); + qmlRegisterType(URI, 1, 0, "User"); qmlRegisterType(URI, 1, 0, "UserData"); qmlRegisterType(URI, 1, 0, "JellyfinItem"); } diff --git a/sailfish/qml/pages/MainPage.qml b/sailfish/qml/pages/MainPage.qml index 81e6d48..21fef97 100644 --- a/sailfish/qml/pages/MainPage.qml +++ b/sailfish/qml/pages/MainPage.qml @@ -51,12 +51,13 @@ Page { } // Tell SilicaFlickable the height of its content. - contentHeight: column.height + contentHeight: column.visible ? column.height : errorColumn.height // Place our content in a Column. The PageHeader is always placed at the top // of the page, followed by our content. Column { id: column + visible: mediaLibraryModel.status != ApiModel.Error width: page.width //spacing: Theme.paddingLarge @@ -123,25 +124,31 @@ Page { } } } - Column { - width: parent.width - visible: mediaLibraryModel.status == ApiModel.Error - PageHeader { - title: qsTr("Network error") - //clickable: false - } + } - PlainLabel { - text: qsTr("An error has occurred. Please try again.") - } - Item { width: 1; height: Theme.paddingLarge } - Button { - text: qsTr("Retry") - anchors.horizontalCenter: parent.horizontalCenter - onClicked: loadModels(true) - } - Item { width: 1; height: Theme.paddingLarge } + Column { + id: errorColumn + width: parent.width + visible: mediaLibraryModel.status == ApiModel.Error + PageHeader { + title: qsTr("Network error") } + + Item { + width: 1 + height: page.height / 3 + } + + PlainLabel { + text: qsTr("An error has occurred. Please try again.") + } + Item { width: 1; height: Theme.paddingLarge } + Button { + text: qsTr("Retry") + anchors.horizontalCenter: parent.horizontalCenter + onClicked: loadModels(true) + } + Item { width: 1; height: Theme.paddingLarge } } } diff --git a/sailfish/qml/pages/SettingsPage.qml b/sailfish/qml/pages/SettingsPage.qml index 5111085..1c7a676 100644 --- a/sailfish/qml/pages/SettingsPage.qml +++ b/sailfish/qml/pages/SettingsPage.qml @@ -48,24 +48,54 @@ Page { text: qsTr("Session") } - PlainLabel { - text: qsTr("Server") - } + Item { + anchors { + left: parent.left + leftMargin: Theme.horizontalPageMargin + right: parent.right + rightMargin: Theme.horizontalPageMargin + } + height: user.implicitHeight + server.implicitHeight + Theme.paddingMedium + User { + id: loggedInUser + apiClient: ApiClient + } + Image { + id: userIcon + width: height + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + source: ApiClient.baseUrl + "/Users/" + ApiClient.userId + "/Images/Primary?tag=" + loggedInUser.primaryImageTag + } - PlainLabel { - text: ApiClient.baseUrl - color: Theme.secondaryHighlightColor - } + Label { + id: user + anchors { + left: userIcon.right + leftMargin: Theme.paddingLarge + bottom: parent.verticalCenter + right: parent.right + } + text: loggedInUser.status == User.Ready ? loggedInUser.name : ApiClient.userId + color: Theme.highlightColor + } - Item { width: 1; height: Theme.paddingMedium; } + Label { + id: server + anchors { + left: userIcon.right + leftMargin: Theme.paddingLarge + top: parent.verticalCenter + right: parent.right + } + text: ApiClient.baseUrl + color: Theme.secondaryHighlightColor + } - PlainLabel { - text: qsTr("User id") - } - PlainLabel { - text: ApiClient.userId - color: Theme.secondaryHighlightColor } Item { width: 1; height: Theme.paddingLarge; } diff --git a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml index 9b79a46..2f91964 100644 --- a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml +++ b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml @@ -68,6 +68,31 @@ Page { running: pageRoot._loading } + SilicaFlickable { + anchors.fill: parent + contentHeight: errorContent.height + visible: jItem.status == JellyfinItem.Error + + PullDownMenu { + busy: jItem.status == JellyfinItem.Loading + MenuItem { + text: qsTr("Retry") + onClicked: jItem.reload() + } + } + + Column { + id: errorContent + width: parent.width + + ViewPlaceholder { + enabled: true + text: qsTr("An error has occured") + hintText: jItem.errorString + } + } + } + JellyfinItem { id: jItem apiClient: ApiClient diff --git a/sailfish/qml/pages/itemdetails/CollectionPage.qml b/sailfish/qml/pages/itemdetails/CollectionPage.qml index 506d9c8..685ce41 100644 --- a/sailfish/qml/pages/itemdetails/CollectionPage.qml +++ b/sailfish/qml/pages/itemdetails/CollectionPage.qml @@ -42,6 +42,8 @@ BaseDetailPage { cellWidth: Constants.libraryDelegateWidth cellHeight: Utils.usePortraitCover(itemData.CollectionType) ? Constants.libraryDelegatePosterHeight : Constants.libraryDelegateHeight + visible: itemData.status !== JellyfinItem.Error + header: PageHeader { title: itemData.name || qsTr("Loading") } diff --git a/sailfish/qml/pages/itemdetails/SeasonPage.qml b/sailfish/qml/pages/itemdetails/SeasonPage.qml index 280df14..80e94cb 100644 --- a/sailfish/qml/pages/itemdetails/SeasonPage.qml +++ b/sailfish/qml/pages/itemdetails/SeasonPage.qml @@ -34,9 +34,16 @@ BaseDetailPage { fields: ["Overview"] } + Connections { + target: itemData + onReady: episodeModel.reload() + } + SilicaListView { anchors.fill: parent contentHeight: content.height + visible: itemData.status !== JellyfinItem.Error + header: PageHeader { title: itemData.name description: itemData.seriesName diff --git a/sailfish/qml/pages/itemdetails/SeriesPage.qml b/sailfish/qml/pages/itemdetails/SeriesPage.qml index 461a461..1cab136 100644 --- a/sailfish/qml/pages/itemdetails/SeriesPage.qml +++ b/sailfish/qml/pages/itemdetails/SeriesPage.qml @@ -28,6 +28,7 @@ BaseDetailPage { SilicaFlickable { anchors.fill: parent contentHeight: content.height + visible: itemData.status !== JellyfinItem.Error Column { id: content @@ -68,6 +69,10 @@ BaseDetailPage { show: itemData.jellyfinId onShowChanged: reload() } + Connections { + target: itemData + onReady: showSeasonsModel.reload() + } SilicaListView { model: showSeasonsModel diff --git a/sailfish/qml/pages/itemdetails/UnsupportedPage.qml b/sailfish/qml/pages/itemdetails/UnsupportedPage.qml index aa0f3cf..881fcf8 100644 --- a/sailfish/qml/pages/itemdetails/UnsupportedPage.qml +++ b/sailfish/qml/pages/itemdetails/UnsupportedPage.qml @@ -23,6 +23,7 @@ import Sailfish.Silica 1.0 BaseDetailPage { SilicaFlickable { anchors.fill: parent + visible: itemData.status !== JellyfinItem.Error PageHeader { title: itemData.name } diff --git a/sailfish/qml/pages/itemdetails/VideoPage.qml b/sailfish/qml/pages/itemdetails/VideoPage.qml index 6009daa..330117c 100644 --- a/sailfish/qml/pages/itemdetails/VideoPage.qml +++ b/sailfish/qml/pages/itemdetails/VideoPage.qml @@ -36,6 +36,7 @@ BaseDetailPage { SilicaFlickable { anchors.fill: parent contentHeight: content.height + Theme.paddingLarge + visible: itemData.status !== JellyfinItem.Error VerticalScrollDecorator {} diff --git a/sailfish/translations/harbour-sailfin.ts b/sailfish/translations/harbour-sailfin.ts index 9c8d94c..0ab2ad9 100644 --- a/sailfish/translations/harbour-sailfin.ts +++ b/sailfish/translations/harbour-sailfin.ts @@ -62,6 +62,17 @@ + + BaseDetailPage + + Retry + + + + An error has occured + + + CollectionPage @@ -259,14 +270,6 @@ Session - - Server - - - - User id - - Log out