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

WIP: implement search

This commit is contained in:
Chris Josten 2021-01-17 17:08:07 +01:00
parent 79d378c9ed
commit eac8faf173
9 changed files with 356 additions and 77 deletions

View file

@ -27,7 +27,7 @@ set(jellyfin-qt_HEADERS
include/JellyfinQt/serverdiscoverymodel.h) include/JellyfinQt/serverdiscoverymodel.h)
add_definitions(-DSAILFIN_VERSION=\"${SAILFIN_VERSION}\") 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 target_include_directories(jellyfin-qt
PUBLIC "include" PUBLIC "include"
) )

View file

@ -143,6 +143,7 @@ public:
Q_PROPERTY(QList<QString> includeItemTypes MEMBER m_includeItemTypes NOTIFY includeItemTypesChanged) Q_PROPERTY(QList<QString> includeItemTypes MEMBER m_includeItemTypes NOTIFY includeItemTypesChanged)
Q_PROPERTY(bool recursive MEMBER m_recursive) Q_PROPERTY(bool recursive MEMBER m_recursive)
Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged) Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged)
Q_PROPERTY(QString searchTerm MEMBER m_searchTerm WRITE setSearchTerm NOTIFY searchTermChanged)
// Path properties // Path properties
Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged) Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged)
@ -157,6 +158,12 @@ public:
bool canFetchMore(const QModelIndex &parent) const override; bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) 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; } ModelStatus status() const { return m_status; }
// Helper methods // Helper methods
@ -184,6 +191,7 @@ signals:
void fieldsChanged(QList<QString> newFields); void fieldsChanged(QList<QString> newFields);
void imageTypesChanged(QList<QString> newImageTypes); void imageTypesChanged(QList<QString> newImageTypes);
void includeItemTypesChanged(const QList<QString> &newIncludeItemTypes); void includeItemTypesChanged(const QList<QString> &newIncludeItemTypes);
void searchTermChanged(QString newSearchTerm);
public slots: public slots:
/** /**
@ -193,6 +201,7 @@ public slots:
protected: protected:
enum LoadType { enum LoadType {
INITIAL_LOAD,
RELOAD, RELOAD,
LOAD_MORE LOAD_MORE
}; };
@ -207,6 +216,14 @@ protected:
* query types specific for a certain model to be available. * query types specific for a certain model to be available.
*/ */
virtual void addQueryParameters(QUrlQuery &query); 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; ApiClient *m_apiClient = nullptr;
ModelStatus m_status = Uninitialised; ModelStatus m_status = Uninitialised;
@ -225,6 +242,7 @@ protected:
// Query properties // Query properties
bool m_addUserId = false; bool m_addUserId = false;
bool m_recursive = false;
QString m_parentId; QString m_parentId;
QString m_seasonId; QString m_seasonId;
QList<QString> m_fields = {}; QList<QString> m_fields = {};
@ -232,7 +250,7 @@ protected:
QList<QString> m_sortBy = {}; QList<QString> m_sortBy = {};
QList<QString> m_includeItemTypes = {}; QList<QString> m_includeItemTypes = {};
SortOrder m_sortOrder = Unspecified; SortOrder m_sortOrder = Unspecified;
bool m_recursive = false; QString m_searchTerm;
QHash<int, QByteArray> m_roles; QHash<int, QByteArray> m_roles;
@ -245,7 +263,7 @@ private:
/** /**
* @brief Generates roleNames based on the first record in m_array. * @brief Generates roleNames based on the first record in m_array.
*/ */
void generateFields(); void generateFields(const QJsonArray &newData);
QString sortByToString(SortOptions::SortBy sortBy); QString sortByToString(SortOptions::SortBy sortBy);
}; };
@ -270,6 +288,31 @@ public slots:
void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> userData); void onUserDataChanged(const QString &itemId, QSharedPointer<UserData> 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<QString, DIFF_SIZE> m_diffTracker;
// Time in ms before we can search again
int m_searchTimeout = 500;
QTimer m_searchTimeoutTimer;
};
class UserViewModel : public ApiModel { class UserViewModel : public ApiModel {
public: public:
explicit UserViewModel (QObject *parent = nullptr); explicit UserViewModel (QObject *parent = nullptr);

View file

@ -138,7 +138,7 @@ public slots:
* *
* The default implementation makes a GET request to getDataUrl() and parses the resulting JSON, * 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 * 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 * they're calling setStatus(Status), setError(QNetworkReply::NetworkError) and
* setErrorString() to let the QML side know what this thing is up to. * setErrorString() to let the QML side know what this thing is up to.
*/ */

View file

@ -30,11 +30,12 @@ ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject
void ApiModel::reload() { void ApiModel::reload() {
this->setStatus(Loading); this->setStatus(Loading);
m_startIndex = 0; m_startIndex = 0;
load(RELOAD);
load(m_array.isEmpty() ? INITIAL_LOAD : RELOAD);
} }
void ApiModel::load(LoadType type) { 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) { if (m_apiClient == nullptr) {
qWarning() << "Please set the apiClient property before (re)loading"; qWarning() << "Please set the apiClient property before (re)loading";
return; return;
@ -81,17 +82,21 @@ void ApiModel::load(LoadType type) {
if (m_recursive) { if (m_recursive) {
query.addQueryItem("Recursive", "true"); query.addQueryItem("Recursive", "true");
} }
if (!m_searchTerm.isEmpty()) {
query.addQueryItem("searchTerm", m_searchTerm);
}
addQueryParameters(query); addQueryParameters(query);
QNetworkReply *rep = m_apiClient->get(m_path, query); QNetworkReply *rep = m_apiClient->get(m_path, query);
connect(rep, &QNetworkReply::finished, this, [this, type, rep]() { connect(rep, &QNetworkReply::finished, this, [this, type, rep]() {
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll()); QJsonDocument doc = QJsonDocument::fromJson(rep->readAll());
QJsonArray items;
if (!m_hasRecordResponse) { if (!m_hasRecordResponse) {
if (!doc.isArray()) { if (!doc.isArray()) {
qWarning() << "Object is not an array!"; qWarning() << "Object is not an array!";
this->setStatus(Error); this->setStatus(Error);
return; return;
} }
this->m_array = doc.array(); items = doc.array();
} else { } else {
if (!doc.isObject()) { if (!doc.isObject()) {
qWarning() << "Object is not an object!"; qWarning() << "Object is not an object!";
@ -120,10 +125,10 @@ void ApiModel::load(LoadType type) {
this->setStatus(Error); this->setStatus(Error);
return; return;
} }
QJsonArray items = obj["Items"].toArray(); items = obj["Items"].toArray();
switch(type) { switch(type) {
case INITIAL_LOAD:
case RELOAD: case RELOAD:
this->m_array = items;
break; break;
case LOAD_MORE: case LOAD_MORE:
this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1); this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1);
@ -138,27 +143,35 @@ void ApiModel::load(LoadType type) {
break; 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); this->setStatus(Ready);
rep->deleteLater(); rep->deleteLater();
}); });
} }
void ApiModel::generateFields() { void ApiModel::generateFields(const QJsonArray &newData) {
if (m_array.size() == 0) return; if (newData.size() == 0) return;
this->beginResetModel();
int i = Qt::UserRole + 1; int i = Qt::UserRole + 1;
if (!m_array[0].isObject()) { if (!newData[0].isObject()) {
qWarning() << "Iterator is not an object?"; qWarning() << "Iterator is not an object?";
return; return;
} }
// Walks over the keys in the first record and adds them to the rolenames. // 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 // 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. // this heuristic hopefully suffices.
QJsonObject ob = m_array[0].toObject(); QJsonObject ob = newData[0].toObject();
for (auto jt = ob.begin(); jt != ob.end(); jt++) { for (auto jt = ob.begin(); jt != ob.end(); jt++) {
QString keyName = jt.key(); QString keyName = jt.key();
keyName[0] = keyName[0].toLower(); keyName[0] = keyName[0].toLower();
@ -167,9 +180,12 @@ void ApiModel::generateFields() {
m_roles.insert(i++, keyArr); 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(); this->endResetModel();
} }
@ -245,6 +261,124 @@ void ItemModel::onUserDataChanged(const QString &itemId, QSharedPointer<UserData
} }
} }
SearchModel::SearchModel(QObject *parent)
: ItemModel("/Users/{{user}}/Items", true, false, parent) {
m_diffTracker.fill("");
m_searchTimeoutTimer.setInterval(m_searchTimeout);
connect(this, &SearchModel::searchTimeoutChanged, &m_searchTimeoutTimer, &QTimer::setInterval);
m_searchTimeoutTimer.setSingleShot(true);
connect(&m_searchTimeoutTimer, &QTimer::timeout, this, &ApiModel::reload);
}
void SearchModel::setArray(const QJsonArray &newData) {
// Calculate the movement of the search results in the first DIFF_SIZE results
qDebug() << "Calculating diff";
int maxLoop = std::min(static_cast<int>(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<int>(m_diffTracker.size()); j++) {
// Stop when we're hitting empty entries
if (m_diffTracker[static_cast<size_t>(j)].isEmpty()) {
j = DIFF_SIZE;
break;
}
if (newData[i].toObject()["id"] == m_diffTracker[static_cast<size_t>(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<int>(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<int>(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<size_t>(i)] = newData[i].toObject()["id"].toString();
qDebug() << i << " changed";
}
for (size_t i = static_cast<size_t>(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<int>(DIFF_SIZE)) return;
int common = std::max(static_cast<int>(DIFF_SIZE), std::min(newData.size(), m_array.size()));
for (int i = static_cast<int>(DIFF_SIZE); i < common; i++) {
m_array.replace(i, newData[i]);
qDebug() << i << " replaced (tail)";
}
emit dataChanged(index(static_cast<int>(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) PublicUserModel::PublicUserModel(QObject *parent)
: ApiModel ("/users/public", false, false, parent) { } : ApiModel ("/users/public", false, false, parent) { }
@ -280,5 +414,6 @@ void registerModels(const char *URI) {
qmlRegisterType<ShowNextUpModel>(URI, 1, 0, "ShowNextUpModel"); qmlRegisterType<ShowNextUpModel>(URI, 1, 0, "ShowNextUpModel");
qmlRegisterType<ShowSeasonsModel>(URI, 1, 0, "ShowSeasonsModel"); qmlRegisterType<ShowSeasonsModel>(URI, 1, 0, "ShowSeasonsModel");
qmlRegisterType<ShowEpisodesModel>(URI, 1, 0, "ShowEpisodesModel"); qmlRegisterType<ShowEpisodesModel>(URI, 1, 0, "ShowEpisodesModel");
qmlRegisterType<SearchModel>(URI, 1, 0, "SearchModel");
} }
} }

View file

@ -23,6 +23,7 @@ set(sailfin_QML_SOURCES
qml/components/music/NarrowAlbumCover.qml qml/components/music/NarrowAlbumCover.qml
qml/components/music/WideAlbumCover.qml qml/components/music/WideAlbumCover.qml
qml/components/music/SongDelegate.qml qml/components/music/SongDelegate.qml
qml/components/search/SearchResults.qml
qml/components/videoplayer/VideoError.qml qml/components/videoplayer/VideoError.qml
qml/components/videoplayer/VideoHud.qml qml/components/videoplayer/VideoHud.qml
qml/components/GlassyBackground.qml qml/components/GlassyBackground.qml

View file

@ -30,6 +30,7 @@ SilicaItem {
property string fallbackImage property string fallbackImage
property bool usingFallbackImage property bool usingFallbackImage
property color fallbackColor: Theme.highlightColor property color fallbackColor: Theme.highlightColor
property bool hideFallbackColor: false
property var __parentPage : null property var __parentPage : null
property bool alreadyLoaded: false property bool alreadyLoaded: false
@ -56,7 +57,7 @@ SilicaItem {
anchors.fill: parent anchors.fill: parent
asynchronous: true asynchronous: true
fillMode: root.fillMode fillMode: root.fillMode
opacity: 1 opacity: hideFallbackColor ? 0.0 : 1.0
source: alreadyLoaded || [PageStatus.Active, PageStatus.Deactivating].indexOf(__parentPage.status) >= 0 ? root.source : "" source: alreadyLoaded || [PageStatus.Active, PageStatus.Deactivating].indexOf(__parentPage.status) >= 0 ? root.source : ""
onStatusChanged: { onStatusChanged: {
if (status == Image.Ready) { if (status == Image.Ready) {
@ -73,6 +74,7 @@ SilicaItem {
GradientStop { position: 1.0; color: Theme.highlightDimmerFromColor(fallbackColor, Theme.colorScheme); } GradientStop { position: 1.0; color: Theme.highlightDimmerFromColor(fallbackColor, Theme.colorScheme); }
} }
opacity: 0 opacity: 0
visible: !hideFallbackColor
} }
Image { Image {
@ -132,7 +134,7 @@ SilicaItem {
when: realImage.status === Image.Ready when: realImage.status === Image.Ready
PropertyChanges { PropertyChanges {
target: realImage target: realImage
//opacity: 1 opacity: 1
} }
} }
] ]

View file

@ -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
}
}

View file

@ -22,6 +22,7 @@ import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0 import nl.netsoj.chris.Jellyfin 1.0
import "../components" import "../components"
import "../components/search"
import "../" import "../"
import "../Utils.js" as Utils import "../Utils.js" as Utils
@ -36,6 +37,7 @@ Page {
allowedOrientations: Orientation.All allowedOrientations: Orientation.All
SilicaFlickable { SilicaFlickable {
id: mainView
anchors.fill: parent anchors.fill: parent
// PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView // PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView
@ -66,7 +68,25 @@ Page {
id: mediaLibraryModel2 id: mediaLibraryModel2
apiClient: ApiClient apiClient: ApiClient
} }
Item {
id: searchBoxPlaceholder
width: parent.width
height: searchBox.height
SearchField {
id: searchBox
width: parent.width
placeholderText: qsTr("Search")
}
}
Label {
text: "%1 results".arg(searchView.count)
}
Column {
id: homeView
width: parent.width
MoreSection { MoreSection {
text: qsTr("Resume watching") text: qsTr("Resume watching")
clickable: false clickable: false
@ -140,6 +160,7 @@ Page {
} }
} }
} }
}
Column { Column {
id: errorColumn id: errorColumn
@ -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: { onStatusChanged: {
if (status == PageStatus.Active) { if (status == PageStatus.Active) {
appWindow.itemData = null 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. * even if loaded.
*/ */
function loadModels(force) { function loadModels(force) {

View file

@ -68,6 +68,7 @@ Page {
top: parent.top top: parent.top
bottom: parent.bottom bottom: parent.bottom
} }
hideFallbackColor: true
source: ApiClient.baseUrl + "/Users/" + ApiClient.userId + "/Images/Primary?tag=" + loggedInUser.primaryImageTag source: ApiClient.baseUrl + "/Users/" + ApiClient.userId + "/Images/Primary?tag=" + loggedInUser.primaryImageTag
} }