1
0
Fork 0
mirror of https://github.com/HenkKalkwater/harbour-sailfin.git synced 2024-11-22 09:15:18 +00:00

Add navigation to artists from tracks

I'm not to happy about the C++ sides. If anyone from the future finds
this commit with "git blame" while debugging this code: I apologise
This commit is contained in:
Chris Josten 2022-07-30 01:16:40 +02:00
parent 3f9661ccb5
commit 0fafb19c7d
14 changed files with 185 additions and 5 deletions

View file

@ -28,6 +28,7 @@
#include <QDebug> #include <QDebug>
#include <QList> #include <QList>
#include <QObject> #include <QObject>
#include <QQmlListProperty>
#include <QSharedPointer> #include <QSharedPointer>
#include <QUuid> #include <QUuid>
@ -55,6 +56,51 @@ namespace ViewModel {
class UserData; class UserData;
namespace {
template<typename T>
void qqmlistproperty_qlist_append(QQmlListProperty<T> *prop, T *data) {
static_cast<QList<T> *>(prop->data())->append(data);
}
template<typename T>
void qqmlistproperty_qlist_clear(QQmlListProperty<T> *prop) {
static_cast<QList<T> *>(prop->data())->clear();
}
template<typename T>
T *qqmlistproperty_qlist_at(QQmlListProperty<T> *prop, qint32 idx) {
return &static_cast<QList<T> *>(prop->data())->at(idx);
}
template<typename T>
void qqmlistproperty_qlist_count(QQmlListProperty<T> *prop) {
static_cast<QList<T> *>(prop->data())->count();
}
}
template <typename T>
QQmlListProperty<T> qQmlListPropertyFromQList(QObject *object, QList<T> *list) {
return QQmlListProperty<T>(object, list, &qqmlistproperty_qlist_append<T>, &qqmlistproperty_qlist_count<T>, &qqmlistproperty_qlist_at<T>, &qqmlistproperty_qlist_clear<T>);
}
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<DTO::NameGuidPair> data = QSharedPointer<DTO::NameGuidPair>::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<DTO::NameGuidPair> m_data;
};
class Item : public QObject { class Item : public QObject {
Q_OBJECT Q_OBJECT
public: public:
@ -116,6 +162,7 @@ public:
Q_PROPERTY(QList<QObject *> subtitleStreams READ subtitleStreams NOTIFY subtitleStreamsChanged) Q_PROPERTY(QList<QObject *> subtitleStreams READ subtitleStreams NOTIFY subtitleStreamsChanged)
Q_PROPERTY(double primaryImageAspectRatio READ primaryImageAspectRatio NOTIFY primaryImageAspectRatioChanged) Q_PROPERTY(double primaryImageAspectRatio READ primaryImageAspectRatio NOTIFY primaryImageAspectRatioChanged)
Q_PROPERTY(QStringList artists READ artists NOTIFY artistsChanged) Q_PROPERTY(QStringList artists READ artists NOTIFY artistsChanged)
Q_PROPERTY(QList<QObject *> artistItems READ artistItems NOTIFY artistItemsChanged);
// Why is this a QJsonObject? Well, because I couldn't be bothered to implement the deserialisations of // Why is this a QJsonObject? Well, because I couldn't be bothered to implement the deserialisations of
// a QHash at the moment. // a QHash at the moment.
Q_PROPERTY(QJsonObject imageTags READ imageTags NOTIFY imageTagsChanged) Q_PROPERTY(QJsonObject imageTags READ imageTags NOTIFY imageTagsChanged)
@ -171,6 +218,7 @@ public:
QObjectList subtitleStreams() const { return m_subtitleStreams; } QObjectList subtitleStreams() const { return m_subtitleStreams; }
double primaryImageAspectRatio() const { return m_data->primaryImageAspectRatio().value_or(1.0); } double primaryImageAspectRatio() const { return m_data->primaryImageAspectRatio().value_or(1.0); }
QStringList artists() const { return m_data->artists(); } QStringList artists() const { return m_data->artists(); }
QList<QObject *> artistItems() const{ return this->m_artistItems; }
QJsonObject imageTags() const { return m_data->imageTags(); } QJsonObject imageTags() const { return m_data->imageTags(); }
QStringList backdropImageTags() const { return m_data->backdropImageTags(); } QStringList backdropImageTags() const { return m_data->backdropImageTags(); }
QJsonObject imageBlurHashes() const { return m_data->imageBlurHashes(); } QJsonObject imageBlurHashes() const { return m_data->imageBlurHashes(); }
@ -243,6 +291,7 @@ signals:
void subtitleStreamsChanged(QVariantList &newSubtitleStreams); void subtitleStreamsChanged(QVariantList &newSubtitleStreams);
void primaryImageAspectRatioChanged(double newPrimaryImageAspectRatio); void primaryImageAspectRatioChanged(double newPrimaryImageAspectRatio);
void artistsChanged(const QStringList &newArtists); void artistsChanged(const QStringList &newArtists);
void artistItemsChanged();
void imageTagsChanged(); void imageTagsChanged();
void backdropImageTagsChanged(); void backdropImageTagsChanged();
void imageBlurHashesChanged(); void imageBlurHashesChanged();
@ -269,6 +318,7 @@ protected:
QObjectList m_audioStreams; QObjectList m_audioStreams;
QObjectList m_videoStreams; QObjectList m_videoStreams;
QObjectList m_subtitleStreams; QObjectList m_subtitleStreams;
QObjectList m_artistItems;
private slots: private slots:
void onUserDataChanged(const DTO::UserItemDataDto &userData); void onUserDataChanged(const DTO::UserItemDataDto &userData);
}; };

