diff --git a/core/include/JellyfinQt/viewmodel/item.h b/core/include/JellyfinQt/viewmodel/item.h index deb42d9..562aa4e 100644 --- a/core/include/JellyfinQt/viewmodel/item.h +++ b/core/include/JellyfinQt/viewmodel/item.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -55,6 +56,51 @@ namespace ViewModel { class UserData; +namespace { + template + void qqmlistproperty_qlist_append(QQmlListProperty *prop, T *data) { + static_cast *>(prop->data())->append(data); + } + + template + void qqmlistproperty_qlist_clear(QQmlListProperty *prop) { + static_cast *>(prop->data())->clear(); + } + + template + T *qqmlistproperty_qlist_at(QQmlListProperty *prop, qint32 idx) { + return &static_cast *>(prop->data())->at(idx); + } + + template + void qqmlistproperty_qlist_count(QQmlListProperty *prop) { + static_cast *>(prop->data())->count(); + } +} + +template +QQmlListProperty qQmlListPropertyFromQList(QObject *object, QList *list) { + return QQmlListProperty(object, list, &qqmlistproperty_qlist_append, &qqmlistproperty_qlist_count, &qqmlistproperty_qlist_at, &qqmlistproperty_qlist_clear); +} + + +class NameGuidPair : public QObject { + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString jellyfinId READ jellyfinId NOTIFY jellyfinIdChanged) +public: + explicit NameGuidPair(QSharedPointer data = QSharedPointer::create(QStringLiteral("00000000000000000000000000000000")), QObject *parent = nullptr); + + QString name() const { return m_data->name(); } + QString jellyfinId() const { return m_data->jellyfinId(); } +signals: + void nameChanged(const QString &newName); + void jellyfinIdChanged(const QString &newJellyfinId); + +private: + QSharedPointer m_data; +}; + class Item : public QObject { Q_OBJECT public: @@ -116,6 +162,7 @@ public: Q_PROPERTY(QList subtitleStreams READ subtitleStreams NOTIFY subtitleStreamsChanged) Q_PROPERTY(double primaryImageAspectRatio READ primaryImageAspectRatio NOTIFY primaryImageAspectRatioChanged) Q_PROPERTY(QStringList artists READ artists NOTIFY artistsChanged) + Q_PROPERTY(QList artistItems READ artistItems NOTIFY artistItemsChanged); // Why is this a QJsonObject? Well, because I couldn't be bothered to implement the deserialisations of // a QHash at the moment. Q_PROPERTY(QJsonObject imageTags READ imageTags NOTIFY imageTagsChanged) @@ -171,6 +218,7 @@ public: QObjectList subtitleStreams() const { return m_subtitleStreams; } double primaryImageAspectRatio() const { return m_data->primaryImageAspectRatio().value_or(1.0); } QStringList artists() const { return m_data->artists(); } + QList artistItems() const{ return this->m_artistItems; } QJsonObject imageTags() const { return m_data->imageTags(); } QStringList backdropImageTags() const { return m_data->backdropImageTags(); } QJsonObject imageBlurHashes() const { return m_data->imageBlurHashes(); } @@ -243,6 +291,7 @@ signals: void subtitleStreamsChanged(QVariantList &newSubtitleStreams); void primaryImageAspectRatioChanged(double newPrimaryImageAspectRatio); void artistsChanged(const QStringList &newArtists); + void artistItemsChanged(); void imageTagsChanged(); void backdropImageTagsChanged(); void imageBlurHashesChanged(); @@ -269,6 +318,7 @@ protected: QObjectList m_audioStreams; QObjectList m_videoStreams; QObjectList m_subtitleStreams; + QObjectList m_artistItems; private slots: void onUserDataChanged(const DTO::UserItemDataDto &userData); }; diff --git a/core/include/JellyfinQt/viewmodel/itemmodel.h b/core/include/JellyfinQt/viewmodel/itemmodel.h index 8024770..26a6f40 100644 --- a/core/include/JellyfinQt/viewmodel/itemmodel.h +++ b/core/include/JellyfinQt/viewmodel/itemmodel.h @@ -355,6 +355,7 @@ public: indexNumber, runTimeTicks, artists, + artistItems, isFolder, overview, parentIndexNumber, @@ -395,6 +396,7 @@ public: JFRN(indexNumber), JFRN(runTimeTicks), JFRN(artists), + JFRN(artistItems), JFRN(isFolder), JFRN(overview), JFRN(parentIndexNumber), diff --git a/core/include/JellyfinQt/viewmodel/playlist.h b/core/include/JellyfinQt/viewmodel/playlist.h index 4e72916..cd902ab 100644 --- a/core/include/JellyfinQt/viewmodel/playlist.h +++ b/core/include/JellyfinQt/viewmodel/playlist.h @@ -59,6 +59,7 @@ public: // Item properties name = Qt::UserRole + 1, artists, + artistItems, runTimeTicks, // Non-item properties diff --git a/core/src/jellyfin.cpp b/core/src/jellyfin.cpp index 609ffc9..aac72cb 100644 --- a/core/src/jellyfin.cpp +++ b/core/src/jellyfin.cpp @@ -54,6 +54,7 @@ void JellyfinPlugin::registerTypes(const char *uri) { qmlRegisterUncreatableType(uri, 1, 0, "User", "Acquire one via UserLoader or exposed properties"); qmlRegisterUncreatableType(uri, 1, 0, "EventBus", "Obtain one via your ApiClient"); qmlRegisterUncreatableType(uri, 1, 0, "WebSocket", "Obtain one via your ApiClient"); + qmlRegisterUncreatableType(uri, 1, 0, "NameGuidPair", "Obbtain one via an Item"); qmlRegisterUncreatableType(uri, 1, 0, "MediaStream", "Obtain one via an Item"); qmlRegisterUncreatableType(uri, 1, 0, "Settings", "Obtain one via your ApiClient"); qmlRegisterUncreatableType(uri, 1, 0, "UserData", "Obtain one via an Item"); diff --git a/core/src/viewmodel/item.cpp b/core/src/viewmodel/item.cpp index 029e3be..99f2668 100644 --- a/core/src/viewmodel/item.cpp +++ b/core/src/viewmodel/item.cpp @@ -26,6 +26,9 @@ namespace Jellyfin { namespace ViewModel { +NameGuidPair::NameGuidPair(QSharedPointer data, QObject *parent) + : QObject(parent), m_data(data) {} + Item::Item(QObject *parent, QSharedPointer data) : QObject(parent), m_data(data), @@ -44,6 +47,7 @@ void Item::setData(QSharedPointer newData) { if (!m_data.isNull()) { connect(m_data.data(), &Model::Item::userDataChanged, this, &Item::onUserDataChanged); + updateMediaStreams(); setUserData(m_data->userData()); } @@ -77,6 +81,11 @@ void Item::updateMediaStreams() { qDebug() << m_audioStreams.size() << " audio streams, " << m_videoStreams.size() << " video streams, " << m_subtitleStreams.size() << " subtitle streams, " << m_allMediaStreams.size() << " streams total"; + m_artistItems.clear(); + const QList artists = m_data->artistItems(); + for (auto it = artists.cbegin(); it != artists.cend(); it++) { + m_artistItems.append(new NameGuidPair(QSharedPointer::create(*it), this)); + } } void Item::setUserData(DTO::UserItemDataDto &newData) { diff --git a/core/src/viewmodel/itemmodel.cpp b/core/src/viewmodel/itemmodel.cpp index cf4907e..e725681 100644 --- a/core/src/viewmodel/itemmodel.cpp +++ b/core/src/viewmodel/itemmodel.cpp @@ -18,6 +18,8 @@ */ #include "JellyfinQt/viewmodel/itemmodel.h" +#include "JellyfinQt/viewmodel/item.h" + #include "JellyfinQt/loader/http/artists.h" #include "JellyfinQt/loader/http/items.h" #include "JellyfinQt/loader/http/userlibrary.h" @@ -93,6 +95,14 @@ QVariant ItemModel::data(const QModelIndex &index, int role) const { case RoleNames::runTimeTicks: return QVariant(item->runTimeTicks().value_or(0)); JF_CASE(artists) + case RoleNames::artistItems: { + QVariantList data; + auto artists = item->artistItems(); + for (auto it = artists.cbegin(); it != artists.cend(); it++) { + data.append(QVariant::fromValue(new NameGuidPair(QSharedPointer::create(*it), const_cast(this)))); + } + return data; + } case RoleNames::isFolder: return QVariant(item->isFolder().value_or(false)); JF_CASE(overview) diff --git a/core/src/viewmodel/playlist.cpp b/core/src/viewmodel/playlist.cpp index e48a289..129e2df 100644 --- a/core/src/viewmodel/playlist.cpp +++ b/core/src/viewmodel/playlist.cpp @@ -18,6 +18,8 @@ */ #include "JellyfinQt/viewmodel/playlist.h" +#include "JellyfinQt/viewmodel/item.h" + namespace Jellyfin { namespace ViewModel { @@ -47,6 +49,7 @@ QHash Playlist::roleNames() const { return { {RoleNames::name, "name"}, {RoleNames::artists, "artists"}, + {RoleNames::artistItems, "artistItems"}, {RoleNames::runTimeTicks, "runTimeTicks"}, {RoleNames::section, "section"}, {RoleNames::playing, "playing"}, @@ -83,6 +86,16 @@ QVariant Playlist::data(const QModelIndex &index, int role) const { return QVariant(rowData->name()); case RoleNames::artists: return QVariant(rowData->artists()); + case RoleNames::artistItems: { + QVariantList result; + + auto items = rowData->artistItems(); + for (auto it = items.cbegin(); it != items.cend(); it++) { + result.append(QVariant::fromValue(new NameGuidPair(QSharedPointer::create(*it), const_cast(this)))); + } + + return result; + } case RoleNames::runTimeTicks: return QVariant(rowData->runTimeTicks().value_or(-1)); default: diff --git a/rpm/harbour-sailfin.changes b/rpm/harbour-sailfin.changes index 78d9583..365b893 100644 --- a/rpm/harbour-sailfin.changes +++ b/rpm/harbour-sailfin.changes @@ -17,6 +17,8 @@ - New layout for artist pages - New layout for the music library - New layout for playlist pages + - Navigation to artists of a song added when long-pressing a song or pressing the name + on the now playing screen. - Bug fixes - The album overview page should now behave correclty with an image with a non-square image diff --git a/sailfish/qml/components/PlayQueue.qml b/sailfish/qml/components/PlayQueue.qml index f72222b..d2b8c74 100644 --- a/sailfish/qml/components/PlayQueue.qml +++ b/sailfish/qml/components/PlayQueue.qml @@ -24,7 +24,7 @@ SilicaListView { } } delegate: SongDelegate { - artists: model.artists + artists: model.artistItems name: model.name width: parent.width indexNumber: index + 1 diff --git a/sailfish/qml/components/PlaybackBar.qml b/sailfish/qml/components/PlaybackBar.qml index 8eaca7a..52ab7e5 100644 --- a/sailfish/qml/components/PlaybackBar.qml +++ b/sailfish/qml/components/PlaybackBar.qml @@ -147,6 +147,11 @@ PanelBackground { maximumLineCount: 1 truncationMode: TruncationMode.Fade color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + linkColor: Theme.secondaryColor + onLinkActivated: { + appWindow.navigateToItem(link, "Audio", "MusicArtist", true) + } + textFormat: Text.RichText } } @@ -349,6 +354,21 @@ PanelBackground { PropertyChanges { target: artists font.pixelSize: Theme.fontSizeMedium + text: { + var links = []; + var items = manager.item.artistItems; + console.log(items) + for (var i = 0; i < items.length; i++) { + links.push("%2" + .arg(items[i].jellyfinId) + .arg(items[i].name) + .arg(Theme.secondaryColor) + ) + } + + return links.join(", ") + } + } AnchorChanges { target: artists @@ -419,6 +439,7 @@ PanelBackground { id: fullPage Page { property bool __hidePlaybackBar: true + property bool __isPlaybackBar: true showNavigationIndicator: true allowedOrientations: appWindow.allowedOrientations SilicaFlickable { diff --git a/sailfish/qml/components/music/SongDelegate.qml b/sailfish/qml/components/music/SongDelegate.qml index 0088e2c..3d85e61 100644 --- a/sailfish/qml/components/music/SongDelegate.qml +++ b/sailfish/qml/components/music/SongDelegate.qml @@ -31,6 +31,7 @@ ListItem { contentHeight: songName.height + songArtists.height + 2 * Theme.paddingMedium width: parent.width + menu: contextMenu TextMetrics { id: indexMetrics @@ -77,7 +78,13 @@ ListItem { right: parent.right rightMargin: Theme.horizontalPageMargin } - text: artists.join(", ") + text: { + var names = [] + for (var i = 0; i < artists.length; i++) { + names.push(artists[i].name) + } + return names.join(", ") + } font.pixelSize: Theme.fontSizeSmall truncationMode: TruncationMode.Fade color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor @@ -97,4 +104,48 @@ ListItem { color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor highlighted: down || playing } + + function goToArtist(id) { + appWindow.navigateToItem(id, "audio", "MusicArtist", true) + } + + Component { + id: contextMenu + ContextMenu { + MenuItem { + text: { + if(artists.length === 1) { + //: Context menu item for navigating to the artist of the selected track + return qsTr("Go to %1").arg(artists[0].name) + } else { + //: Context menu item for navigating to one of the artists of the selected track (opens submenu) + return qsTr("Go to artists") + } + } + onDelayedClick: { + if (artists.length > 1) { + songDelegateRoot.menu = artistMenu + songDelegateRoot.openMenu() + } else { + goToArtist(artists[0].jellyfinId) + } + } + } + } + } + + Component { + id: artistMenu + ContextMenu { + Repeater { + model: artists + MenuItem { + text: modelData.name + onDelayedClick: goToArtist(modelData.jellyfinId) + } + } + onClosed: songDelegateRoot.menu = contextMenu + } + } + } diff --git a/sailfish/qml/harbour-sailfin.qml b/sailfish/qml/harbour-sailfin.qml index 7425cfc..0f1fbda 100644 --- a/sailfish/qml/harbour-sailfin.qml +++ b/sailfish/qml/harbour-sailfin.qml @@ -154,7 +154,13 @@ ApplicationWindow { if (mediaType === "Audio" && !isFolder) { playbackManager.playItemId(jellyfinId) } else { - pageStack.push(Utils.getPageUrl(mediaType, type, isFolder), {"itemId": jellyfinId}); + var url = Utils.getPageUrl(mediaType, type, isFolder) + var properties = {"itemId": jellyfinId} + if ("__isPlaybackBar" in pageStack.currentPage) { + pageStack.replace(url, properties); + } else { + pageStack.push(url, properties); + } } } diff --git a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml index 1827c64..5c72d1f 100644 --- a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml +++ b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml @@ -93,7 +93,7 @@ BaseDetailPage { delegate: SongDelegate { id: songDelegate name: model.name - artists: model.artists + artists: model.artistItems duration: model.runTimeTicks indexNumber: itemData.type === "MusicAlbum" ? model.indexNumber : index + 1 onClicked: window.playbackManager.playItemInList(collectionModel, model.index) diff --git a/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml b/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml index 2dda4f1..2d14c30 100644 --- a/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml +++ b/sailfish/qml/pages/itemdetails/MusicLibraryPage.qml @@ -19,6 +19,15 @@ BaseDetailPage { SilicaFlickable { anchors.fill: parent contentHeight: content.height + Component { + id: latestMediaLoaderComponent + J.LatestMediaLoader { + apiClient: appWindow.apiClient + parentId: itemData.jellyfinId + includeItemTypes: "Audio" + autoReload: false + } + } Component { id: albumArtistLoaderComponent @@ -64,7 +73,6 @@ BaseDetailPage { text: qsTr("Recently added") //collapseWhenEmpty: false extraBusy: !_firstTimeLoaded - clickable: false loader: J.LatestMediaLoader { apiClient: appWindow.apiClient parentId: itemData.jellyfinId @@ -72,6 +80,12 @@ BaseDetailPage { includeItemTypes: "Audio" limit: 12 } + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), { + "loader": latestMediaLoaderComponent.createObject(musicLibraryPage), + //: Page title for the list of all albums within the music library + "pageTitle": qsTr("Latest media"), + "allowSort": false + }) }