Added user details and somewhat imporved error handling

* [UI] Improved: error handling should be slightly better
* [UI] Improved: settings now show the user name and picture instead of the user id if network is available.
This commit is contained in:
Chris Josten 2020-10-10 15:56:04 +02:00
parent 8a683df2a2
commit d3a7c17586
11 changed files with 240 additions and 80 deletions

View File

@ -126,9 +126,39 @@ signals:
void apiClientChanged(ApiClient *newApiClient); void apiClientChanged(ApiClient *newApiClient);
void errorChanged(QNetworkReply::NetworkError newError); void errorChanged(QNetworkReply::NetworkError newError);
void errorStringChanged(QString newErrorString); void errorStringChanged(QString newErrorString);
/**
* @brief Convenience signal for status == RemoteData.Ready.
*/
void ready();
public slots: 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: 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 setStatus(Status newStatus);
void setError(QNetworkReply::NetworkError error); void setError(QNetworkReply::NetworkError error);
void setErrorString(const QString &newErrorString); void setErrorString(const QString &newErrorString);
@ -139,6 +169,33 @@ private:
QString m_errorString; 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 { class MediaStream : public JsonSerializable {
Q_OBJECT Q_OBJECT
public: public:
@ -365,12 +422,12 @@ signals:
void imageBlurHashesChanged(); void imageBlurHashesChanged();
public slots: public slots:
/**
* @brief (Re)loads the item from the Jellyfin server.
*/
void reload() override;
void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData); void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData);
protected: protected:
// Overrides
QString getDataUrl() const override;
bool canReload() const override;
QString m_id; QString m_id;
QString m_name; QString m_name;
QString m_originalTitle; QString m_originalTitle;

View File

@ -198,6 +198,7 @@ RemoteData::RemoteData(QObject *parent) : JsonSerializable (parent) {}
void RemoteData::setStatus(Status newStatus) { void RemoteData::setStatus(Status newStatus) {
m_status = newStatus; m_status = newStatus;
emit statusChanged(newStatus); emit statusChanged(newStatus);
if (newStatus == Ready) emit ready();
} }
void RemoteData::setError(QNetworkReply::NetworkError error) { void RemoteData::setError(QNetworkReply::NetworkError error) {
@ -216,6 +217,54 @@ void RemoteData::setApiClient(ApiClient *newApiClient) {
reload(); 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<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&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::MediaStream(QObject *parent) : JsonSerializable (parent) {} MediaStream::MediaStream(QObject *parent) : JsonSerializable (parent) {}
MediaStream::MediaStream(const MediaStream &other) 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) { void Item::setJellyfinId(QString newId) {
m_id = newId.trimmed(); 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<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&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> userData) { void Item::onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData) {
if (itemId != m_id || m_userData == nullptr) return; if (itemId != m_id || m_userData == nullptr) return;
@ -330,6 +351,7 @@ void Item::onUserDataChanged(const QString &itemId, QSharedPointer<UserData> use
void registerSerializableJsonTypes(const char* URI) { void registerSerializableJsonTypes(const char* URI) {
qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream"); qmlRegisterType<MediaStream>(URI, 1, 0, "MediaStream");
qmlRegisterType<User>(URI, 1, 0, "User");
qmlRegisterType<UserData>(URI, 1, 0, "UserData"); qmlRegisterType<UserData>(URI, 1, 0, "UserData");
qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem"); qmlRegisterType<Item>(URI, 1, 0, "JellyfinItem");
} }

View File

@ -51,12 +51,13 @@ Page {
} }
// Tell SilicaFlickable the height of its content. // 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 // Place our content in a Column. The PageHeader is always placed at the top
// of the page, followed by our content. // of the page, followed by our content.
Column { Column {
id: column id: column
visible: mediaLibraryModel.status != ApiModel.Error
width: page.width width: page.width
//spacing: Theme.paddingLarge //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 { Column {
text: qsTr("An error has occurred. Please try again.") id: errorColumn
} width: parent.width
Item { width: 1; height: Theme.paddingLarge } visible: mediaLibraryModel.status == ApiModel.Error
Button { PageHeader {
text: qsTr("Retry") title: qsTr("Network error")
anchors.horizontalCenter: parent.horizontalCenter
onClicked: loadModels(true)
}
Item { width: 1; height: Theme.paddingLarge }
} }
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 }
} }
} }

