From 0c0b91dc4beac3c02e566c5c60d538c057d2b2cc Mon Sep 17 00:00:00 2001 From: Henk Kalkwater Date: Thu, 28 Jul 2022 19:55:33 +0200 Subject: [PATCH 1/2] Add artist overview page --- core/include/JellyfinQt/support/jsonconv.h | 1 + core/include/JellyfinQt/support/loader.h | 1 + core/include/JellyfinQt/viewmodel/item.h | 29 ++ core/src/support/jsonconv.cpp | 4 + rpm/harbour-sailfin.changes | 4 + sailfish/CMakeLists.txt | 1 + sailfish/qml/Constants.qml | 13 + sailfish/qml/Utils.js | 39 ++- sailfish/qml/components/RemoteImage.qml | 4 +- sailfish/qml/pages/MainPage.qml | 1 + .../qml/pages/itemdetails/CollectionPage.qml | 38 ++- .../qml/pages/itemdetails/MusicArtistPage.qml | 287 ++++++++++++++++++ 12 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 sailfish/qml/pages/itemdetails/MusicArtistPage.qml diff --git a/core/include/JellyfinQt/support/jsonconv.h b/core/include/JellyfinQt/support/jsonconv.h index 0ef4663..616c85f 100644 --- a/core/include/JellyfinQt/support/jsonconv.h +++ b/core/include/JellyfinQt/support/jsonconv.h @@ -57,6 +57,7 @@ extern template QString toString(const float &source, convertType); extern template QString toString(const double &source, convertType); extern template QString toString(const bool &source, convertType); extern template QString toString(const QString &source, convertType); +extern template QString toString(const QStringList &source, convertType); } // NS Support } // NS Jellyfin diff --git a/core/include/JellyfinQt/support/loader.h b/core/include/JellyfinQt/support/loader.h index b6853d1..0487d10 100644 --- a/core/include/JellyfinQt/support/loader.h +++ b/core/include/JellyfinQt/support/loader.h @@ -198,6 +198,7 @@ public: if (m_reply->isRunning()) { m_reply->abort(); m_reply->deleteLater(); + m_reply = nullptr; } } diff --git a/core/include/JellyfinQt/viewmodel/item.h b/core/include/JellyfinQt/viewmodel/item.h index d545a1a..deb42d9 100644 --- a/core/include/JellyfinQt/viewmodel/item.h +++ b/core/include/JellyfinQt/viewmodel/item.h @@ -121,6 +121,15 @@ public: Q_PROPERTY(QJsonObject imageTags READ imageTags NOTIFY imageTagsChanged) Q_PROPERTY(QStringList backdropImageTags READ backdropImageTags NOTIFY backdropImageTagsChanged) Q_PROPERTY(QJsonObject imageBlurHashes READ imageBlurHashes NOTIFY imageBlurHashesChanged) + Q_PROPERTY(int trailerCount READ trailerCount NOTIFY trailerCountChanged) + Q_PROPERTY(int movieCount READ movieCount NOTIFY movieCountChanged) + Q_PROPERTY(int seriesCount READ seriesCount NOTIFY seriesCountChanged) + Q_PROPERTY(int programCount READ programCount NOTIFY programCountChanged) + Q_PROPERTY(int episodeCount READ episodeCount NOTIFY episodeCountChanged) + Q_PROPERTY(int songCount READ songCount NOTIFY songCountChanged) + Q_PROPERTY(int albumCount READ albumCount NOTIFY albumCountChanged) + Q_PROPERTY(int artistCount READ artistCount NOTIFY artistCountChanged) + Q_PROPERTY(int musicVideoCount READ musicVideoCount NOTIFY musicVideoCountChanged) Q_PROPERTY(QString mediaType READ mediaType READ mediaType NOTIFY mediaTypeChanged) Q_PROPERTY(int width READ width NOTIFY widthChanged) Q_PROPERTY(int height READ height NOTIFY heightChanged) @@ -166,6 +175,17 @@ public: QStringList backdropImageTags() const { return m_data->backdropImageTags(); } QJsonObject imageBlurHashes() const { return m_data->imageBlurHashes(); } QString mediaType() const { return m_data->mediaType(); } + + int trailerCount() const { return m_data->trailerCount().value_or(0); } + int movieCount() const { return m_data->movieCount().value_or(0); } + int seriesCount() const { return m_data->seriesCount().value_or(0); } + int programCount() const { return m_data->programCount().value_or(0); } + int episodeCount() const { return m_data->episodeCount().value_or(0); } + int songCount() const { return m_data->songCount().value_or(0); } + int albumCount() const { return m_data->albumCount().value_or(0); } + int artistCount() const { return m_data->artistCount().value_or(0); } + int musicVideoCount() const { return m_data->musicVideoCount().value_or(0); } + int width() const { return m_data->width().value_or(0); } int height() const { return m_data->height().value_or(0); } @@ -226,6 +246,15 @@ signals: void imageTagsChanged(); void backdropImageTagsChanged(); void imageBlurHashesChanged(); + void trailerCountChanged(int newTrailerCount); + void movieCountChanged(int newMovieCount); + void seriesCountChanged(int newSeriesCount); + void programCountChanged(int newProgramCount); + void episodeCountChanged(int newEpisodeCount); + void songCountChanged(int newSongCount); + void albumCountChanged(int newAlbumCount); + void artistCountChanged(int newArtistCount); + void musicVideoCountChanged(int newMusicVideoCount); void mediaTypeChanged(const QString &newMediaType); void widthChanged(int newWidth); void heightChanged(int newHeight); diff --git a/core/src/support/jsonconv.cpp b/core/src/support/jsonconv.cpp index 349be26..d0c5b10 100644 --- a/core/src/support/jsonconv.cpp +++ b/core/src/support/jsonconv.cpp @@ -257,6 +257,10 @@ template <> QString toString(const QString &source, convertType) { return source; } +template <> +QString toString(const QStringList &source, convertType) { + return source.join(','); +} } // NS Support } // NS Jellyfin diff --git a/rpm/harbour-sailfin.changes b/rpm/harbour-sailfin.changes index e3e2e98..349249b 100644 --- a/rpm/harbour-sailfin.changes +++ b/rpm/harbour-sailfin.changes @@ -11,6 +11,10 @@ # * date Author's Name version-release # - Summary of changes +# +* Future version Chris Josten 0.??.??-1 +- New features + - New layout for artist pages * Wed Jul 20 2022 Chris Josten 0.4.2-1 - Bugfixes: diff --git a/sailfish/CMakeLists.txt b/sailfish/CMakeLists.txt index 7c417ed..85745e6 100644 --- a/sailfish/CMakeLists.txt +++ b/sailfish/CMakeLists.txt @@ -53,6 +53,7 @@ set(sailfin_QML_SOURCES qml/pages/itemdetails/EpisodePage.qml qml/pages/itemdetails/FilmPage.qml qml/pages/itemdetails/MusicAlbumPage.qml + qml/pages/itemdetails/MusicArtistPage.qml qml/pages/itemdetails/PhotoPage.qml qml/pages/itemdetails/SeasonPage.qml qml/pages/itemdetails/SeriesPage.qml diff --git a/sailfish/qml/Constants.qml b/sailfish/qml/Constants.qml index 48116d9..b4e3e71 100644 --- a/sailfish/qml/Constants.qml +++ b/sailfish/qml/Constants.qml @@ -23,6 +23,19 @@ import QtQuick 2.6 import Sailfish.Silica 1.0 QtObject { + + readonly property int gridColumns: { + switch(Screen.sizeCategory) { + case Screen.Small: + case Screen.Medium: + return 3 + case Screen.Large: + return 5 + case Screen.ExtraLarge: + return 7 + } + } + readonly property real libraryDelegateWidth: { switch(Screen.sizeCategory) { case Screen.Small: diff --git a/sailfish/qml/Utils.js b/sailfish/qml/Utils.js index 5be3e26..1d4b183 100644 --- a/sailfish/qml/Utils.js +++ b/sailfish/qml/Utils.js @@ -47,6 +47,32 @@ function ticksToText(ticks, showHours) { return timeToText(ticks / 10000, showHours); } +function propsToQuery(options) { + var query = ""; + for (var prop in options) { + if (options.hasOwnProperty(prop)) { + var value = options[prop]; + if (prop === "maxWidth" || prop === "maxHeight") { + value = Math.floor(options[prop]); + } + query += "&" + prop + "=" + value; + } + } + return query; +} + +function randomBackdrop(baseUrl, item) { + var count = item.backdropImageTags.length; + if (count === 0) return -1 + return Math.floor(Math.random() * count); +} + +function itemBackdropUrl(baseUrl, item, idx, options) { + var extraQuery = propsToQuery(options) + return baseUrl + "/Items/" + item.jellyfinId + "/Images/Backdrop/" + idx + "?tag=" + item.backdropImageTags[idx] + extraQuery; +} + + function itemImageUrl(baseUrl, item, type, options) { if (item === null || !item.imageTags[type]) { return "" } return itemModelImageUrl(baseUrl, item.jellyfinId, item.imageTags[type], type, options) @@ -54,16 +80,7 @@ function itemImageUrl(baseUrl, item, type, options) { function itemModelImageUrl(baseUrl, itemId, tag, type, options) { if (tag === undefined) return "" - var extraQuery = ""; - for (var prop in options) { - if (options.hasOwnProperty(prop)) { - var value = options[prop]; - if (prop === "maxWidth" || prop === "maxHeight") { - value = Math.floor(options[prop]); - } - extraQuery += "&" + prop + "=" + value; - } - } + var extraQuery = propsToQuery(options) return baseUrl + "/Items/" + itemId + "/Images/" + type + "?tag=" + tag + extraQuery } @@ -88,6 +105,8 @@ function getPageUrl(mediaType, itemType, isFolder) { return Qt.resolvedUrl("pages/itemdetails/SeasonPage.qml") case "episode": return Qt.resolvedUrl("pages/itemdetails/EpisodePage.qml") + case "musicartist": + return Qt.resolvedUrl("pages/itemdetails/MusicArtistPage.qml") case "musicalbum": case "playlist": return Qt.resolvedUrl("pages/itemdetails/MusicAlbumPage.qml") diff --git a/sailfish/qml/components/RemoteImage.qml b/sailfish/qml/components/RemoteImage.qml index 0f12c68..9db5f0a 100644 --- a/sailfish/qml/components/RemoteImage.qml +++ b/sailfish/qml/components/RemoteImage.qml @@ -90,8 +90,8 @@ SilicaItem { id: blurhashImage anchors.fill: parent fillMode: root.fillMode - sourceSize.height: 32 - sourceSize.width: 32 * aspectRatio + sourceSize.height: 16 + sourceSize.width: 16 * aspectRatio source: blurhash.length > 0 ? "image://blurhash/" + encodeURIComponent(blurhash) : "" opacity: 0 } diff --git a/sailfish/qml/pages/MainPage.qml b/sailfish/qml/pages/MainPage.qml index d895271..d279af0 100644 --- a/sailfish/qml/pages/MainPage.qml +++ b/sailfish/qml/pages/MainPage.qml @@ -171,6 +171,7 @@ Page { onStatusChanged: { if (status == PageStatus.Active) { appWindow.itemData = null + //appWindow.navigateToItem("14b92f36bfc877ae741079fef49a3d80", "MusicArtist", "MusicArtist", true) } } diff --git a/sailfish/qml/pages/itemdetails/CollectionPage.qml b/sailfish/qml/pages/itemdetails/CollectionPage.qml index 9a1a33c..ffd6c2b 100644 --- a/sailfish/qml/pages/itemdetails/CollectionPage.qml +++ b/sailfish/qml/pages/itemdetails/CollectionPage.qml @@ -28,6 +28,10 @@ BaseDetailPage { id: pageRoot property bool _collectionModelLoaded: false + property bool allowSort: true + property var modelStatus: collectionModel.loader.modelStatus + property string pageTitle: itemData.name + property alias loader: collectionModel.loader J.ItemModel { id: collectionModel @@ -35,13 +39,22 @@ BaseDetailPage { id: collectionLoader apiClient: appWindow.apiClient parentId: itemData.jellyfinId - autoReload: itemData.jellyfinId.length > 0 && (pageRoot.status == PageStatus.Active || _collectionModelLoaded) - //onParentIdChanged: if (parentId.length > 0) reload() sortBy: "SortName" - onStatusChanged: { - if (status === J.ModelStatus.Ready) { - _collectionModelLoaded = true - } + autoReload: itemData.jellyfinId.length > 0 && (pageRoot.status == PageStatus.Active || _collectionModelLoaded) + } + } + + Binding { + target: collectionModel.loader + property: "autoReload" + value: (pageRoot.status == PageStatus.Active || pageRoot._collectionModelLoaded) + } + + Connections { + target: collectionModel.loader + onStatusChanged: { + if (status === J.ModelStatus.Ready) { + _collectionModelLoaded = true } } } @@ -56,16 +69,19 @@ BaseDetailPage { visible: itemData.status !== J.ItemLoader.Error header: PageHeader { - title: itemData.name || qsTr("Loading") + title: pageTitle || qsTr("Loading") } PullDownMenu { id: downMenu + visible: visibleChildren.length > 0 MenuItem { + id: sortMenuItem + visible: pageRoot.allowSort //: Menu item for selecting the sort order of a collection text: qsTr("Sort by") onClicked: pageStack.push(sortPageComponent) } - busy: collectionLoader.status === J.ModelStatus.Loading + busy: modelStatus === J.ModelStatus.Loading } add: Transition { id: trans @@ -169,9 +185,9 @@ BaseDetailPage { onClicked: openMenu() function apply(field, order) { - collectionLoader.sortBy = field; - collectionLoader.sortOrder = order; - collectionLoader.reload() + collectionModel.loader.sortBy = field; + collectionModel.loader.sortOrder = order; + collectionModel.loader.reload() pageStack.pop() } } diff --git a/sailfish/qml/pages/itemdetails/MusicArtistPage.qml b/sailfish/qml/pages/itemdetails/MusicArtistPage.qml new file mode 100644 index 0000000..414628d --- /dev/null +++ b/sailfish/qml/pages/itemdetails/MusicArtistPage.qml @@ -0,0 +1,287 @@ +/* +Sailfin: a Jellyfin client written using Qt +Copyright (C) 2022 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 +*/ +import QtQuick 2.6 +import QtQuick.Layouts 1.1 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 as J + +import "../../components" +import "../.." + +BaseDetailPage { + id: albumPage + readonly property int _maxItems: 12 + + J.ItemModel { + id: albumsModel + loader: J.UserItemsLoader { + apiClient: appWindow.apiClient + sortBy: "PremiereDate,ProductionYear,SortName" + sortOrder: "Descending" + fields: [J.ItemFields.ItemCounts, J.ItemFields.PrimaryImageAspectRatio] + includeItemTypes: ["MusicAlbum"] + albumArtistIds: itemData.jellyfinId + recursive: true + autoReload: itemData.jellyfinId.length > 0 + limit: _maxItems + } + } + + Component { + id: fullAlbumsModelComponent + J.UserItemsLoader { + apiClient: appWindow.apiClient + sortBy: "PremiereDate,ProductionYear,SortName" + sortOrder: "Descending" + fields: [J.ItemFields.ItemCounts, J.ItemFields.PrimaryImageAspectRatio] + includeItemTypes: ["MusicAlbum"] + albumArtistIds: itemData.jellyfinId + recursive: true + autoReload: false + } + } + + J.ItemModel { + id: appearsOnModel + loader: J.UserItemsLoader { + apiClient: appWindow.apiClient + sortBy: "PremiereDate,ProductionYear,SortName" + sortOrder: "Descending" + fields: [J.ItemFields.ItemCounts, J.ItemFields.PrimaryImageAspectRatio] + includeItemTypes: ["MusicAlbum"] + contributingArtistIds: itemData.jellyfinId + recursive: true + autoReload: itemData.jellyfinId.length > 0 + limit: _maxItems + } + } + Component { + id: fullAppearsOnModelComponent + J.UserItemsLoader { + apiClient: appWindow.apiClient + sortBy: "PremiereDate,ProductionYear,SortName" + sortOrder: "Descending" + fields: [J.ItemFields.ItemCounts, J.ItemFields.PrimaryImageAspectRatio] + includeItemTypes: ["MusicAlbum"] + contributingArtistIds: itemData.jellyfinId + recursive: true + autoReload: false + } + } + + + SilicaFlickable { + anchors.fill: parent + contentHeight: content.height + + Column { + id: content + width: parent.width + + Item { + id: header + width: parent.width + height: backdrop.height + title.height + + RemoteImage { + id: backdrop + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: width / 16 * 9 + fillMode: Image.PreserveAspectCrop + source: Utils.itemBackdropUrl(apiClient.baseUrl, itemData, 0, {"maxWidth": parent.width}) + blurhash: itemData.imageBlurHashes["Backdrop"][itemData.backdropImageTags[0]] + } + + Shim { + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: Theme.itemSizeHuge + upsideDown: true + shimOpacity: Theme.opacityOverlay + } + + RemoteImage { + id: artistImage + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + bottom: title.bottom + } + source: Utils.itemImageUrl(apiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width}) + blurhash: itemData.imageBlurHashes["Primary"][itemData.imageTags["Primary"]] + width: Constants.libraryDelegateWidth + height: width / itemData.primaryImageAspectRatio + fallbackColor: Utils.colorFromString(itemData.name) + } + + PageHeader { + id: title + title: itemData.name + description: qsTr("%1 songs | %2 albums") + .arg(itemData.songCount) + .arg(itemData.albumCount) + anchors { + top: backdrop.bottom + left: parent.left + right: artistImage.left + } + } + } + // Spacer + Item { height: Theme.paddingLarge; width: 1; visible: !aboutBackground.visible } + BackgroundItem { + property bool _expanded: false + id: aboutBackground + anchors { + left: parent.left + right: parent.right + } + height: about.height + Theme.paddingLarge + clip: true + onClicked: aboutBackground._expanded = !aboutBackground._expanded + visible: aboutLabel.text.length > 0 + //Behavior on height { SmoothedAnimation { duration: 300; } } + + Item { + id: about + anchors { + left: parent.left + leftMargin: Theme.horizontalPageMargin + right: parent.right + rightMargin: Theme.horizontalPageMargin + } + height: aboutLabel.height + Label { + id: aboutLabel + anchors { + left: parent.left + right: parent.right + } + topPadding: Theme.paddingLarge + bottomPadding: Theme.paddingLarge + color: aboutBackground.highlighted ? Theme.highlightColor : Theme.primaryColor + text: itemData.overview + wrapMode: Text.WordWrap + height: aboutBackground._expanded ? implicitHeight : Math.min(font.pixelSize * lineHeight * 8, implicitHeight) + Behavior on height { SmoothedAnimation { id: expandAnimation; duration: 300; } } + textFormat: Text.PlainText + } + OpacityRampEffect { + enabled: !aboutBackground._expanded || expandAnimation.running + offset: aboutBackground._expanded ? 1.0 : 0.5 + sourceItem: aboutLabel + direction: OpacityRamp.TopToBottom + } + } + HighlightImage { + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + bottom: parent.bottom + bottomMargin: Theme.paddingMedium + } + + source: "image://theme/icon-lock-more" + } + } + + MoreSection { + text: qsTr("Discography") + visible: albumRepeater.count > 0 + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": fullAlbumsModelComponent.createObject(albumPage), + "allowSort": false, + //: Page title for the page with an overview of all albums, eps and singles by a specific artist + "pageTitle": qsTr("Discography of %1").arg(itemData.name) + }) + } + GridLayout { + width: parent.width + columns: 3 + columnSpacing: 0 + rowSpacing: 0 + anchors { + left: parent.left + right: parent.right + } + Repeater { + id: albumRepeater + model: albumsModel + + LibraryItemDelegate { + readonly property int _multiplier: index === 0 ? 2 : 1 + poster: Utils.itemModelImageUrl(appWindow.apiClient.baseUrl, model.jellyfinId, model.imageTags["Primary"], "Primary", {"height": height}) + blurhash: model.imageBlurHashes["Primary"][model.imageTags["Primary"]] + title: model.name + Layout.preferredWidth: Constants.libraryDelegateWidth * _multiplier + Layout.preferredHeight: Constants.libraryDelegateHeight * _multiplier + Layout.rowSpan: _multiplier + Layout.columnSpan: _multiplier + onClicked: appWindow.navigateToItem(model.jellyfinId, model.mediaType, model.type, model.isFolder) + } + } + } + MoreSection { + text: qsTr("Appears on") + visible: appearsOnRepeater.count > 0 + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": fullAppearsOnModelComponent.createObject(albumPage), + "allowSort": false, + //: Page title for the page with an overview of all albums a specific artist appears on + "pageTitle": qsTr("%1 appears on").arg(itemData.name) + }) + } + GridLayout { + width: parent.width + columns: 3 + columnSpacing: 0 + rowSpacing: 0 + anchors { + left: parent.left + right: parent.right + } + Repeater { + id: appearsOnRepeater + model: appearsOnModel + + LibraryItemDelegate { + readonly property int _multiplier: 1 + poster: Utils.itemModelImageUrl(appWindow.apiClient.baseUrl, model.jellyfinId, model.imageTags["Primary"], "Primary", {"height": height}) + blurhash: model.imageBlurHashes["Primary"][model.imageTags["Primary"]] + title: model.name + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.preferredWidth: Constants.libraryDelegateWidth * _multiplier + Layout.preferredHeight: Constants.libraryDelegateHeight * _multiplier + Layout.fillWidth: false + Layout.fillHeight: false + onClicked: appWindow.navigateToItem(model.jellyfinId, model.mediaType, model.type, model.isFolder) + } + } + } + } + } +} From dc9c3ea1b85dbb9800905c17b5e9962bbb74ccd6 Mon Sep 17 00:00:00 2001 From: Henk Kalkwater Date: Fri, 29 Jul 2022 14:26:25 +0200 Subject: [PATCH 2/2] Add music library page --- core/include/JellyfinQt/apimodel.h | 2 + core/include/JellyfinQt/viewmodel/itemmodel.h | 36 +++++ core/src/apimodel.cpp | 10 ++ core/src/jellyfin.cpp | 1 + core/src/viewmodel/itemmodel.cpp | 6 +- sailfish/CMakeLists.txt | 4 +- sailfish/qml/Utils.js | 7 + .../qml/components/ItemChildrenShowcase.qml | 60 ++++++++ sailfish/qml/pages/MainPage.qml | 119 +++------------ .../qml/pages/itemdetails/CollectionPage.qml | 3 +- .../pages/itemdetails/MusicLibraryPage.qml | 138 ++++++++++++++++++ 11 files changed, 284 insertions(+), 102 deletions(-) create mode 100644 sailfish/qml/components/ItemChildrenShowcase.qml create mode 100644 sailfish/qml/pages/itemdetails/MusicLibraryPage.qml diff --git a/core/include/JellyfinQt/apimodel.h b/core/include/JellyfinQt/apimodel.h index f1aa95e..b4860c4 100644 --- a/core/include/JellyfinQt/apimodel.h +++ b/core/include/JellyfinQt/apimodel.h @@ -253,6 +253,8 @@ extern template void setRequestLimit(Loader::GetPublicUsersParams ¶ms, int l extern template bool setRequestStartIndex(Loader::GetPublicUsersParams ¶ms, int offset); extern template void setRequestLimit(Loader::GetNextUpParams ¶ms, int limit); extern template bool setRequestStartIndex(Loader::GetNextUpParams ¶ms, int offset); +extern template void setRequestLimit(Loader::GetAlbumArtistsParams ¶ms, int limit); +extern template bool setRequestStartIndex(Loader::GetAlbumArtistsParams ¶ms, int offset); extern template QList extractRecords(const QList &result); extern template int extractTotalRecordCount(const QList &result); diff --git a/core/include/JellyfinQt/viewmodel/itemmodel.h b/core/include/JellyfinQt/viewmodel/itemmodel.h index b3385bf..8024770 100644 --- a/core/include/JellyfinQt/viewmodel/itemmodel.h +++ b/core/include/JellyfinQt/viewmodel/itemmodel.h @@ -290,6 +290,42 @@ public: FWDPROP(QString, seriesId, SeriesId) }; +using AlbumArtistLoaderBase = AbstractUserParameterLoader; +class AlbumArtistLoader : public AlbumArtistLoaderBase { + Q_OBJECT +public: + explicit AlbumArtistLoader(QObject *parent = nullptr); + + FWDLISTPROP(Jellyfin::DTO::ImageTypeClass::Value, enableImageTypes, EnableImageTypes); + FWDPROP(bool, enableImages, EnableImages) + FWDPROP(bool, enableTotalRecordCount, EnableTotalRecordCount) + FWDPROP(bool, enableUserData, EnableUserData) + FWDPROP(QStringList, excludeItemTypes, ExcludeItemTypes) + FWDLISTPROP(Jellyfin::DTO::ItemFieldsClass::Value, fields, Fields) + FWDLISTPROP(Jellyfin::DTO::ItemFilterClass::Value, filters, Filters) + FWDPROP(QStringList, genreIds, GenreIds) + FWDPROP(QStringList, genres, Genres) + FWDPROP(qint32, imageTypeLimit, ImageTypeLimit) + FWDPROP(QStringList, includeItemTypes, IncludeItemTypes) + FWDPROP(bool, isFavorite, IsFavorite) + FWDPROP(int, limit, Limit) + FWDPROP(QStringList, mediaTypes, MediaTypes) + FWDPROP(double, minCommunityRating, MinCommunityRating) + FWDPROP(QString, nameLessThan, NameLessThan) + FWDPROP(QString, nameStartsWith, NameStartsWith) + FWDPROP(QString, nameStartsWithOrGreater, NameStartsWithOrGreater) + FWDPROP(QStringList, officialRatings, OfficialRatings) + FWDPROP(QString, parentId, ParentId) + FWDPROP(QStringList, personIds, PersonIds) + FWDPROP(QStringList, personTypes, PersonTypes) + FWDPROP(QString, searchTerm, SearchTerm) + FWDPROP(int, startIndex, StartIndex) + FWDPROP(QStringList, studioIds, StudioIds) + FWDPROP(QStringList, studios, Studios) + FWDPROP(QStringList, tags, Tags) + FWDPROP(QString, userId, UserId) + FWDLISTPROP(int, years, Years); +}; /** * @brief Base class for each model that works with items. diff --git a/core/src/apimodel.cpp b/core/src/apimodel.cpp index 739642c..ed6d90e 100644 --- a/core/src/apimodel.cpp +++ b/core/src/apimodel.cpp @@ -184,6 +184,16 @@ bool setRequestStartIndex(Loader::GetNextUpParams ¶ms, int offset) { return true; } +template<> +void setRequestLimit(Loader::GetAlbumArtistsParams ¶ms, int limit) { + params.setLimit(limit); +} +template<> +bool setRequestStartIndex(Loader::GetAlbumArtistsParams ¶ms, int offset) { + params.setStartIndex(offset); + return true; +} + template<> QList extractRecords(const QList &result) { return result; diff --git a/core/src/jellyfin.cpp b/core/src/jellyfin.cpp index 3d95be2..609ffc9 100644 --- a/core/src/jellyfin.cpp +++ b/core/src/jellyfin.cpp @@ -77,6 +77,7 @@ void JellyfinPlugin::registerTypes(const char *uri) { qmlRegisterType(uri, 1, 0, "ShowEpisodesLoader"); qmlRegisterType(uri, 1, 0, "NextUpLoader"); qmlRegisterType(uri, 1, 0, "PublicUsersLoader"); + qmlRegisterType(uri, 1, 0, "AlbumArtistLoader"); // Enumerations qmlRegisterUncreatableType(uri, 1, 0, "GeneralCommandType", "Is an enum"); diff --git a/core/src/viewmodel/itemmodel.cpp b/core/src/viewmodel/itemmodel.cpp index f94f074..cf4907e 100644 --- a/core/src/viewmodel/itemmodel.cpp +++ b/core/src/viewmodel/itemmodel.cpp @@ -18,6 +18,7 @@ */ #include "JellyfinQt/viewmodel/itemmodel.h" +#include "JellyfinQt/loader/http/artists.h" #include "JellyfinQt/loader/http/items.h" #include "JellyfinQt/loader/http/userlibrary.h" #include "JellyfinQt/loader/http/userviews.h" @@ -57,6 +58,9 @@ ShowEpisodesLoader::ShowEpisodesLoader(QObject *parent) NextUpLoader::NextUpLoader(QObject *parent) : NextUpLoaderBase(new Jellyfin::Loader::HTTP::GetNextUpLoader(), parent) {} +AlbumArtistLoader::AlbumArtistLoader(QObject *parent) + : AlbumArtistLoaderBase(new Jellyfin::Loader::HTTP::GetAlbumArtistsLoader(), parent) {} + ItemModel::ItemModel(QObject *parent) : ApiModel(parent) { connect(this, &QAbstractItemModel::rowsInserted, this, &ItemModel::onInsertItems); @@ -128,7 +132,7 @@ QSharedPointer ItemModel::itemAt(int index) { void ItemModel::onInsertItems(const QModelIndex &parent, int start, int end) { if (parent.isValid()) return; - qDebug() << "Connecting " << (end - start + 1) << "items!"; + //qDebug() << "Connecting " << (end - start + 1) << "items!"; for (int i = start; i <= end; i++) { connect(itemAt(i).data(), &Model::Item::userDataChanged, this, &ItemModel::onUserDataUpdated); } diff --git a/sailfish/CMakeLists.txt b/sailfish/CMakeLists.txt index 85745e6..5018548 100644 --- a/sailfish/CMakeLists.txt +++ b/sailfish/CMakeLists.txt @@ -26,6 +26,7 @@ set(sailfin_QML_SOURCES qml/components/videoplayer/VideoError.qml qml/components/videoplayer/VideoHud.qml qml/components/IconListItem.qml + qml/components/ItemChildrenShowcase.qml qml/components/JItem.qml qml/components/LibraryItemDelegate.qml qml/components/MoreSection.qml @@ -53,7 +54,8 @@ set(sailfin_QML_SOURCES qml/pages/itemdetails/EpisodePage.qml qml/pages/itemdetails/FilmPage.qml qml/pages/itemdetails/MusicAlbumPage.qml - qml/pages/itemdetails/MusicArtistPage.qml + qml/pages/itemdetails/MusicArtistPage.qml + qml/pages/itemdetails/MusicLibraryPage.qml qml/pages/itemdetails/PhotoPage.qml qml/pages/itemdetails/SeasonPage.qml qml/pages/itemdetails/SeriesPage.qml diff --git a/sailfish/qml/Utils.js b/sailfish/qml/Utils.js index 1d4b183..049c5f5 100644 --- a/sailfish/qml/Utils.js +++ b/sailfish/qml/Utils.js @@ -112,6 +112,13 @@ function getPageUrl(mediaType, itemType, isFolder) { return Qt.resolvedUrl("pages/itemdetails/MusicAlbumPage.qml") case "photo": return Qt.resolvedUrl("pages/itemdetails/PhotoPage.qml") + case "collectionfolder": + // TODO: support for other collection folders + switch(mediaType.toLowerCase()) { + case "music": + return Qt.resolvedUrl("pages/itemdetails/MusicLibraryPage.qml") + } + // FALLTRHOUGH default: switch (mediaType ? mediaType.toLowerCase() : isFolder ? "folder" : "") { case "folder": diff --git a/sailfish/qml/components/ItemChildrenShowcase.qml b/sailfish/qml/components/ItemChildrenShowcase.qml new file mode 100644 index 0000000..93084e5 --- /dev/null +++ b/sailfish/qml/components/ItemChildrenShowcase.qml @@ -0,0 +1,60 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 as J + +import "../" + +MoreSection { + id: header + busy: itemModel.loader.status === J.ModelStatus.Loading || extraBusy + property bool extraBusy: false + property alias loader: itemModel.loader + property string collectionType + property bool collapseWhenEmpty: true + + J.ItemModel { + id: itemModel + } + + SilicaListView { + readonly property bool isPortrait: Utils.usePortraitCover(collectionType) + id: list + clip: true + height: { + if (count > 0 || !collapseWhenEmpty) { + if (isPortrait) { + Constants.libraryDelegatePosterHeight + } else { + Constants.libraryDelegateHeight + } + } else { + 0 + } + } + Behavior on height { + NumberAnimation { easing.type: Easing.OutQuad; duration: 300 } + } + model: itemModel + width: parent.width + orientation: ListView.Horizontal + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + spacing: Theme.paddingLarge + delegate: LibraryItemDelegate { + property string id: model.jellyfinId + title: model.name + poster: Utils.itemModelImageUrl(appWindow.apiClient.baseUrl, model.jellyfinId, model.imageTags["Primary"], "Primary", {"height": height}) + Binding on blurhash { + when: poster != "" + value: model.imageBlurHashes["Primary"][model.imageTags["Primary"]] + } + landscape: !list.isPortrait + progress: (typeof model.userDataPlayedProgress !== 0.0) ? model.userDataPlayedPercentage / 100 : 0.0 + + onClicked: { + appWindow.navigateToItem(model.jellyfinId, model.mediaType, model.type, model.isFolder); + } + } + } +} diff --git a/sailfish/qml/pages/MainPage.qml b/sailfish/qml/pages/MainPage.qml index d279af0..e894d75 100644 --- a/sailfish/qml/pages/MainPage.qml +++ b/sailfish/qml/pages/MainPage.qml @@ -78,73 +78,40 @@ Page { } } - MoreSection { + ItemChildrenShowcase { //- Section header for films and TV shows that an user hasn't completed yet. text: qsTr("Resume watching") clickable: false - busy: userResumeLoader.status === J.ModelStatus.Loading - Loader { - width: parent.width - sourceComponent: carrouselView - property alias itemModel: userResumeModel - property string collectionType: "series" - - J.ItemModel { - id: userResumeModel - loader: J.ResumeItemsLoader { - id: userResumeLoader - apiClient: appWindow.apiClient - limit: 12 - //recursive: true - } - } + loader: J.ResumeItemsLoader { + id: userResumeLoader + apiClient: appWindow.apiClient + limit: 12 + //recursive: true } } - MoreSection { + ItemChildrenShowcase { //- Section header for next episodes in a TV show that an user was watching. text: qsTr("Next up") clickable: false - busy: showNextUpLoader.status === J.ModelStatus.Loading - - Loader { - width: parent.width - sourceComponent: carrouselView - property alias itemModel: showNextUpModel - property string collectionType: "series" - - J.ItemModel { - id: showNextUpModel - loader: J.NextUpLoader { - id: showNextUpLoader - apiClient: appWindow.apiClient - enableUserData: true - } - } + loader: J.NextUpLoader { + id: showNextUpLoader + apiClient: appWindow.apiClient + enableUserData: true } } Repeater { model: mediaLibraryModel - MoreSection { + ItemChildrenShowcase { text: model.name - busy: userItemModel.status !== J.UsersViewsLoader.Ready - onHeaderClicked: appWindow.navigateToItem(model.jellyfinId, model.mediaType, model.type, model.isFolder); - Loader { - width: parent.width - sourceComponent: carrouselView - property alias itemModel: userItemModel - property string collectionType: model.collectionType || "" - - J.ItemModel { - id: userItemModel - loader: J.LatestMediaLoader { - apiClient: appWindow.apiClient - parentId: jellyfinId - } - } - Connections { - target: mediaLibraryLoader - onReady: userItemModel.reload() - } + onHeaderClicked: appWindow.navigateToItem(model.jellyfinId, model.collectionType, model.type, model.isFolder); + collectionType: model.collectionType || "" + loader: J.LatestMediaLoader { + apiClient: appWindow.apiClient + parentId: jellyfinId + } + Connections { + target: mediaLibraryLoader + onReady: loader.reload() } } } @@ -194,50 +161,6 @@ Page { } } - Component { - id: carrouselView - SilicaListView { - property bool isPortrait: Utils.usePortraitCover(collectionType) - id: list - clip: true - height: { - if (count > 0) { - if (isPortrait) { - Constants.libraryDelegatePosterHeight - } else { - Constants.libraryDelegateHeight - } - } else { - 0 - } - } - Behavior on height { - NumberAnimation { easing.type: Easing.OutQuad; duration: 300 } - } - model: itemModel - width: parent.width - orientation: ListView.Horizontal - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - spacing: Theme.paddingLarge - delegate: LibraryItemDelegate { - property string id: model.jellyfinId - title: model.name - poster: Utils.itemModelImageUrl(appWindow.apiClient.baseUrl, model.jellyfinId, model.imageTags["Primary"], "Primary", {"height": height}) - Binding on blurhash { - when: poster !== "" - value: model.imageBlurHashes["Primary"][model.imageTags["Primary"]] - } - landscape: !isPortrait - progress: (typeof model.userDataPlayedProgress !== 0.0) ? model.userDataPlayedPercentage / 100 : 0.0 - - onClicked: { - appWindow.navigateToItem(model.jellyfinId, model.mediaType, model.type, model.isFolder); - } - } - } - } - state: "default" states: [ State { diff --git a/sailfish/qml/pages/itemdetails/CollectionPage.qml b/sailfish/qml/pages/itemdetails/CollectionPage.qml index ffd6c2b..8723095 100644 --- a/sailfish/qml/pages/itemdetails/CollectionPage.qml +++ b/sailfish/qml/pages/itemdetails/CollectionPage.qml @@ -73,10 +73,9 @@ BaseDetailPage { } PullDownMenu { id: downMenu - visible: visibleChildren.length > 0 + visible: pageRoot.allowSort MenuItem { id: sortMenuItem - visible: pageRoot.allowSort //: Menu item for selecting the sort order of a collection text: qsTr("Sort by") onClicked: pageStack.push(sortPageComponent) diff --git a/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml b/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml new file mode 100644 index 0000000..2dda4f1 --- /dev/null +++ b/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml @@ -0,0 +1,138 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 as J + +import "../../components" +import "../.." + +BaseDetailPage { + id: musicLibraryPage + property bool _firstTimeLoaded: false + + onStatusChanged: { + if (status == PageStatus.Active) { + _firstTimeLoaded = true + } + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: content.height + + Component { + id: albumArtistLoaderComponent + J.AlbumArtistLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + autoReload: false + } + } + Component { + id: albumLoaderComponent + J.UserItemsLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + includeItemTypes: "MusicAlbum" + recursive: true + sortBy: "SortName" + autoReload: false + } + } + Component { + id: playlistLoaderComponent + J.UserItemsLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + includeItemTypes: "Playlist" + recursive: true + sortBy: "SortName" + autoReload: false + } + } + + Column { + id: content + width: parent.width + + PageHeader { + title: itemData.name + } + + ItemChildrenShowcase { + //: Header on music library: Recently added music albums + text: qsTr("Recently added") + //collapseWhenEmpty: false + extraBusy: !_firstTimeLoaded + clickable: false + loader: J.LatestMediaLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + autoReload: _firstTimeLoaded && itemData.jellyfinId.length > 0 + includeItemTypes: "Audio" + limit: 12 + } + + } + + ItemChildrenShowcase { + text: qsTr("Albums") + //collapseWhenEmpty: false + extraBusy: !_firstTimeLoaded + loader: J.UserItemsLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + includeItemTypes: "MusicAlbum" + autoReload: _firstTimeLoaded && itemData.jellyfinId.length > 0 + sortBy: "Random" + recursive: true + limit: 12 + } + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": albumLoaderComponent.createObject(musicLibraryPage), + //: Page title for the list of all albums within the music library + "pageTitle": qsTr("Albums") + }) + } + + ItemChildrenShowcase { + text: qsTr("Playlists") + //collapseWhenEmpty: false + extraBusy: !_firstTimeLoaded + loader: J.UserItemsLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + includeItemTypes: "Playlist" + autoReload: _firstTimeLoaded && itemData.jellyfinId.length > 0 + sortBy: "Random" + recursive: true + limit: 12 + } + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": playlistLoaderComponent.createObject(musicLibraryPage), + //: Page title for the list of all playlists within the music library + "pageTitle": qsTr("Playlists") + }) + } + + ItemChildrenShowcase { + //: Header for music artists + text: qsTr("Artists") + //collapseWhenEmpty: false + extraBusy: !_firstTimeLoaded + loader: J.AlbumArtistLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + autoReload: _firstTimeLoaded && itemData.jellyfinId.length > 0 + limit: 12 + } + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": albumArtistLoaderComponent.createObject(musicLibraryPage), + "allowSort": false, + //: Page title for the list of all artists within the music library + "pageTitle": qsTr("Artists") + }) + } + } + } +}