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) + } + } + } + } + } +}