View File

@ -48,24 +48,54 @@ Page {
text: qsTr("Session") text: qsTr("Session")
} }
PlainLabel { Item {
text: qsTr("Server") 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 { Label {
text: ApiClient.baseUrl id: user
color: Theme.secondaryHighlightColor 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; } Item { width: 1; height: Theme.paddingLarge; }

View File

@ -68,6 +68,31 @@ Page {
running: pageRoot._loading 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 { JellyfinItem {
id: jItem id: jItem
apiClient: ApiClient apiClient: ApiClient

View File

@ -42,6 +42,8 @@ BaseDetailPage {
cellWidth: Constants.libraryDelegateWidth cellWidth: Constants.libraryDelegateWidth
cellHeight: Utils.usePortraitCover(itemData.CollectionType) ? Constants.libraryDelegatePosterHeight cellHeight: Utils.usePortraitCover(itemData.CollectionType) ? Constants.libraryDelegatePosterHeight
: Constants.libraryDelegateHeight : Constants.libraryDelegateHeight
visible: itemData.status !== JellyfinItem.Error
header: PageHeader { header: PageHeader {
title: itemData.name || qsTr("Loading") title: itemData.name || qsTr("Loading")
} }

View File

@ -34,9 +34,16 @@ BaseDetailPage {
fields: ["Overview"] fields: ["Overview"]
} }
Connections {
target: itemData
onReady: episodeModel.reload()
}
SilicaListView { SilicaListView {
anchors.fill: parent anchors.fill: parent
contentHeight: content.height contentHeight: content.height
visible: itemData.status !== JellyfinItem.Error
header: PageHeader { header: PageHeader {
title: itemData.name title: itemData.name
description: itemData.seriesName description: itemData.seriesName

View File

@ -28,6 +28,7 @@ BaseDetailPage {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
contentHeight: content.height contentHeight: content.height
visible: itemData.status !== JellyfinItem.Error
Column { Column {
id: content id: content
@ -68,6 +69,10 @@ BaseDetailPage {
show: itemData.jellyfinId show: itemData.jellyfinId
onShowChanged: reload() onShowChanged: reload()
} }
Connections {
target: itemData
onReady: showSeasonsModel.reload()
}
SilicaListView { SilicaListView {
model: showSeasonsModel model: showSeasonsModel

View File

@ -23,6 +23,7 @@ import Sailfish.Silica 1.0
BaseDetailPage { BaseDetailPage {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
visible: itemData.status !== JellyfinItem.Error
PageHeader { PageHeader {
title: itemData.name title: itemData.name
} }

View File

@ -36,6 +36,7 @@ BaseDetailPage {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
contentHeight: content.height + Theme.paddingLarge contentHeight: content.height + Theme.paddingLarge
visible: itemData.status !== JellyfinItem.Error
VerticalScrollDecorator {} VerticalScrollDecorator {}

View File

@ -62,6 +62,17 @@
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
</context> </context>
<context>
<name>BaseDetailPage</name>
<message>
<source>Retry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>An error has occured</source>
<translation type="unfinished"></translation>
</message>
</context>
<context> <context>
<name>CollectionPage</name> <name>CollectionPage</name>
<message> <message>
@ -259,14 +270,6 @@
<source>Session</source> <source>Session</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>Server</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>User id</source>
<translation type="unfinished"></translation>
</message>
<message> <message>
<source>Log out</source> <source>Log out</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>