From fb47b92353b60b0fe4954cfac5174766d1704368 Mon Sep 17 00:00:00 2001 From: Digital Artifex <7929434+DigitalArtifex@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:06:05 -0400 Subject: [PATCH] Added video search page and model --- KomplexHubContent/CMakeLists.txt | 1 + KomplexHubContent/MainScreen.qml | 25 +- KomplexHubContent/pages/VideoSearchPage.qml | 225 ++++++++++++++ KomplexHubPlugin/CMakeLists.txt | 4 +- KomplexHubPlugin/featuredvideosmodel.cpp | 2 +- KomplexHubPlugin/videosearchmodel.cpp | 288 ++++++++++++++++++ KomplexHubPlugin/videosearchmodel.h | 315 ++++++++++++++++++++ 7 files changed, 856 insertions(+), 4 deletions(-) create mode 100644 KomplexHubContent/pages/VideoSearchPage.qml create mode 100644 KomplexHubPlugin/videosearchmodel.cpp create mode 100644 KomplexHubPlugin/videosearchmodel.h diff --git a/KomplexHubContent/CMakeLists.txt b/KomplexHubContent/CMakeLists.txt index ae86ebf..b1aea49 100644 --- a/KomplexHubContent/CMakeLists.txt +++ b/KomplexHubContent/CMakeLists.txt @@ -17,6 +17,7 @@ qt6_add_qml_module(KomplexHubContent "pages/SettingsPage.qml" "pages/UserProfilePage.qml" "pages/WallpaperSettingsPage.qml" + QML_FILES pages/VideoSearchPage.qml ) diff --git a/KomplexHubContent/MainScreen.qml b/KomplexHubContent/MainScreen.qml index e5adfc9..6c5c595 100644 --- a/KomplexHubContent/MainScreen.qml +++ b/KomplexHubContent/MainScreen.qml @@ -83,7 +83,27 @@ Rectangle { currentMenuButton = this searchContainer.preferredHeight = 50 - // searchContainer.searchTerm = (pageLoader.page as ImageSearchPage).query + } + } + + MenuButton { + Layout.alignment: Qt.AlignTop + + height: 64 + width: 100 + text: "Videos" + + icon.height: 32 + icon.width: 32 + icon.url: "qrc:/images/icons/icons8-video.svg" + + onTriggered: () => { + pageLoader.page = "pages/VideoSearchPage.qml" + + currentMenuButton.selected = false + currentMenuButton = this + + searchContainer.preferredHeight = 50 } } @@ -287,7 +307,8 @@ Rectangle { { return ( pageLoader.item instanceof ImageSearchPage || - pageLoader.item instanceof LiveSearchPage + pageLoader.item instanceof LiveSearchPage || + pageLoader.item instanceof VideoSearchPage ); } } diff --git a/KomplexHubContent/pages/VideoSearchPage.qml b/KomplexHubContent/pages/VideoSearchPage.qml new file mode 100644 index 0000000..04cfa07 --- /dev/null +++ b/KomplexHubContent/pages/VideoSearchPage.qml @@ -0,0 +1,225 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import KomplexHub +import KomplexHub.Controls +import KomplexHub.Kero +import KomplexHubPlugin + +Item +{ + property string query + readonly property bool searchable: true + property int resultsPerRow: (resultsContainer.width - (Constants.largeMargin * 2)) / (resultWidth + Constants.mediumMargin) + property int rows: ((resultsContainer.height - (Constants.largeMargin * 2)) / (resultHeight + Constants.mediumMargin)) + property int resultsPerPage: resultsPerRow * rows + property int emptyWidth: (resultsContainer.width - (Constants.largeMargin * 2)) - (resultsPerRow * (resultWidth + Constants.mediumMargin)) + property int resultWidth: 256 + property int resultHeight: 245 + + id: videoSearchPageRoot + + VideoSearchModel + { + id: searchModel + query: videoSearchPageRoot.query + resultsPerPage: videoSearchPageRoot.resultsPerPage + } + + Rectangle + { + anchors.fill: parent + color: palette.base + + Rectangle + { + id: resultsContainer + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: buttonBoxLayout.top + + color: "transparent" + + ScrollView + { + id: resultsView + anchors.fill: parent + + RowLayout + { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.top: parent.top + anchors.margins: Constants.largeMargin + + Item + { + Layout.fillHeight: true + Layout.preferredWidth: emptyWidth / 2 + Layout.maximumWidth: emptyWidth / 2 + } + + GridLayout + { + Layout.fillHeight: true + Layout.fillWidth: true + + columns: videoSearchPageRoot.resultsPerRow + columnSpacing: Constants.mediumMargin + rowSpacing: Constants.mediumMargin + + Repeater + { + model: searchModel + + delegate: Item { + width: resultWidth + height: resultHeight + + required property string uuid + required property string author + required property string authorId + required property string authorUrl + required property string thumbnail + + SearchResultItem + { + anchors.fill: parent + title: parent.author + author: parent.author + description: "Video provided by Pexels" + thumbnail: parent.thumbnail + uuid: parent.uuid + } + } + } + } + } + } + } + + RowLayout + { + id: buttonBoxLayout + height: 35 + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Constants.largeMargin + + SquareButton + { + Layout.fillHeight: true + Layout.preferredWidth: 128 + + icon.source: "qrc:/images/icons/icons8-back.svg" + icon.height: 16 + icon.width: 16 + text: qsTr("Previous") + enabled: searchModel.hasPreviousPage + + onTriggered: () => searchModel.previousPage() + } + + Item { Layout.fillWidth: true } + + SquareButton + { + Layout.fillHeight: true + Layout.preferredWidth: 128 + + icon.source: "qrc:/images/icons/icons8-next.svg" + icon.height: 16 + icon.width: 16 + text: qsTr("Next") + enabled: searchModel.hasNextPage + + onTriggered: () => searchModel.nextPage() + } + } + } + + KeroErrorAvatar + { + id: errorOverlay + + anchors.fill: parent + opacity: 0 + } + + KeroNoResultsAvatar + { + id: noResultsOverlay + + anchors.fill: parent + opacity: 0 + } + + KeroLoadingAnimation + { + id: loadingOverlay + + anchors.fill: parent + opacity: 0 + } + + states: [ + State + { + name: "loading" + when: searchModel.state == VideoSearchModel.Loading + + PropertyChanges + { + target: loadingOverlay + opacity: 1 + } + PropertyChanges + { + target: resultsView + opacity: 0 + } + }, + State + { + name: "empty" + when: searchModel.totalResults === 0 + + PropertyChanges + { + target: noResultsOverlay + opacity: 1 + } + PropertyChanges + { + target: resultsView + opacity: 0 + } + PropertyChanges + { + target: buttonBoxLayout + visible: false + } + }, + State + { + name: "error" + when: searchModel.state == VideoSearchModel.Error + + PropertyChanges + { + target: errorOverlay + opacity: 1 + } + PropertyChanges + { + target: resultsView + opacity: 0 + } + } + ] +} diff --git a/KomplexHubPlugin/CMakeLists.txt b/KomplexHubPlugin/CMakeLists.txt index 5c9632b..b4894d1 100644 --- a/KomplexHubPlugin/CMakeLists.txt +++ b/KomplexHubPlugin/CMakeLists.txt @@ -38,7 +38,7 @@ qt_add_qml_module( SOURCES paginators/featuredcubemapspaginator.h SOURCES paginators/searchshaderspaginator.h SOURCES paginators/searchcubemapspaginator.h - SOURCES paginators/searchvideospaginator.h + SOURCES paginators/videosearchpaginator.h SOURCES featuredpacksmodel.h SOURCES featuredpacksmodel.cpp SOURCES featuredimagesmodel.h @@ -52,6 +52,8 @@ qt_add_qml_module( SOURCES paginators/videopaginator.h SOURCES imagesearchmodel.cpp SOURCES imagesearchmodel.h + SOURCES videosearchmodel.h + SOURCES videosearchmodel.cpp ) diff --git a/KomplexHubPlugin/featuredvideosmodel.cpp b/KomplexHubPlugin/featuredvideosmodel.cpp index 1de1be0..662ec3b 100644 --- a/KomplexHubPlugin/featuredvideosmodel.cpp +++ b/KomplexHubPlugin/featuredvideosmodel.cpp @@ -94,7 +94,7 @@ auto FeaturedVideosModel::data(const QModelIndex &index, int role) const -> QVar case WidthRole: data = dataPoint.width; break; - } + } return data; } diff --git a/KomplexHubPlugin/videosearchmodel.cpp b/KomplexHubPlugin/videosearchmodel.cpp new file mode 100644 index 0000000..7e696a4 --- /dev/null +++ b/KomplexHubPlugin/videosearchmodel.cpp @@ -0,0 +1,288 @@ +#include "videosearchmodel.h" + +VideoSearchModel::VideoSearchModel(QObject *parent) : QAbstractListModel{parent} +{ + setState(Loading); + + m_paginator = new VideoSearchPaginator(this); + PaginationNotifier *notifier = static_cast(m_paginator); + + QObject::connect( + notifier, + &PaginationNotifier::resultsPerPageChanged, + this, + &VideoSearchModel::resultsPerPageChanged + ); + + QObject::connect( + notifier, + &PaginationNotifier::totalResultsChanged, + this, + &VideoSearchModel::totalResultsChanged + ); + + QObject::connect( + notifier, + &PaginationNotifier::totalPagesChanged, + this, + &VideoSearchModel::totalPagesChanged + ); + + QObject::connect( + notifier, + &PaginationNotifier::pageChanged, + this, + &VideoSearchModel::pageChanged + ); + + QObject::connect( + notifier, + &PaginationNotifier::dataChanged, + this, + &VideoSearchModel::resetDataModel + ); + + QObject::connect( + m_paginator, + &VideoSearchPaginator::queryChanged, + this, + &VideoSearchModel::queryChanged + ); +} + +auto VideoSearchModel::rowCount(const QModelIndex &) const -> int +{ + if(m_paginator != nullptr) + { + return m_paginator->count(); + } + + return 0; +} + +auto VideoSearchModel::data(const QModelIndex &index, int role) const -> QVariant +{ + if(index.row() < 0 || m_paginator == nullptr) + { + return {}; + } + + VideoCache dataPoint = m_data.at(index.row()); + + QVariant data; + + switch(static_cast(role)) + { + case UuidRole: + data = dataPoint.id; + break; + case AuthorRole: + data = dataPoint.author; + break; + case AuthorIdRole: + data = dataPoint.authorId; + break; + case AuthorUrlRole: + data = dataPoint.authorUrl; + break; + case DurationRole: + data = dataPoint.duration; + break; + case ThumbnailRole: + data = dataPoint.thumbnail; + break; + case UrlRole: + data = dataPoint.url; + break; + case HeightRole: + data = dataPoint.height; + break; + case WidthRole: + data = dataPoint.width; + break; + } + + return data; +} + +auto VideoSearchModel::index(int row, int column, const QModelIndex &parent) const -> QModelIndex +{ + Q_UNUSED(parent) + + if(row < 0 || row >= m_data.count()) + return {}; + + return createIndex(row, column, &m_data[row]); +} + +auto VideoSearchModel::columnCount(const QModelIndex &) const -> int +{ + return 0; +} + +auto VideoSearchModel::parent(const QModelIndex &) const -> QModelIndex +{ + return {}; +} + +auto VideoSearchModel::setData(const QModelIndex &, const QVariant &, int) -> bool +{ + return false; +} + +auto VideoSearchModel::state() const -> VideoSearchModel::State +{ + return m_state; +} + +auto VideoSearchModel::setState(State state) -> void +{ + if (m_state == state) + { + return; + } + + m_state = state; + emit stateChanged(); +} + +auto VideoSearchModel::resetState() -> void +{ + setState(Idle); +} + +auto VideoSearchModel::resetDataModel() -> void +{ + setState(Loading); + + //invalidate previous model data + beginRemoveRows(QModelIndex(), 0, m_data.count()); + m_data.clear(); + endRemoveRows(); + + //signal new data + beginInsertRows(QModelIndex(), 0, m_paginator->count() - 1); + auto dataset = m_paginator->data(); + + for(const VideoCache& data : std::as_const(dataset)) + { + m_data.append(data); + } + + endInsertRows(); + + setState(Idle); +} + +auto VideoSearchModel::hasNextPage() -> bool +{ + return m_paginator->page() < m_paginator->totalPages(); +} + +auto VideoSearchModel::hasPreviousPage() -> bool +{ + return m_paginator->page() > 0; +} + +auto VideoSearchModel::roleNames() const -> QHash +{ + return m_dataRoles; +} + +auto VideoSearchModel::errorString() const -> QString +{ + return m_errorString; +} + +auto VideoSearchModel::setErrorString(const QString &errorString) -> void +{ + if (m_errorString == errorString) + { + return; + } + + m_errorString = errorString; + emit errorStringChanged(); +} + +auto VideoSearchModel::resultsPerPage() const -> qsizetype +{ + if(m_paginator != nullptr) + { + return m_paginator->resultsPerPage(); + } + + return 0; +} + +auto VideoSearchModel::setResultsPerPage(qsizetype resultsPerPage) -> void +{ + if(m_paginator != nullptr) + { + m_paginator->setResultsPerPage(resultsPerPage); + } +} + +auto VideoSearchModel::totalResults() const -> qsizetype +{ + if(m_paginator != nullptr) + { + return m_paginator->totalResults(); + } + + return 0; +} + +auto VideoSearchModel::nextPage() const -> void +{ + if(m_paginator != nullptr) + { + m_paginator->next(); + } +} + +auto VideoSearchModel::previousPage() const -> void +{ + if(m_paginator != nullptr) + { + m_paginator->previous(); + } +} + +auto VideoSearchModel::query() const -> QString +{ + if(m_paginator != nullptr) + { + return m_paginator->query(); + } + + return {}; +} + +auto VideoSearchModel::setQuery(const QString &query) -> void +{ + if(m_paginator != nullptr) + { + m_paginator->setQuery(query); + } +} + +qsizetype VideoSearchModel::page() const +{ + if(m_paginator != nullptr) + { + return m_paginator->page(); + } + + return 0; +} + +auto VideoSearchModel::totalPages() const -> qsizetype +{ + if(m_paginator != nullptr) + { + return m_paginator->totalPages(); + } + + return 0; +} diff --git a/KomplexHubPlugin/videosearchmodel.h b/KomplexHubPlugin/videosearchmodel.h new file mode 100644 index 0000000..2c3c470 --- /dev/null +++ b/KomplexHubPlugin/videosearchmodel.h @@ -0,0 +1,315 @@ +/* + * Komplex Wallpaper Engine + * Copyright (C) 2026 @DigitalArtifex + * https://digitalartifex.dev - https://github.com/DigitalArtifex + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ +#ifndef VideoSearchModel_H +#define VideoSearchModel_H +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "paginators/videosearchpaginator.h" + +/** + * @brief The VideoSearchModel class + */ +class KOMPLEX_EXPORT VideoSearchModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT +public: + + /** + * @brief Used to control the UI state + */ + enum State + { + Idle, + Loading, + Error + }; + Q_ENUM(State) + + /** + * @brief Data roles used by the view to request data + */ + enum DataRole { + UuidRole = Qt::UserRole + 1, + AuthorRole, + AuthorIdRole, + AuthorUrlRole, + DurationRole, + ThumbnailRole, + UrlRole, + HeightRole, + WidthRole + }; + Q_ENUM(DataRole) + + explicit VideoSearchModel(QObject *parent = nullptr); + + auto rowCount(const QModelIndex &parent = QModelIndex()) const -> int override; + + /** + * @brief data + * Used by the view to pull data from the model. + * Required by QAbstractListModel + * @param index + * @param role + * @return + */ + auto data( + const QModelIndex &index, + int role = Qt::DisplayRole + ) const -> QVariant override; + + /** + * @brief index + * Used by the view to create an index for the data point. + * Required by QAbstractListModel + * @param row + * @param column + * @param parent + * @return + */ + auto index( + int row, + int column, + const QModelIndex &parent = QModelIndex() + ) const -> QModelIndex override; + + /** + * @brief columnCount + * Required by QAbstractListModel, but just returns 0 + * since we dont use cols + * @param parent + * @return + */ + auto columnCount( + const QModelIndex &parent = QModelIndex() + ) const -> int override; + + /** + * @brief parent + * @param index + * @return + */ + auto parent(const QModelIndex &index) const -> QModelIndex override; + + /** + * @brief setData + * Required by QAbstractListModel but not used + * @param index + * @param value + * @param role + * @return true always + */ + auto setData( + const QModelIndex &index, + const QVariant &value, + int role = Qt::EditRole + ) -> bool override; + + /** + * @brief state + * Current Model State + * @return + */ + auto state() const -> State; + + /** + * @brief roleNames + * Used to tell the view what data roles are available + * and their QML friendly names + * @return + */ + auto roleNames() const -> QHash override; + + /** + * @brief errorString + * User-friendly error string to be reported to the user + * @return + */ + auto errorString() const -> QString; + + /** + * @brief resultsPerPage + * @return + */ + auto resultsPerPage() const -> qsizetype; + + /** + * @brief setResultsPerPage + * @param resultsPerPage + */ + auto setResultsPerPage(qsizetype resultsPerPage) -> void; + + /** + * @brief totalResults + * Total number of results available + * @return + */ + auto totalResults() const -> qsizetype; + + /** + * @brief page + * @return + */ + auto page() const -> qsizetype; + + /** + * @brief totalPages + * Total pages based on the current resultsPerPage + * @return + */ + auto totalPages() const -> qsizetype; + + /** + * @brief nextPage + * Function for the QML frontend + */ + Q_INVOKABLE auto nextPage() const -> void; + + /** + * @brief previousPage + * Function for the QML frontend + */ + Q_INVOKABLE auto previousPage() const -> void; + + /** + * @brief query + * Current search query + * @return + */ + auto query() const -> QString; + + /** + * @brief setQuery + * Sets the current + * @param query + */ + auto setQuery(const QString &query) -> void; + +protected: + /** + * @brief setErrorString + * @param errorString + */ + auto setErrorString(const QString &errorString) -> void; + + /** + * @brief setState + * @param state + */ + auto setState(State state) -> void; + + /** + * @brief resetState + */ + auto resetState() -> void; + + auto resetDataModel() -> void; + + auto hasNextPage() -> bool; + + auto hasPreviousPage() -> bool; + +signals: + auto stateChanged() -> void; + auto errorStringChanged() -> void; + auto resultsPerPageChanged() -> void; + + auto pageChanged() -> void; + auto totalResultsChanged() -> void; + auto totalPagesChanged() -> void; + auto queryChanged() -> void; + +private: + static inline const QHash m_dataRoles = + { + { + static_cast(UuidRole), + QByteArray("uuid") + }, + { + static_cast(AuthorRole), + QByteArray("author") + }, + { + static_cast(AuthorIdRole), + QByteArray("authorId") + }, + { + static_cast(AuthorUrlRole), + QByteArray("authorUrl") + }, + { + static_cast(DurationRole), + QByteArray("duration") + }, + { + static_cast(ThumbnailRole), + QByteArray("thumbnail") + }, + { + static_cast(UrlRole), + QByteArray("url") + }, + { + static_cast(HeightRole), + QByteArray("height") + }, + { + static_cast(WidthRole), + QByteArray("width") + } + }; + State m_state = Idle; + + QString m_errorString = QString(); + + VideoSearchPaginator *m_paginator = nullptr; + + mutable QList m_data; + + Q_PROPERTY(State state READ state WRITE setState RESET resetState NOTIFY stateChanged FINAL) + Q_PROPERTY(QString errorString READ errorString WRITE setErrorString NOTIFY errorStringChanged FINAL) + Q_PROPERTY(qsizetype resultsPerPage READ resultsPerPage WRITE setResultsPerPage NOTIFY resultsPerPageChanged FINAL) + Q_PROPERTY(qsizetype totalResults READ totalResults NOTIFY totalResultsChanged FINAL) + Q_PROPERTY(qsizetype totalPages READ totalPages NOTIFY totalPagesChanged FINAL) + Q_PROPERTY(qsizetype page READ page NOTIFY pageChanged FINAL) + Q_PROPERTY(bool hasNextPage READ hasNextPage NOTIFY pageChanged FINAL) + Q_PROPERTY(bool hasPreviousPage READ hasPreviousPage NOTIFY pageChanged FINAL) + Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged FINAL) +}; +Q_DECLARE_METATYPE(VideoSearchModel) + +#endif // VideoSearchModel_H