diff --git a/.gitignore b/.gitignore index 1a9b849..262168e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build/ build-*/ # IDE files -harbour-sailfin.pro.user +*.user +CMakeLists.txt.user.* diff --git a/core/include/JellyfinQt/jellyfinapimodel.h b/core/include/JellyfinQt/jellyfinapimodel.h index dc36589..0ab5e06 100644 --- a/core/include/JellyfinQt/jellyfinapimodel.h +++ b/core/include/JellyfinQt/jellyfinapimodel.h @@ -140,6 +140,7 @@ public: Q_PROPERTY(QList fields MEMBER m_fields NOTIFY fieldsChanged) Q_PROPERTY(QString seasonId MEMBER m_seasonId NOTIFY seasonIdChanged) Q_PROPERTY(QList imageTypes MEMBER m_imageTypes NOTIFY imageTypesChanged) + Q_PROPERTY(QList includeItemTypes MEMBER m_includeItemTypes NOTIFY includeItemTypesChanged) Q_PROPERTY(bool recursive MEMBER m_recursive) Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged) @@ -182,6 +183,7 @@ signals: void seasonIdChanged(QString newSeasonId); void fieldsChanged(QList newFields); void imageTypesChanged(QList newImageTypes); + void includeItemTypesChanged(const QList &newIncludeItemTypes); public slots: /** @@ -225,9 +227,10 @@ protected: bool m_addUserId = false; QString m_parentId; QString m_seasonId; - QList m_fields; - QList m_imageTypes; + QList m_fields = {}; + QList m_imageTypes = {}; QList m_sortBy = {}; + QList m_includeItemTypes = {}; SortOrder m_sortOrder = Unspecified; bool m_recursive = false; diff --git a/core/include/JellyfinQt/jellyfinitem.h b/core/include/JellyfinQt/jellyfinitem.h index 7157459..e837dff 100644 --- a/core/include/JellyfinQt/jellyfinitem.h +++ b/core/include/JellyfinQt/jellyfinitem.h @@ -169,6 +169,21 @@ private: QString m_errorString; }; +class NameGuidPair : public JsonSerializable { + Q_OBJECT +public: + Q_INVOKABLE NameGuidPair(QObject *parent = nullptr); + Q_PROPERTY(QString name MEMBER m_name NOTIFY nameChanged) + // Once again the Jellyfin id workaround + Q_PROPERTY(QString jellyfinId MEMBER m_id NOTIFY jellyfinIdChanged) +signals: + void nameChanged(const QString &newName); + void jellyfinIdChanged(const QString &newJellyfinId); +private: + QString m_name; + QString m_id; +}; + class User : public RemoteData { Q_OBJECT public: @@ -333,10 +348,16 @@ public: Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged) Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged) Q_PROPERTY(UserData *userData MEMBER m_userData NOTIFY userDataChanged) + Q_PROPERTY(int recursiveItemCount READ recursiveItemCount WRITE setRecursiveItemCount NOTIFY recursiveItemCountChanged) + Q_PROPERTY(int childCount READ childCount WRITE setChildCount NOTIFY childCountChanged) + Q_PROPERTY(QString albumArtist MEMBER m_albumArtist NOTIFY albumArtistChanged) + Q_PROPERTY(QList __list__albumArtists MEMBER __list__m_albumArtists NOTIFY albumArtistsChanged) + Q_PROPERTY(QVariantList albumArtists MEMBER m_albumArtists NOTIFY albumArtistsChanged STORED false) Q_PROPERTY(QString seriesName MEMBER m_seriesName NOTIFY seriesNameChanged) Q_PROPERTY(QString seasonName MEMBER m_seasonName NOTIFY seasonNameChanged) Q_PROPERTY(QList __list__mediaStreams MEMBER __list__m_mediaStreams NOTIFY mediaStreamsChanged) Q_PROPERTY(QVariantList mediaStreams MEMBER m_mediaStreams NOTIFY mediaStreamsChanged STORED false) + Q_PROPERTY(QStringList artists MEMBER m_artists NOTIFY artistsChanged) // 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 MEMBER m_imageTags NOTIFY imageTagsChanged) @@ -374,6 +395,10 @@ public: void setIndexNumberEnd(int newIndexNumberEnd) { m_indexNumberEnd = std::optional(newIndexNumberEnd); emit indexNumberEndChanged(newIndexNumberEnd); } bool isFolder() const { return m_isFolder.value_or(false); } void setIsFolder(bool newIsFolder) { m_isFolder = newIsFolder; emit isFolderChanged(newIsFolder); } + int recursiveItemCount() const { return m_recursiveItemCount.value_or(-1); } + void setRecursiveItemCount(int newRecursiveItemCount) { m_recursiveItemCount = newRecursiveItemCount; emit recursiveItemCountChanged(newRecursiveItemCount); } + int childCount() const { return m_childCount.value_or(-1); } + void setChildCount(int newChildCount) { m_childCount = newChildCount; emit childCountChanged(newChildCount); } //QQmlListProperty mediaStreams() { return toReadOnlyQmlListProperty(m_mediaStreams); } //QList mediaStreams() { return *reinterpret_cast *>(&m_mediaStreams); } @@ -415,9 +440,14 @@ signals: void isFolderChanged(bool newIsFolder); void typeChanged(const QString &newType); void userDataChanged(UserData *newUserData); + void recursiveItemCountChanged(int newRecursiveItemCount); + void childCountChanged(int newChildCount); + void albumArtistChanged(const QString &newAlbumArtist); + void albumArtistsChanged(NameGuidPair *newAlbumArtists); void seriesNameChanged(const QString &newSeriesName); void seasonNameChanged(const QString &newSeasonName); void mediaStreamsChanged(/*const QList &newMediaStreams*/); + void artistsChanged(const QStringList &newArtists); void imageTagsChanged(); void imageBlurHashesChanged(); @@ -463,10 +493,16 @@ protected: std::optional m_isFolder = std::nullopt; QString m_type; UserData *m_userData = nullptr; + std::optional m_recursiveItemCount = std::nullopt; + std::optional m_childCount = std::nullopt; + QString m_albumArtist; + QList __list__m_albumArtists; + QVariantList m_albumArtists; QString m_seriesName; QString m_seasonName; QList __list__m_mediaStreams; QVariantList m_mediaStreams; + QStringList m_artists; QJsonObject m_imageTags; QJsonObject m_imageBlurHashes; diff --git a/core/src/jellyfinapimodel.cpp b/core/src/jellyfinapimodel.cpp index 9aef149..3b8759b 100644 --- a/core/src/jellyfinapimodel.cpp +++ b/core/src/jellyfinapimodel.cpp @@ -66,6 +66,9 @@ void ApiModel::load(LoadType type) { if (!m_imageTypes.empty()) { query.addQueryItem("ImageTypes", m_imageTypes.join(",")); } + if (!m_includeItemTypes.empty()) { + query.addQueryItem("IncludeItemTypes", m_includeItemTypes.join(",")); + } if (!m_fields.empty()) { query.addQueryItem("Fields", m_fields.join(",")); } @@ -191,6 +194,7 @@ bool ApiModel::canFetchMore(const QModelIndex &parent) const { switch(m_status) { case Uninitialised: case Loading: + case LoadingMore: return false; default: break; diff --git a/core/src/jellyfinitem.cpp b/core/src/jellyfinitem.cpp index 08bf5c5..cf46726 100644 --- a/core/src/jellyfinitem.cpp +++ b/core/src/jellyfinitem.cpp @@ -254,6 +254,8 @@ void RemoteData::reload() { }); } +NameGuidPair::NameGuidPair(QObject *parent) : JsonSerializable (parent) {} + // User User::User(QObject *parent) : RemoteData (parent) {} @@ -351,6 +353,7 @@ void Item::onUserDataChanged(const QString &itemId, QSharedPointer use void registerSerializableJsonTypes(const char* URI) { qmlRegisterType(URI, 1, 0, "MediaStream"); + qmlRegisterType(URI, 1, 0, "NameGuidPair"); qmlRegisterType(URI, 1, 0, "User"); qmlRegisterType(URI, 1, 0, "UserData"); qmlRegisterType(URI, 1, 0, "JellyfinItem"); diff --git a/sailfish/CMakeLists.txt b/sailfish/CMakeLists.txt index d606834..9954f69 100644 --- a/sailfish/CMakeLists.txt +++ b/sailfish/CMakeLists.txt @@ -20,8 +20,7 @@ set(sailfin_QML_SOURCES qml/components/Shim.qml qml/components/UserGridDelegate.qml qml/components/VideoPlayer.qml - qml/components/VideoTrackSelector.qml - qml/components/itemdetails/SeasonDetails.qml + qml/components/VideoTrackSelector.qml qml/components/videoplayer/VideoError.qml qml/components/videoplayer/VideoHud.qml qml/cover/CoverPage.qml @@ -46,14 +45,12 @@ set(sailfin_QML_SOURCES qml/pages/setup/LoginDialog.qml qml/qmldir) -add_executable(harbour-sailfin ${harbour-sailfin_SOURCES}) +add_executable(harbour-sailfin ${harbour-sailfin_SOURCES} ${sailfin_QML_SOURCES}) target_link_libraries(harbour-sailfin PRIVATE Qt5::Gui Qt5::Qml Qt5::Quick SailfishApp::SailfishApp # Note: this may break when the compiler changes. -rdynamic and -pie seem to be needed for the # invoker/booster to work jellyfin-qt "-Wl,-rpath,${CMAKE_INSTALL_LIBDIR} -rdynamic -pie") -add_custom_target(harbour-sailfin-qml ${sailfin_QML_SOURCES}) - install(TARGETS harbour-sailfin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) @@ -79,3 +76,19 @@ install(FILES icons/128x128/harbour-sailfin.png install(FILES icons/172x172/harbour-sailfin.png DESTINATION share/icons/hicolor/172x172/apps ) + +# Tell Qt Creator where the application executable(s) would be located on the +# device. +# +# It is not necessary to list other deployables than executables (runtime +# targets) here. The deployment process of Sailfish OS projects is opaque to +# Qt Creator and the information contained in QtCreatorDeployment.txt is only +# used to locate the executable associated with the active run configuration +# on the device in order to run it. +# +# Search the Qt Creator Manual to learn about the QtCreatorDeployment.txt file +# format. +file(WRITE "${CMAKE_BINARY_DIR}/QtCreatorDeployment.txt" + "${CMAKE_INSTALL_PREFIX} + sailfish/harbour-sailfin:bin +") diff --git a/sailfish/qml/Utils.js b/sailfish/qml/Utils.js index 060bda1..9c050e1 100644 --- a/sailfish/qml/Utils.js +++ b/sailfish/qml/Utils.js @@ -22,15 +22,17 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA /** * Converts miliseconds to a h:mm:ss format */ -function timeToText(time) { +function timeToText(time, showHours) { + var mShowHours = showHours === undefined ? true : showHours if (time < 0) return "??:??:??" var hours = Math.floor(time / (60 * 60 * 1000)) var left = time % (60 * 60 * 1000) var minutes = Math.floor(left / (60 * 1000)) left = time % (60 * 1000) var seconds = Math.floor(left / 1000) - - return hours + ":" + (minutes < 10 ? "0" : "") + minutes + ":" + (seconds < 10 ? "0" : "")+ seconds + return (hours > 0 ? hours + ":" : "") + + (minutes < 10 ? "0" : "") + + minutes + ":" + (seconds < 10 ? "0" : "")+ seconds } function msToTicks(ms) { @@ -41,8 +43,8 @@ function ticksToMs(ticks) { return ticks / 10000; } -function ticksToText(ticks) { - return timeToText(ticks / 10000); +function ticksToText(ticks, showHours) { + return timeToText(ticks / 10000, showHours); } function itemImageUrl(baseUrl, item, type, options) { @@ -80,6 +82,8 @@ function getPageUrl(mediaType, itemType) { return Qt.resolvedUrl("pages/itemdetails/SeasonPage.qml") case "episode": return Qt.resolvedUrl("pages/itemdetails/EpisodePage.qml") + case "musicalbum": + return Qt.resolvedUrl("pages/itemdetails/MusicAlbumPage.qml") default: switch (mediaType ? mediaType.toLowerCase() : "folder") { case "folder": diff --git a/sailfish/qml/components/LibraryItemDelegate.qml b/sailfish/qml/components/LibraryItemDelegate.qml index 5f98a88..ddbb4f7 100644 --- a/sailfish/qml/components/LibraryItemDelegate.qml +++ b/sailfish/qml/components/LibraryItemDelegate.qml @@ -45,6 +45,7 @@ BackgroundItem { } fillMode: Image.PreserveAspectCrop fallbackColor: Utils.colorFromString(title) + highlighted: root.highlighted } /*Rectangle { diff --git a/sailfish/qml/components/RemoteImage.qml b/sailfish/qml/components/RemoteImage.qml index a88d52b..6eadcbb 100644 --- a/sailfish/qml/components/RemoteImage.qml +++ b/sailfish/qml/components/RemoteImage.qml @@ -23,11 +23,22 @@ import Sailfish.Silica 1.0 /** * An image for "remote" images (loaded over e.g. http), with a spinner and a fallback image */ -HighlightImage { - property string fallbackImage - property bool usingFallbackImage +SilicaItem { + property string fallbackImage + property bool usingFallbackImage property color fallbackColor: Theme.highlightColor - asynchronous: true + + property alias source: realImage.source + property alias sourceSize: realImage.sourceSize + property alias fillMode: realImage.fillMode + implicitHeight: realImage.implicitHeight + implicitWidth: realImage.implicitWidth + + Image { + id: realImage + anchors.fill: parent + asynchronous: true + } Rectangle { id: fallbackBackground @@ -36,18 +47,25 @@ HighlightImage { GradientStop { position: 0.0; color: fallbackColor; } GradientStop { position: 1.0; color: Theme.highlightDimmerFromColor(fallbackColor, Theme.colorScheme); } } - visible: parent.status == Image.Error || parent.status == Image.Null || parent.status == Image.Loading + visible: realImage.status === Image.Error || realImage.status === Image.Null || realImage.status === Image.Loading + } + + Rectangle { + id: highlightOverlay + anchors.fill: parent + color: Theme.rgba(Theme.highlightColor, Theme.opacityOverlay) + visible: parent.highlighted } BusyIndicator { anchors.centerIn: parent - running: parent.status == Image.Loading + running: realImage.status === Image.Loading } HighlightImage { id: fallbackImageItem anchors.centerIn: parent - visible: parent.status == Image.Error || parent.status == Image.Null + visible: realImage.status === Image.Error || realImage.status === Image.Null source: fallbackImage ? fallbackImage : "image://theme/icon-m-question" } } diff --git a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml index 9c36e13..04675ef 100644 --- a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml +++ b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml @@ -1,5 +1,230 @@ -import QtQuick 2.0 +import QtQuick 2.6 +import Sailfish.Silica 1.0 -Item { +import nl.netsoj.chris.Jellyfin 1.0 +import "../../components" +import "../.." + +BaseDetailPage { + readonly property int _songIndexWidth: 100 + property string _albumArtistText: itemData.albumArtist + + UserItemModel { + id: collectionModel + apiClient: ApiClient + sortBy: ["SortName"] + fields: ["ItemCounts","PrimaryImageAspectRatio","BasicSyncInfo","CanDelete","MediaSourceCount"] + parentId: itemData.jellyfinId + onParentIdChanged: reload() + } + + SilicaListView { + id: list + anchors.fill: parent + model: collectionModel + header: Item { + property string stateIfArt: "largeArt" + property alias albumArt: albumArt + id: listHeader + width: parent.width + //spacing: Theme.paddingLarge + state: albumArt.source != "" ? stateIfArt : "noArt" + MouseArea { + anchors.fill: parent + onClicked: { + if (listHeader.stateIfArt == "largeArt") { + listHeader.stateIfArt = "details" + } else { + listHeader.stateIfArt = "largeArt" + } + } + } + RemoteImage { + id: albumArt + anchors { + top: parent.top + right: parent.right + } + source: Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width}) + sourceSize.width: listHeader.width + sourceSize.height: listHeader.width + fillMode: Image.PreserveAspectFit + opacity: 1 + clip: true + } + PageHeader { + id: albumHeader + width: parent.width - Theme.horizontalPageMargin - height + title: itemData.name + description: qsTr("%1\n%2 songs | %3 | %4") + .arg(_albumArtistText) + .arg(itemData.childCount) + .arg(Utils.ticksToText(itemData.runTimeTicks)) + .arg(itemData.productionYear > 0 ? itemData.productionYear : qsTr("Unknown year")) + } + + states: [ + State { + name: "largeArt" + PropertyChanges { + target: albumArt + width: parent.width + height: width + } + PropertyChanges { + target: listHeader + height: width + } + PropertyChanges { + target: albumHeader + opacity: 0 + } + PropertyChanges { + target: list + contentY: -list.width + } + AnchorChanges { + target: albumHeader + anchors.left: undefined + anchors.right: albumArt.left + } + }, + State { + name: "details" + PropertyChanges { + target: albumArt + width: height + height: albumHeader.height + } + PropertyChanges { + target: listHeader + height: albumHeader.height + } + PropertyChanges { + target: albumHeader + opacity: 1 + } + PropertyChanges { + target: list + contentY: -albumHeader.height + } + AnchorChanges { + target: albumHeader + anchors.left: undefined + anchors.right: albumArt.left + } + }, + State { + name: "noArt" + extend: "details" + PropertyChanges { + target: albumArt + opacity: 0 + } + PropertyChanges { + target: albumHeader + width: parent.width - Theme.horizontalPageMargin * 2 + } + AnchorChanges { + target: albumHeader + anchors.left: parent.left + anchors.right: parent.right + } + } + ] + transitions: Transition { + OpacityAnimator { target: albumHeader} + OpacityAnimator { target: albumArt} + NumberAnimation { + properties: "width,height,contentY" + //velocity: 1600 + duration: 300 + easing.type: Easing.OutQuad + } + AnchorAnimation {} + } + } + section { + property: "parentIndexNumber" + delegate: SectionHeader { + text: qsTr("Disc %1").arg(section) + } + } + delegate: ListItem { + contentHeight: songName.height + songArtists.height + 2 * Theme.paddingMedium + width: parent.width + + Label { + id: songIndex + anchors { + top: parent.top + topMargin: Theme.paddingMedium + left: parent.left + leftMargin: Theme.horizontalPageMargin + } + text: model.indexNumber + horizontalAlignment: Text.AlignRight + font.pixelSize: Theme.fontSizeExtraLarge + width: _songIndexWidth + } + + Label { + id: songName + anchors { + left: songIndex.right + leftMargin: Theme.paddingLarge + top: parent.top + topMargin: Theme.paddingMedium + right: duration.left + rightMargin: Theme.paddingLarge + } + text: model.name + font.pixelSize: Theme.fontSizeMedium + truncationMode: TruncationMode.Fade + } + Label { + id: songArtists + anchors { + top: songName.bottom + left: songIndex.right + leftMargin: Theme.paddingLarge + right: parent.right + rightMargin: Theme.horizontalPageMargin + } + text: model.artists.join(", ") + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Fade + color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + } + + Label { + id: duration + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + baseline: songName.baseline + } + width: contentWidth + text: Utils.ticksToText(model.runTimeTicks) + font.pixelSize: Theme.fontSizeSmall + color: highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + } + } + + + + VerticalScrollDecorator {} + } + + Connections { + target: itemData + onAlbumArtistsChanged: { + console.log(itemData.albumArtists) + _albumArtistText = "" + for (var i = 0; i < itemData.albumArtists.length; i++) { + _albumArtistText += itemData.albumArtists[i]["name"] + } + } + } }