From eac8faf17336d8d9cd47d012b88adf9fe7a20b6e Mon Sep 17 00:00:00 2001 From: Chris Josten Date: Sun, 17 Jan 2021 17:08:07 +0100 Subject: [PATCH] WIP: implement search --- core/CMakeLists.txt | 2 +- core/include/JellyfinQt/jellyfinapimodel.h | 47 ++++- core/include/JellyfinQt/jellyfinitem.h | 2 +- core/src/jellyfinapimodel.cpp | 167 ++++++++++++++++-- sailfish/CMakeLists.txt | 3 +- sailfish/qml/components/RemoteImage.qml | 6 +- .../qml/components/search/SearchResults.qml | 40 +++++ sailfish/qml/pages/MainPage.qml | 165 +++++++++++------ sailfish/qml/pages/SettingsPage.qml | 1 + 9 files changed, 356 insertions(+), 77 deletions(-) create mode 100644 sailfish/qml/components/search/SearchResults.qml diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index fd696ba..928c3f0 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -27,7 +27,7 @@ set(jellyfin-qt_HEADERS include/JellyfinQt/serverdiscoverymodel.h) add_definitions(-DSAILFIN_VERSION=\"${SAILFIN_VERSION}\") -add_library(jellyfin-qt ${jellyfin-qt_SOURCES} ${jellyfin-qt_HEADERS}) +add_library(jellyfin-qt STATIC ${jellyfin-qt_SOURCES} ${jellyfin-qt_HEADERS}) target_include_directories(jellyfin-qt PUBLIC "include" ) diff --git a/core/include/JellyfinQt/jellyfinapimodel.h b/core/include/JellyfinQt/jellyfinapimodel.h index 0ab5e06..ed21259 100644 --- a/core/include/JellyfinQt/jellyfinapimodel.h +++ b/core/include/JellyfinQt/jellyfinapimodel.h @@ -143,6 +143,7 @@ public: 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) + Q_PROPERTY(QString searchTerm MEMBER m_searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) // Path properties Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged) @@ -157,6 +158,12 @@ public: bool canFetchMore(const QModelIndex &parent) const override; void fetchMore(const QModelIndex &parent) override; + virtual void setSearchTerm(QString newSearchTerm) { + this->m_searchTerm = newSearchTerm; + emit searchTermChanged(newSearchTerm); + reload(); + } + ModelStatus status() const { return m_status; } // Helper methods @@ -184,6 +191,7 @@ signals: void fieldsChanged(QList newFields); void imageTypesChanged(QList newImageTypes); void includeItemTypesChanged(const QList &newIncludeItemTypes); + void searchTermChanged(QString newSearchTerm); public slots: /** @@ -193,6 +201,7 @@ public slots: protected: enum LoadType { + INITIAL_LOAD, RELOAD, LOAD_MORE }; @@ -207,6 +216,14 @@ protected: * query types specific for a certain model to be available. */ virtual void addQueryParameters(QUrlQuery &query); + + /** + * @brief setArray gets called when response data that replaces the current data has been received. + * The default implementation just resets the model, subclasses may do more advanced operations. + * @param newData The new data to store. + */ + virtual void setArray(const QJsonArray &newData); + ApiClient *m_apiClient = nullptr; ModelStatus m_status = Uninitialised; @@ -225,6 +242,7 @@ protected: // Query properties bool m_addUserId = false; + bool m_recursive = false; QString m_parentId; QString m_seasonId; QList m_fields = {}; @@ -232,7 +250,7 @@ protected: QList m_sortBy = {}; QList m_includeItemTypes = {}; SortOrder m_sortOrder = Unspecified; - bool m_recursive = false; + QString m_searchTerm; QHash m_roles; @@ -245,7 +263,7 @@ private: /** * @brief Generates roleNames based on the first record in m_array. */ - void generateFields(); + void generateFields(const QJsonArray &newData); QString sortByToString(SortOptions::SortBy sortBy); }; @@ -270,6 +288,31 @@ public slots: void onUserDataChanged(const QString &itemId, QSharedPointer userData); }; +/** + * @brief Lists search items and provides a nice animation when search items change their position. + */ +class SearchModel : public ItemModel { + Q_OBJECT +public: + explicit SearchModel(QObject *parent = nullptr); + Q_PROPERTY(int searchTimeout MEMBER m_searchTimeout NOTIFY searchTimeoutChanged) +signals: + void searchTimeoutChanged(int newTimeout); +protected: + void setArray(const QJsonArray &newData) override; + void setSearchTerm(QString searchTerm) override; + /** + * @brief DIFF_SIZE amount of items to keep track of when the position changes. + */ + const static size_t DIFF_SIZE = 10; + // Store the ids of the + std::array m_diffTracker; + + // Time in ms before we can search again + int m_searchTimeout = 500; + QTimer m_searchTimeoutTimer; +}; + class UserViewModel : public ApiModel { public: explicit UserViewModel (QObject *parent = nullptr); diff --git a/core/include/JellyfinQt/jellyfinitem.h b/core/include/JellyfinQt/jellyfinitem.h index dd9f3ec..78bb017 100644 --- a/core/include/JellyfinQt/jellyfinitem.h +++ b/core/include/JellyfinQt/jellyfinitem.h @@ -138,7 +138,7 @@ public slots: * * The default implementation makes a GET request to getDataUrl() and parses the resulting JSON, * which should be enough for most cases. Consider overriding getDataUrl() and - * canRelaod() if possible. Manual overrides need to make sure that + * canReload() if possible. Manual overrides need to make sure that * they're calling setStatus(Status), setError(QNetworkReply::NetworkError) and * setErrorString() to let the QML side know what this thing is up to. */ diff --git a/core/src/jellyfinapimodel.cpp b/core/src/jellyfinapimodel.cpp index 468b860..6f622eb 100644 --- a/core/src/jellyfinapimodel.cpp +++ b/core/src/jellyfinapimodel.cpp @@ -30,11 +30,12 @@ ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject void ApiModel::reload() { this->setStatus(Loading); m_startIndex = 0; - load(RELOAD); + + load(m_array.isEmpty() ? INITIAL_LOAD : RELOAD); } void ApiModel::load(LoadType type) { - qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); + qDebug() << (type == RELOAD ? "RELOAD" : type == INITIAL_LOAD ? "INITIAL_LOAD" : "LOAD_MORE"); if (m_apiClient == nullptr) { qWarning() << "Please set the apiClient property before (re)loading"; return; @@ -81,17 +82,21 @@ void ApiModel::load(LoadType type) { if (m_recursive) { query.addQueryItem("Recursive", "true"); } + if (!m_searchTerm.isEmpty()) { + query.addQueryItem("searchTerm", m_searchTerm); + } addQueryParameters(query); QNetworkReply *rep = m_apiClient->get(m_path, query); connect(rep, &QNetworkReply::finished, this, [this, type, rep]() { QJsonDocument doc = QJsonDocument::fromJson(rep->readAll()); + QJsonArray items; if (!m_hasRecordResponse) { if (!doc.isArray()) { qWarning() << "Object is not an array!"; this->setStatus(Error); return; } - this->m_array = doc.array(); + items = doc.array(); } else { if (!doc.isObject()) { qWarning() << "Object is not an object!"; @@ -120,10 +125,10 @@ void ApiModel::load(LoadType type) { this->setStatus(Error); return; } - QJsonArray items = obj["Items"].toArray(); + items = obj["Items"].toArray(); switch(type) { + case INITIAL_LOAD: case RELOAD: - this->m_array = items; break; case LOAD_MORE: this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1); @@ -138,27 +143,35 @@ void ApiModel::load(LoadType type) { break; } } - if (type == RELOAD) { - generateFields(); + + if (type == INITIAL_LOAD) { + generateFields(items); } + + if (type == INITIAL_LOAD || type == RELOAD) { + for (auto it = items.begin(); it != items.end(); it++){ + JsonHelper::convertToCamelCase(*it); + } + setArray(items); + } + this->setStatus(Ready); rep->deleteLater(); }); } -void ApiModel::generateFields() { - if (m_array.size() == 0) return; - this->beginResetModel(); +void ApiModel::generateFields(const QJsonArray &newData) { + if (newData.size() == 0) return; int i = Qt::UserRole + 1; - if (!m_array[0].isObject()) { + if (!newData[0].isObject()) { qWarning() << "Iterator is not an object?"; return; } // Walks over the keys in the first record and adds them to the rolenames. // This assumes the back-end has the same keys for every record. I could technically - // go over all records to be really sure, but no-one got time for a O(n²) algorithm, so + // go over all records to be really sure, but no-one got time for a O(n) algorithm, so // this heuristic hopefully suffices. - QJsonObject ob = m_array[0].toObject(); + QJsonObject ob = newData[0].toObject(); for (auto jt = ob.begin(); jt != ob.end(); jt++) { QString keyName = jt.key(); keyName[0] = keyName[0].toLower(); @@ -167,9 +180,12 @@ void ApiModel::generateFields() { m_roles.insert(i++, keyArr); } } - for (auto it = m_array.begin(); it != m_array.end(); it++){ - JsonHelper::convertToCamelCase(*it); - } + +} + +void ApiModel::setArray(const QJsonArray &newData) { + this->beginResetModel(); + m_array = newData; this->endResetModel(); } @@ -245,6 +261,124 @@ void ItemModel::onUserDataChanged(const QString &itemId, QSharedPointer(DIFF_SIZE), newData.size()); + + // Loop over the first DIFF_SIZE entries of newData to check where they came from. + for (int i = 0; i < maxLoop; i++) { + + // Loop over all the entries of our difference tracker + int j; + for (j = 0; j < static_cast(m_diffTracker.size()); j++) { + // Stop when we're hitting empty entries + if (m_diffTracker[static_cast(j)].isEmpty()) { + j = DIFF_SIZE; + break; + } + + if (newData[i].toObject()["id"] == m_diffTracker[static_cast(j)]) { + // The old entry is on the same place as the previous entry + if (i == j) break; + // Entry[j] has moved to position i + this->beginMoveRows(QModelIndex(), j, j, QModelIndex(), i); + m_array.insert(i, newData[i]); + // If the old position j is greater than the new position i, we need to remove + // j + 1, since the index has shifted due the insertion we just made. + m_array.removeAt(j > i ? j + 1 : j); + qDebug() << j << " -> " << i; + this->endMoveRows(); + break; + } + + } + + if (j == static_cast(m_diffTracker.size())) { + // New item id could not be found in the old item id list. + // Remove the old items and add the new item + if (i < m_array.size()) { + this->beginRemoveRows(QModelIndex(), i, i); + m_array.removeAt(i); + this->endRemoveRows(); + qDebug() << i << " replaced"; + } else { + qDebug() << i << " inserted"; + } + this->beginInsertRows(QModelIndex(), i, i); + m_array.insert(i, newData[i]); + this->endInsertRows(); + } + } + + // Remove items from the point where we just left until we reach the previous size or the + // amount of items we keep the difference on. Should only be executed when the amount + // of new results is smaller than the old amount. + for (int i = maxLoop; i < static_cast(DIFF_SIZE) && i < m_array.size(); i++) { + this->beginRemoveRows(QModelIndex(), i, i); + m_array.removeAt(i); + this->endRemoveRows(); + } + + // Store the new positions of the items + for (int i = 0; i < maxLoop; i++) { + if (!newData[i].isObject() || !newData[i].toObject().contains("id")) continue; + m_diffTracker[static_cast(i)] = newData[i].toObject()["id"].toString(); + qDebug() << i << " changed"; + } + for (size_t i = static_cast(maxLoop); i < DIFF_SIZE; i++) { + m_diffTracker[i].clear(); + qDebug() << i << " removed"; + } + + // If the amount of data is smaller than the amount of data of which we track their differences in position, + // we're done hear. + // if (newData.size() < static_cast(DIFF_SIZE)) return; + + int common = std::max(static_cast(DIFF_SIZE), std::min(newData.size(), m_array.size())); + for (int i = static_cast(DIFF_SIZE); i < common; i++) { + m_array.replace(i, newData[i]); + qDebug() << i << " replaced (tail)"; + } + + emit dataChanged(index(static_cast(DIFF_SIZE)), index(common - 1)); + + // Handle extra or fewer items in the area that we do not diff + if (newData.size() > m_array.size()) { + this->beginInsertRows(QModelIndex(), common, newData.size() - 1); + for (int i = common; i < newData.size(); i++) { + m_array.append(newData[i]); + qDebug() << i << " appended (tail)"; + } + this->endInsertRows(); + } else if (newData.size() < m_array.size()) { + this->beginRemoveRows(QModelIndex(), common, m_array.size() - 1); + for (int i = common; i < m_array.size(); i++) { + qDebug() << i << " removed (tail)"; + m_array.removeAt(i); + } + this->endRemoveRows(); + } +} + +void SearchModel::setSearchTerm(QString searchTerm) { + if (m_searchTimeoutTimer.isActive()) { + m_searchTimeoutTimer.stop(); + } + m_searchTimeoutTimer.start(); + m_searchTerm = searchTerm; + emit searchTermChanged(searchTerm); +} + PublicUserModel::PublicUserModel(QObject *parent) : ApiModel ("/users/public", false, false, parent) { } @@ -280,5 +414,6 @@ void registerModels(const char *URI) { qmlRegisterType(URI, 1, 0, "ShowNextUpModel"); qmlRegisterType(URI, 1, 0, "ShowSeasonsModel"); qmlRegisterType(URI, 1, 0, "ShowEpisodesModel"); + qmlRegisterType(URI, 1, 0, "SearchModel"); } } diff --git a/sailfish/CMakeLists.txt b/sailfish/CMakeLists.txt index a098c3e..baa9f27 100644 --- a/sailfish/CMakeLists.txt +++ b/sailfish/CMakeLists.txt @@ -23,6 +23,7 @@ set(sailfin_QML_SOURCES qml/components/music/NarrowAlbumCover.qml qml/components/music/WideAlbumCover.qml qml/components/music/SongDelegate.qml + qml/components/search/SearchResults.qml qml/components/videoplayer/VideoError.qml qml/components/videoplayer/VideoHud.qml qml/components/GlassyBackground.qml @@ -62,7 +63,7 @@ set(sailfin_QML_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 + # invoker/booster to work jellyfin-qt "-Wl,-rpath,${CMAKE_INSTALL_LIBDIR} -rdynamic -pie") install(TARGETS harbour-sailfin diff --git a/sailfish/qml/components/RemoteImage.qml b/sailfish/qml/components/RemoteImage.qml index 8ee9d6e..d59f8ed 100644 --- a/sailfish/qml/components/RemoteImage.qml +++ b/sailfish/qml/components/RemoteImage.qml @@ -30,6 +30,7 @@ SilicaItem { property string fallbackImage property bool usingFallbackImage property color fallbackColor: Theme.highlightColor + property bool hideFallbackColor: false property var __parentPage : null property bool alreadyLoaded: false @@ -56,7 +57,7 @@ SilicaItem { anchors.fill: parent asynchronous: true fillMode: root.fillMode - opacity: 1 + opacity: hideFallbackColor ? 0.0 : 1.0 source: alreadyLoaded || [PageStatus.Active, PageStatus.Deactivating].indexOf(__parentPage.status) >= 0 ? root.source : "" onStatusChanged: { if (status == Image.Ready) { @@ -73,6 +74,7 @@ SilicaItem { GradientStop { position: 1.0; color: Theme.highlightDimmerFromColor(fallbackColor, Theme.colorScheme); } } opacity: 0 + visible: !hideFallbackColor } Image { @@ -132,7 +134,7 @@ SilicaItem { when: realImage.status === Image.Ready PropertyChanges { target: realImage - //opacity: 1 + opacity: 1 } } ] diff --git a/sailfish/qml/components/search/SearchResults.qml b/sailfish/qml/components/search/SearchResults.qml new file mode 100644 index 0000000..5ec0284 --- /dev/null +++ b/sailfish/qml/components/search/SearchResults.qml @@ -0,0 +1,40 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 + +SilicaListView { + id: root + model: query.length ? searchModel : 0 + property alias query: searchModel.searchTerm + delegate: ListItem { + width: parent.width + height: Theme.itemSizeLarge + Label { + anchors.centerIn: parent + width: parent.width + text: model.name + } + } + add: Transition { + NumberAnimation { property: "scale"; from: 0.0001; to: 1.0; } + FadeAnimation { from: 0.0; to: 1.0; } + } + + move: Transition { + NumberAnimation { + properties: "x,y" + } + } + + remove: Transition { + NumberAnimation { property: "scale"; from: 1.0; to: 0.0001; } + FadeAnimation { from: 1.0; to: 0.0; } + } + + SearchModel { + id: searchModel + apiClient: ApiClient + recursive: true + } +} diff --git a/sailfish/qml/pages/MainPage.qml b/sailfish/qml/pages/MainPage.qml index bf4fcbb..18131f9 100644 --- a/sailfish/qml/pages/MainPage.qml +++ b/sailfish/qml/pages/MainPage.qml @@ -22,6 +22,7 @@ import Sailfish.Silica 1.0 import nl.netsoj.chris.Jellyfin 1.0 import "../components" +import "../components/search" import "../" import "../Utils.js" as Utils @@ -36,6 +37,7 @@ Page { allowedOrientations: Orientation.All SilicaFlickable { + id: mainView anchors.fill: parent // PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView @@ -66,73 +68,92 @@ Page { id: mediaLibraryModel2 apiClient: ApiClient } + Item { + id: searchBoxPlaceholder + width: parent.width + height: searchBox.height - MoreSection { - text: qsTr("Resume watching") - clickable: false - busy: userResumeModel.status == ApiModel.Loading - Loader { + SearchField { + id: searchBox width: parent.width - sourceComponent: carrouselView - property alias itemModel: userResumeModel - property string collectionType: "series" - - UserItemResumeModel { - id: userResumeModel - apiClient: ApiClient - limit: 12 - recursive: true - } - } - } - MoreSection { - text: qsTr("Next up") - clickable: false - busy: showNextUpModel.status == ApiModel.Loading - - Loader { - width: parent.width - sourceComponent: carrouselView - property alias itemModel: showNextUpModel - property string collectionType: "series" - - ShowNextUpModel { - id: showNextUpModel - apiClient: ApiClient - limit: 12 - } + placeholderText: qsTr("Search") } } - UserViewModel { - id: mediaLibraryModel - apiClient: ApiClient + Label { + text: "%1 results".arg(searchView.count) } - Repeater { - model: mediaLibraryModel + + Column { + id: homeView + width: parent.width MoreSection { - text: model.name - busy: userItemModel.status != ApiModel.Ready - - onHeaderClicked: pageStack.push(Qt.resolvedUrl("itemdetails/CollectionPage.qml"), {"itemId": model.id}) + text: qsTr("Resume watching") + clickable: false + busy: userResumeModel.status == ApiModel.Loading Loader { width: parent.width sourceComponent: carrouselView - property alias itemModel: userItemModel - property string collectionType: model.collectionType || "" + property alias itemModel: userResumeModel + property string collectionType: "series" - UserItemLatestModel { - id: userItemModel + UserItemResumeModel { + id: userResumeModel + apiClient: ApiClient + limit: 12 + recursive: true + } + } + } + MoreSection { + text: qsTr("Next up") + clickable: false + busy: showNextUpModel.status == ApiModel.Loading + + Loader { + width: parent.width + sourceComponent: carrouselView + property alias itemModel: showNextUpModel + property string collectionType: "series" + + ShowNextUpModel { + id: showNextUpModel apiClient: ApiClient - parentId: model.id limit: 12 } - Connections { - target: mediaLibraryModel - onStatusChanged: { - console.log("MediaLibraryModel status " + status) - if (status == ApiModel.Ready) { - userItemModel.reload() + } + } + + UserViewModel { + id: mediaLibraryModel + apiClient: ApiClient + } + Repeater { + model: mediaLibraryModel + MoreSection { + text: model.name + busy: userItemModel.status != ApiModel.Ready + + onHeaderClicked: pageStack.push(Qt.resolvedUrl("itemdetails/CollectionPage.qml"), {"itemId": model.id}) + Loader { + width: parent.width + sourceComponent: carrouselView + property alias itemModel: userItemModel + property string collectionType: model.collectionType || "" + + UserItemLatestModel { + id: userItemModel + apiClient: ApiClient + parentId: model.id + limit: 12 + } + Connections { + target: mediaLibraryModel + onStatusChanged: { + console.log("MediaLibraryModel status " + status) + if (status == ApiModel.Ready) { + userItemModel.reload() + } } } } @@ -167,6 +188,42 @@ Page { } } + SearchResults { + id: searchView + anchors.fill: parent + header: Item { + width: parent.width + height: searchBox.height + } + query: searchBox.text + visible: false + opacity: 0 + } + + states: [ + State { + name: "search" + when: searchBox.text.length + PropertyChanges { + target: searchView + visible: true + opacity: 1 + } + PropertyChanges { + target: mainView + opacity: 0 + visible: false + } + ParentChange { + target: searchBox + parent: searchView.headerItem + } + StateChangeScript { + script: searchBox.forceActiveFocus() + } + } + ] + onStatusChanged: { if (status == PageStatus.Active) { appWindow.itemData = null @@ -181,7 +238,7 @@ Page { /** - * Loads models if not laoded. Set force to true to reload models + * Loads models if not loaded. Set force to true to reload models * even if loaded. */ function loadModels(force) { diff --git a/sailfish/qml/pages/SettingsPage.qml b/sailfish/qml/pages/SettingsPage.qml index fcf7860..a14d184 100644 --- a/sailfish/qml/pages/SettingsPage.qml +++ b/sailfish/qml/pages/SettingsPage.qml @@ -68,6 +68,7 @@ Page { top: parent.top bottom: parent.bottom } + hideFallbackColor: true source: ApiClient.baseUrl + "/Users/" + ApiClient.userId + "/Images/Primary?tag=" + loggedInUser.primaryImageTag }