View file

@ -355,6 +355,7 @@ public:
indexNumber, indexNumber,
runTimeTicks, runTimeTicks,
artists, artists,
artistItems,
isFolder, isFolder,
overview, overview,
parentIndexNumber, parentIndexNumber,
@ -395,6 +396,7 @@ public:
JFRN(indexNumber), JFRN(indexNumber),
JFRN(runTimeTicks), JFRN(runTimeTicks),
JFRN(artists), JFRN(artists),
JFRN(artistItems),
JFRN(isFolder), JFRN(isFolder),
JFRN(overview), JFRN(overview),
JFRN(parentIndexNumber), JFRN(parentIndexNumber),

View file

@ -59,6 +59,7 @@ public:
// Item properties // Item properties
name = Qt::UserRole + 1, name = Qt::UserRole + 1,
artists, artists,
artistItems,
runTimeTicks, runTimeTicks,
// Non-item properties // Non-item properties

View file

@ -54,6 +54,7 @@ void JellyfinPlugin::registerTypes(const char *uri) {
qmlRegisterUncreatableType<ViewModel::User>(uri, 1, 0, "User", "Acquire one via UserLoader or exposed properties"); qmlRegisterUncreatableType<ViewModel::User>(uri, 1, 0, "User", "Acquire one via UserLoader or exposed properties");
qmlRegisterUncreatableType<EventBus>(uri, 1, 0, "EventBus", "Obtain one via your ApiClient"); qmlRegisterUncreatableType<EventBus>(uri, 1, 0, "EventBus", "Obtain one via your ApiClient");
qmlRegisterUncreatableType<WebSocket>(uri, 1, 0, "WebSocket", "Obtain one via your ApiClient"); qmlRegisterUncreatableType<WebSocket>(uri, 1, 0, "WebSocket", "Obtain one via your ApiClient");
qmlRegisterUncreatableType<ViewModel::NameGuidPair>(uri, 1, 0, "NameGuidPair", "Obbtain one via an Item");
qmlRegisterUncreatableType<ViewModel::MediaStream>(uri, 1, 0, "MediaStream", "Obtain one via an Item"); qmlRegisterUncreatableType<ViewModel::MediaStream>(uri, 1, 0, "MediaStream", "Obtain one via an Item");
qmlRegisterUncreatableType<ViewModel::Settings>(uri, 1, 0, "Settings", "Obtain one via your ApiClient"); qmlRegisterUncreatableType<ViewModel::Settings>(uri, 1, 0, "Settings", "Obtain one via your ApiClient");
qmlRegisterUncreatableType<ViewModel::UserData>(uri, 1, 0, "UserData", "Obtain one via an Item"); qmlRegisterUncreatableType<ViewModel::UserData>(uri, 1, 0, "UserData", "Obtain one via an Item");

View file

@ -26,6 +26,9 @@
namespace Jellyfin { namespace Jellyfin {
namespace ViewModel { namespace ViewModel {
NameGuidPair::NameGuidPair(QSharedPointer<DTO::NameGuidPair> data, QObject *parent)
: QObject(parent), m_data(data) {}
Item::Item(QObject *parent, QSharedPointer<Model::Item> data) Item::Item(QObject *parent, QSharedPointer<Model::Item> data)
: QObject(parent), : QObject(parent),
m_data(data), m_data(data),
@ -44,6 +47,7 @@ void Item::setData(QSharedPointer<Model::Item> newData) {
if (!m_data.isNull()) { if (!m_data.isNull()) {
connect(m_data.data(), &Model::Item::userDataChanged, this, &Item::onUserDataChanged); connect(m_data.data(), &Model::Item::userDataChanged, this, &Item::onUserDataChanged);
updateMediaStreams();
setUserData(m_data->userData()); setUserData(m_data->userData());
} }
@ -77,6 +81,11 @@ void Item::updateMediaStreams() {
qDebug() << m_audioStreams.size() << " audio streams, " << m_videoStreams.size() << " video streams, " qDebug() << m_audioStreams.size() << " audio streams, " << m_videoStreams.size() << " video streams, "
<< m_subtitleStreams.size() << " subtitle streams, " << m_allMediaStreams.size() << " streams total"; << m_subtitleStreams.size() << " subtitle streams, " << m_allMediaStreams.size() << " streams total";
m_artistItems.clear();
const QList<DTO::NameGuidPair> artists = m_data->artistItems();
for (auto it = artists.cbegin(); it != artists.cend(); it++) {
m_artistItems.append(new NameGuidPair(QSharedPointer<DTO::NameGuidPair>::create(*it), this));
}
} }
void Item::setUserData(DTO::UserItemDataDto &newData) { void Item::setUserData(DTO::UserItemDataDto &newData) {

View file

@ -18,6 +18,8 @@
*/ */
#include "JellyfinQt/viewmodel/itemmodel.h" #include "JellyfinQt/viewmodel/itemmodel.h"
#include "JellyfinQt/viewmodel/item.h"
#include "JellyfinQt/loader/http/artists.h" #include "JellyfinQt/loader/http/artists.h"
#include "JellyfinQt/loader/http/items.h" #include "JellyfinQt/loader/http/items.h"
#include "JellyfinQt/loader/http/userlibrary.h" #include "JellyfinQt/loader/http/userlibrary.h"
@ -93,6 +95,14 @@ QVariant ItemModel::data(const QModelIndex &index, int role) const {
case RoleNames::runTimeTicks: case RoleNames::runTimeTicks:
return QVariant(item->runTimeTicks().value_or(0)); return QVariant(item->runTimeTicks().value_or(0));
JF_CASE(artists) 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<DTO::NameGuidPair>::create(*it), const_cast<ItemModel *>(this))));
}
return data;
}
case RoleNames::isFolder: case RoleNames::isFolder:
return QVariant(item->isFolder().value_or(false)); return QVariant(item->isFolder().value_or(false));
JF_CASE(overview) JF_CASE(overview)

View file

@ -18,6 +18,8 @@
*/ */
#include "JellyfinQt/viewmodel/playlist.h" #include "JellyfinQt/viewmodel/playlist.h"
#include "JellyfinQt/viewmodel/item.h"
namespace Jellyfin { namespace Jellyfin {
namespace ViewModel { namespace ViewModel {
@ -47,6 +49,7 @@ QHash<int, QByteArray> Playlist::roleNames() const {
return { return {
{RoleNames::name, "name"}, {RoleNames::name, "name"},
{RoleNames::artists, "artists"}, {RoleNames::artists, "artists"},
{RoleNames::artistItems, "artistItems"},
{RoleNames::runTimeTicks, "runTimeTicks"}, {RoleNames::runTimeTicks, "runTimeTicks"},
{RoleNames::section, "section"}, {RoleNames::section, "section"},
{RoleNames::playing, "playing"}, {RoleNames::playing, "playing"},
@ -83,6 +86,16 @@ QVariant Playlist::data(const QModelIndex &index, int role) const {
return QVariant(rowData->name()); return QVariant(rowData->name());
case RoleNames::artists: case RoleNames::artists:
return QVariant(rowData->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<DTO::NameGuidPair>::create(*it), const_cast<Playlist *>(this))));
}
return result;
}
case RoleNames::runTimeTicks: case RoleNames::runTimeTicks:
return QVariant(rowData->runTimeTicks().value_or(-1)); return QVariant(rowData->runTimeTicks().value_or(-1));
default: default:

View file

@ -17,6 +17,8 @@
- New layout for artist pages - New layout for artist pages
- New layout for the music library - New layout for the music library
- New layout for playlist pages - 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 - Bug fixes
- The album overview page should now behave correclty with an image with a non-square image - The album overview page should now behave correclty with an image with a non-square image

View file

@ -24,7 +24,7 @@ SilicaListView {
} }
} }
delegate: SongDelegate { delegate: SongDelegate {
artists: model.artists artists: model.artistItems
name: model.name name: model.name
width: parent.width width: parent.width
indexNumber: index + 1 indexNumber: index + 1

View file

@ -147,6 +147,11 @@ PanelBackground {
maximumLineCount: 1 maximumLineCount: 1
truncationMode: TruncationMode.Fade truncationMode: TruncationMode.Fade
color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor 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 { PropertyChanges {
target: artists target: artists
font.pixelSize: Theme.fontSizeMedium 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("<a href=\"%1\" style=\"text-decoration:none;color:%3\">%2</a>"
.arg(items[i].jellyfinId)
.arg(items[i].name)
.arg(Theme.secondaryColor)
)
}
return links.join(", ")
}
} }
AnchorChanges { AnchorChanges {
target: artists target: artists
@ -419,6 +439,7 @@ PanelBackground {
id: fullPage id: fullPage
Page { Page {
property bool __hidePlaybackBar: true property bool __hidePlaybackBar: true
property bool __isPlaybackBar: true
showNavigationIndicator: true showNavigationIndicator: true
allowedOrientations: appWindow.allowedOrientations allowedOrientations: appWindow.allowedOrientations
SilicaFlickable { SilicaFlickable {

View file

@ -31,6 +31,7 @@ ListItem {
contentHeight: songName.height + songArtists.height + 2 * Theme.paddingMedium contentHeight: songName.height + songArtists.height + 2 * Theme.paddingMedium
width: parent.width width: parent.width
menu: contextMenu
TextMetrics { TextMetrics {
id: indexMetrics id: indexMetrics
@ -77,7 +78,13 @@ ListItem {
right: parent.right right: parent.right
rightMargin: Theme.horizontalPageMargin 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 font.pixelSize: Theme.fontSizeSmall
truncationMode: TruncationMode.Fade truncationMode: TruncationMode.Fade
color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor
@ -97,4 +104,48 @@ ListItem {
color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor
highlighted: down || playing 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
}
}
} }

View file

@ -154,7 +154,13 @@ ApplicationWindow {
if (mediaType === "Audio" && !isFolder) { if (mediaType === "Audio" && !isFolder) {
playbackManager.playItemId(jellyfinId) playbackManager.playItemId(jellyfinId)
} else { } 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);
}
} }
} }

View file

@ -93,7 +93,7 @@ BaseDetailPage {
delegate: SongDelegate { delegate: SongDelegate {
id: songDelegate id: songDelegate
name: model.name name: model.name
artists: model.artists artists: model.artistItems
duration: model.runTimeTicks duration: model.runTimeTicks
indexNumber: itemData.type === "MusicAlbum" ? model.indexNumber : index + 1 indexNumber: itemData.type === "MusicAlbum" ? model.indexNumber : index + 1
onClicked: window.playbackManager.playItemInList(collectionModel, model.index) onClicked: window.playbackManager.playItemInList(collectionModel, model.index)

View file

@ -19,6 +19,15 @@ BaseDetailPage {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
contentHeight: content.height contentHeight: content.height
Component {
id: latestMediaLoaderComponent
J.LatestMediaLoader {
apiClient: appWindow.apiClient
parentId: itemData.jellyfinId
includeItemTypes: "Audio"
autoReload: false
}
}
Component { Component {
id: albumArtistLoaderComponent id: albumArtistLoaderComponent
@ -64,7 +73,6 @@ BaseDetailPage {
text: qsTr("Recently added") text: qsTr("Recently added")
//collapseWhenEmpty: false //collapseWhenEmpty: false
extraBusy: !_firstTimeLoaded extraBusy: !_firstTimeLoaded
clickable: false
loader: J.LatestMediaLoader { loader: J.LatestMediaLoader {
apiClient: appWindow.apiClient apiClient: appWindow.apiClient
parentId: itemData.jellyfinId parentId: itemData.jellyfinId
@ -72,6 +80,12 @@ BaseDetailPage {
includeItemTypes: "Audio" includeItemTypes: "Audio"
limit: 12 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
})
} }