From 15cef03f08eedee701f20392cfed0e907480651e Mon Sep 17 00:00:00 2001 From: Gary Wang Date: Sat, 9 Nov 2024 15:05:14 +0800 Subject: [PATCH] initial commit --- .gitignore | 9 +++ CMakeLists.txt | 62 ++++++++++++++++ ComicOverviewPage.qml | 67 +++++++++++++++++ ComicSelectionPage.qml | 42 +++++++++++ ComicViewer.qml | 89 +++++++++++++++++++++++ ConnectServerPage.qml | 43 +++++++++++ LibrarySelectionPage.qml | 40 +++++++++++ Main.qml | 25 +++++++ appcontroller.cpp | 148 ++++++++++++++++++++++++++++++++++++++ appcontroller.h | 57 +++++++++++++++ dataitems/comicitem.cpp | 11 +++ dataitems/comicitem.h | 21 ++++++ dataitems/libraryitem.cpp | 8 +++ dataitems/libraryitem.h | 16 +++++ main.cpp | 55 ++++++++++++++ 15 files changed, 693 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 ComicOverviewPage.qml create mode 100644 ComicSelectionPage.qml create mode 100644 ComicViewer.qml create mode 100644 ConnectServerPage.qml create mode 100644 LibrarySelectionPage.qml create mode 100644 Main.qml create mode 100644 appcontroller.cpp create mode 100644 appcontroller.h create mode 100644 dataitems/comicitem.cpp create mode 100644 dataitems/comicitem.h create mode 100644 dataitems/libraryitem.cpp create mode 100644 dataitems/libraryitem.h create mode 100644 main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c50ac7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# User files +*.user +*.user.* + +# Generic build dir +[Bb]uild/ + +# IDE/Editor config folder +.vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..711eddc --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.16) + +project(pineapple-comic-reader VERSION 0.1 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Qt6 6.8 REQUIRED COMPONENTS Quick Network) + +qt_standard_project_setup(REQUIRES 6.8) + +qt_add_executable(pcomic + main.cpp + appcontroller.cpp + appcontroller.h + dataitems/libraryitem.cpp + dataitems/libraryitem.h + dataitems/comicitem.cpp + dataitems/comicitem.h +) + +qt_add_qml_module(pcomic + URI net.blumia.pineapple.comic.reader + VERSION 1.0 + QML_FILES + Main.qml + ConnectServerPage.qml + LibrarySelectionPage.qml + ComicSelectionPage.qml + ComicOverviewPage.qml + ComicViewer.qml +) + +# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1. +# If you are developing for iOS or macOS you should consider setting an +# explicit, fixed bundle identifier manually though. +set_target_properties(pcomic PROPERTIES +# MACOSX_BUNDLE_GUI_IDENTIFIER net.blumia.pineapple.comic.reader + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR} + MACOSX_BUNDLE TRUE + WIN32_EXECUTABLE TRUE + QT_ANDROID_PACKAGE_NAME "net.blumia.pineapple.comic.reader" +) + +target_link_libraries(pcomic + PRIVATE Qt6::Quick Qt6::Network +) + +qt_import_plugins(pcomic + INCLUDE_BY_TYPE imageformats + # These are the ones that ship by default if we don't use `qt_import_plugins` + Qt::QGifPlugin Qt::QIcoPlugin Qt::QSvgPlugin Qt::QJpegPlugin + # We need webp support from Qt imageformats module. + Qt::QWebpPlugin +) + +include(GNUInstallDirs) +install(TARGETS pcomic + BUNDLE DESTINATION . + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) diff --git a/ComicOverviewPage.qml b/ComicOverviewPage.qml new file mode 100644 index 0000000..bf2cd60 --- /dev/null +++ b/ComicOverviewPage.qml @@ -0,0 +1,67 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import net.blumia.pineapple.comic.reader +import net.blumia.pineapple.comic.reader.comicitem + +Control { + id: root + anchors.fill: parent + padding: 5 + + property var curComicIndex: AppController.currentComicModelIndex() + property bool startReading: AppController.selectedComicOpened + + function dataByRole(role) { + return AppController.comicsModel.data(curComicIndex, role) + } + + contentItem: ColumnLayout { + Image { + Layout.fillWidth: true + Layout.maximumHeight: Math.min(root.width / 2, root.height / 2) + fillMode: Image.PreserveAspectFit + source: AppController.coverImageSource(dataByRole(ComicItem.HashRole)) + // sourceSize.width: root.width / 3 + // sourceSize.height: root.width / 2 + } + Label { + Layout.preferredWidth: root.width - 10 + text: dataByRole(Qt.DisplayRole) + wrapMode: Text.Wrap + } + Label { + Layout.preferredWidth: root.width - 10 + text: `${dataByRole(ComicItem.PageCountRole)} pages` + wrapMode: Text.Wrap + } + Label { + Layout.preferredWidth: root.width - 10 + text: `Last read at ${dataByRole(ComicItem.CurrentPageRole)} page` + wrapMode: Text.Wrap + } + Button { + Layout.fillWidth: true + text: "Read" + onClicked: function() { + AppController.openComic() + } + } + Button { + Layout.fillWidth: true + text: "Back" + onClicked: function() { + AppController.selectedComicId = "" + } + } + Item { + Layout.fillHeight: true + } + } + + ComicViewer { + z: 2 + visible: root.startReading + pageCount: dataByRole(ComicItem.PageCountRole) + } +} diff --git a/ComicSelectionPage.qml b/ComicSelectionPage.qml new file mode 100644 index 0000000..2926aa4 --- /dev/null +++ b/ComicSelectionPage.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + anchors.fill: parent + Label { + text: "Comics" + font.pixelSize: 20 + } + GridView { + id: gridView + clip: true + cellHeight: cellWidth * 3 / 2 + cellWidth: width / Math.max(Math.floor(width / (65 * 2)), 1) + Layout.fillWidth: true + Layout.fillHeight: true + model: AppController.comicsModel + delegate: Button { + width: GridView.view.cellWidth + height: GridView.view.cellHeight + Column { + Image { + source: AppController.coverImageSource(model.hash) + width: gridView.cellWidth + height: gridView.cellHeight + fillMode: Image.PreserveAspectFit + retainWhileLoading: true + } + } + + onClicked: function() { + AppController.selectedComicId = model.comicId + } + } + ScrollBar.vertical: ScrollBar { } + } + + Component.onCompleted: function() { + AppController.updateComicsInFolder() + } +} diff --git a/ComicViewer.qml b/ComicViewer.qml new file mode 100644 index 0000000..e4cbcc1 --- /dev/null +++ b/ComicViewer.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import net.blumia.pineapple.comic.reader + +Pane { + id: root + required property int pageCount + + property bool osdVisible: false + + anchors.fill: parent + + SwipeView { + id: view + anchors.fill: parent + + Repeater { + model: root.pageCount + Loader { + active: root.visible && (SwipeView.isCurrentItem || SwipeView.isNextItem || SwipeView.isPreviousItem) + sourceComponent: Image { + anchors.fill: parent + fillMode: Image.PreserveAspectFit + source: AppController.comicImageSource(index) + onStatusChanged: function() { + if (status == Image.Error) { + reloadTimer.start() + } + } + Timer { + id: reloadTimer + interval: 500 + onTriggered: { + console.log("reload") + let origSrc = parent.source + parent.source = "" + parent.source = origSrc + } + } + } + } + } + } + + Pane { + visible: root.osdVisible + anchors.fill: parent + opacity: 0.6 + } + + ColumnLayout { + visible: root.osdVisible + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + TapHandler { + onTapped: function() { + root.osdVisible = !root.osdVisible + } + } + } + Button { + text: "Close Comic" + Layout.fillWidth: true + onClicked: function() { + AppController.closeComic() + } + } + } + + PageIndicator { + id: indicator + + visible: !root.osdVisible + count: view.count + currentIndex: view.currentIndex + + anchors.top: view.top + anchors.horizontalCenter: parent.horizontalCenter + + TapHandler { + onTapped: function() { + root.osdVisible = !root.osdVisible + } + } + } +} diff --git a/ConnectServerPage.qml b/ConnectServerPage.qml new file mode 100644 index 0000000..269e83f --- /dev/null +++ b/ConnectServerPage.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import net.blumia.pineapple.comic.reader + +Control { + anchors.fill: parent + padding: 5 + contentItem: ColumnLayout { + Label { + text: "Pineapple Comic Reader" + font.pixelSize: 20 + } + Item { + Layout.fillHeight: true + Layout.verticalStretchFactor: 2 + } + Label { + text: "YACReader Library Server URL:" + } + TextField { + id: baseUrlEdit + Layout.fillWidth: true + enabled: AppController.connectionState === AppController.NotConnected + } + Item { + Layout.fillHeight: true + Layout.verticalStretchFactor: 1 + } + Button { + Layout.fillWidth: true + text: AppController.connectionState === AppController.NotConnected ? "Connect" : "Connecting" + enabled: baseUrlEdit.enabled + onClicked: function() { + AppController.connectServer(baseUrlEdit.text) + } + } + Item { + Layout.fillHeight: true + Layout.verticalStretchFactor: 3 + } + } +} diff --git a/LibrarySelectionPage.qml b/LibrarySelectionPage.qml new file mode 100644 index 0000000..99518c1 --- /dev/null +++ b/LibrarySelectionPage.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import net.blumia.pineapple.comic.reader +import net.blumia.pineapple.comic.reader.libraryitem + +Control { + anchors.fill: parent + padding: 5 + contentItem: ColumnLayout { + Label { + text: "Libraries" + font.pixelSize: 20 + } + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + model: DelegateModel { + model: AppController.librariesModel + delegate: ItemDelegate { + text: model.display + width: ListView.view.width + onClicked: function() { + AppController.selectedLibraryId = model.libraryId + } + } + onCountChanged: function() { + if (count == 1) { + AppController.selectedLibraryId = model.data(modelIndex(0), LibraryItem.IdRole) + } + } + } + } + + Component.onCompleted: function() { + AppController.updateLibraries() + } + } +} diff --git a/Main.qml b/Main.qml new file mode 100644 index 0000000..04a69ff --- /dev/null +++ b/Main.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + width: 480 + height: 640 + visible: true + title: qsTr("YACReader Client") + Loader { + anchors.fill: parent + source: { + if (AppController.connectionState !== AppController.Connected) return "ConnectServerPage.qml" + if (AppController.selectedLibraryId === -1) return "LibrarySelectionPage.qml" + if (AppController.selectedComicId === "") return "ComicSelectionPage.qml" + return "ComicOverviewPage.qml" + } + } + // before figure out how to handle back button on Android... + onClosing: function(close) { + if (Qt.platform.os === "android") { + close.accepted = false; + } + } +} diff --git a/appcontroller.cpp b/appcontroller.cpp new file mode 100644 index 0000000..669c32b --- /dev/null +++ b/appcontroller.cpp @@ -0,0 +1,148 @@ +#include "appcontroller.h" + +#include "dataitems/libraryitem.h" +#include "dataitems/comicitem.h" + +#include +#include +#include +#include +#include +#include +#include + +AppController::AppController(QObject *parent) + : QObject(parent) + , m_networkAccessManager(new QNetworkAccessManager(this)) + , m_restAccessManager(new QRestAccessManager(m_networkAccessManager, this)) + , m_librariesModel(new QStandardItemModel(this)) + , m_comicsModel(new QStandardItemModel(this)) +{ + m_librariesModel->setItemRoleNames({ + {Qt::DisplayRole, "display"}, + {LibraryItem::IdRole, "libraryId"}, + {LibraryItem::UuidRole, "uuid"}, + }); + m_comicsModel->setItemRoleNames({ + {Qt::DisplayRole, "display"}, + {ComicItem::IdRole, "comicId"}, + {ComicItem::HashRole, "hash"}, + {ComicItem::PageCountRole, "pageCount"}, + {ComicItem::CurrentPageRole, "currentPage"}, + {ComicItem::TypeRole, "type"}, + }); +} + +void AppController::connectServer(QUrl serverBaseUrl) +{ + setProperty("connectionState", Connecting); + serverBaseUrl.setPath("/v2/"); + QNetworkRequestFactory api(serverBaseUrl); + m_restAccessManager->get(api.createRequest("version"), this, [=](QRestReply &reply){ + if (reply.isSuccess()) { + qDebug() << reply.readText(); + m_requestFactory.setBaseUrl(serverBaseUrl); + m_requestFactory.clearCommonHeaders(); + QHttpHeaders commonHeaders; + commonHeaders.append("X-Request-Id", "114514"); + m_requestFactory.setCommonHeaders(commonHeaders); + setProperty("connectionState", Connected); + } else { + setProperty("connectionState", NotConnected); + } + }); +} + +void AppController::updateLibraries() +{ + m_restAccessManager->get(apiFactory().createRequest("libraries"), this, [=](QRestReply &reply){ + if (reply.isSuccess()) { + std::optional libraries = reply.readJson(); + if (libraries && !(*libraries).isEmpty() && (*libraries).isArray()) { + const QJsonArray array = (*libraries).array(); + m_librariesModel->clear(); + for (const QJsonValue & value : array) { + QJsonObject libraryObj = value.toObject(); + m_librariesModel->appendRow(new LibraryItem(libraryObj["id"].toInt(), + libraryObj["name"].toString(), + libraryObj["uuid"].toString())); + } + } + } + }); +} + +void AppController::updateComicsInFolder(int folderId) +{ + m_restAccessManager->get(apiFactory().createRequest(QString("library/%1/folder/%2/content") + .arg(m_currentLibraryId).arg(folderId)), + this, [=](QRestReply &reply){ + qDebug() << m_currentLibraryId << folderId << reply.httpStatus() << reply.errorString(); + if (reply.isSuccess()) { + std::optional libraries = reply.readJson(); + if (libraries && !(*libraries).isEmpty() && (*libraries).isArray()) { + const QJsonArray array = (*libraries).array(); + m_comicsModel->clear(); + for (const QJsonValue & value : array) { + QJsonObject comicObj = value.toObject(); + qDebug() << comicObj; + m_comicsModel->appendRow(new ComicItem(comicObj, comicObj["file_name"].toString())); + } + } + } + }); +} + +void AppController::openComic() +{ + m_restAccessManager->get(apiFactory().createRequest(QString("library/%1/comic/%2/remote") + .arg(m_currentLibraryId).arg(m_currentComicId)), + this, [=](QRestReply &reply){ + qDebug() << m_currentLibraryId << m_currentComicId << reply.httpStatus() << reply.errorString(); + if (reply.isSuccess()) { + setProperty("selectedComicOpened", true); + } else { + setProperty("selectedComicOpened", false); + } + }); +} + +void AppController::closeComic() +{ + // the api seems not proceed by the server, so we simply do nothing but set the property + setProperty("selectedComicOpened", false); +} + +QString AppController::coverImageSource(QString comicHash) +{ + QUrl url(apiFactory().baseUrl()); + url.setPath(url.path() + "library/" + QString::number(m_currentLibraryId) + "/cover/" + comicHash + ".jpg"); + return url.toString(); +} + +QModelIndex AppController::currentComicModelIndex() +{ + if (m_currentComicId.isEmpty()) return QModelIndex(); + for (int i = 0; i < m_comicsModel->rowCount(); i++) { + QModelIndex index = m_comicsModel->index(i, 0); + if (m_comicsModel->data(index, ComicItem::IdRole) == m_currentComicId) { + return index; + } + } + return QModelIndex(); +} + +QString AppController::comicImageSource(int page) +{ + QNetworkRequest req = apiFactory().createRequest(QString("library/%1/comic/%2/page/%3/remote") + .arg(m_currentLibraryId) + .arg(m_currentComicId) + .arg(page)); + + return req.url().toString(); +} + +QNetworkRequestFactory AppController::apiFactory() const +{ + return m_requestFactory; +} diff --git a/appcontroller.h b/appcontroller.h new file mode 100644 index 0000000..6362e4e --- /dev/null +++ b/appcontroller.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +class AppController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON +public: + enum ConnectionState { + NotConnected, + Connecting, + Connected + }; + Q_ENUM(ConnectionState) + + Q_PROPERTY(ConnectionState connectionState MEMBER m_connectionState NOTIFY connectionStateChanged FINAL) + Q_PROPERTY(int selectedLibraryId MEMBER m_currentLibraryId NOTIFY currentLibraryIdChanged FINAL) + Q_PROPERTY(QString selectedComicId MEMBER m_currentComicId NOTIFY currentComicIdChanged FINAL) + Q_PROPERTY(bool selectedComicOpened MEMBER m_currentComicOpened NOTIFY currentComicOpenedChanged FINAL) + Q_PROPERTY(QStandardItemModel * librariesModel MEMBER m_librariesModel CONSTANT FINAL) + Q_PROPERTY(QStandardItemModel * comicsModel MEMBER m_comicsModel CONSTANT FINAL) + + AppController(QObject *parent = nullptr); + + Q_INVOKABLE void connectServer(QUrl serverBaseUrl); + Q_INVOKABLE void updateLibraries(); + Q_INVOKABLE void updateComicsInFolder(int folderId = 1); + Q_INVOKABLE void openComic(); + Q_INVOKABLE void closeComic(); + Q_INVOKABLE QString coverImageSource(QString comicHash); + Q_INVOKABLE QModelIndex currentComicModelIndex(); + Q_INVOKABLE QString comicImageSource(int page); + +signals: + void connectionStateChanged(ConnectionState newState); + void currentLibraryIdChanged(int newLibraryId); + void currentComicIdChanged(QString newComicId); + void currentComicOpenedChanged(bool opened); + +private: + QNetworkRequestFactory apiFactory() const; + + ConnectionState m_connectionState = NotConnected; + int m_currentLibraryId = -1; + QString m_currentComicId; + bool m_currentComicOpened = false; + QNetworkRequestFactory m_requestFactory; + QNetworkAccessManager * m_networkAccessManager; + QRestAccessManager * m_restAccessManager; + QStandardItemModel * m_librariesModel; + QStandardItemModel * m_comicsModel; +}; diff --git a/dataitems/comicitem.cpp b/dataitems/comicitem.cpp new file mode 100644 index 0000000..cd0bad3 --- /dev/null +++ b/dataitems/comicitem.cpp @@ -0,0 +1,11 @@ +#include "comicitem.h" + +ComicItem::ComicItem(QJsonObject jsonObj, const QString &name) + : QStandardItem(name) +{ + setData(jsonObj["id"].toString(), IdRole); + setData(jsonObj["hash"].toString(), HashRole); + setData(jsonObj["num_pages"].toInt(), PageCountRole); + setData(jsonObj["current_page"].toInt(), CurrentPageRole); + setData(jsonObj["type"].toInt(), TypeRole); +} diff --git a/dataitems/comicitem.h b/dataitems/comicitem.h new file mode 100644 index 0000000..65907d9 --- /dev/null +++ b/dataitems/comicitem.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +class ComicItem : public QStandardItem +{ + Q_GADGET +public: + enum Roles { + IdRole = Qt::UserRole + 1, + HashRole, + PageCountRole, + CurrentPageRole, + TypeRole, + }; + Q_ENUM(Roles) + + explicit ComicItem(QJsonObject jsonObj, const QString &name); +}; diff --git a/dataitems/libraryitem.cpp b/dataitems/libraryitem.cpp new file mode 100644 index 0000000..06d2b85 --- /dev/null +++ b/dataitems/libraryitem.cpp @@ -0,0 +1,8 @@ +#include "libraryitem.h" + +LibraryItem::LibraryItem(int id, const QString &name, const QString &uuid) + : QStandardItem(QIcon::fromTheme(QIcon::ThemeIcon::FolderOpen), name) +{ + setData(id, IdRole); + setData(uuid, UuidRole); +} diff --git a/dataitems/libraryitem.h b/dataitems/libraryitem.h new file mode 100644 index 0000000..59e922f --- /dev/null +++ b/dataitems/libraryitem.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class LibraryItem : public QStandardItem +{ + Q_GADGET +public: + enum Roles { + IdRole = Qt::UserRole + 1, + UuidRole + }; + Q_ENUM(Roles) + + explicit LibraryItem(int id, const QString &name, const QString &uuid); +}; diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..16a51d4 --- /dev/null +++ b/main.cpp @@ -0,0 +1,55 @@ +#include +#include +#include +#include +#include + +#include "dataitems/libraryitem.h" +#include "dataitems/comicitem.h" + +class QmlNetworkAccessManager : public QNetworkAccessManager +{ +public: + QmlNetworkAccessManager(QObject *parent = nullptr) : QNetworkAccessManager(parent) {} + +protected: + QNetworkReply *createRequest(Operation op, const QNetworkRequest &request, QIODevice *outgoingData = nullptr) override + { + QNetworkRequest newRequest(request); + newRequest.setRawHeader("X-Request-Id", "114514"); + return QNetworkAccessManager::createRequest(op, newRequest, outgoingData); + } +}; + +class QmlNetworkAccessManagerFactory : public QQmlNetworkAccessManagerFactory +{ +public: + inline QNetworkAccessManager *create(QObject *parent) override + { + return new QmlNetworkAccessManager(parent); + } +}; + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + qputenv("QT_QUICK_CONTROLS_STYLE", QByteArray("FluentWinUI3")); + + qmlRegisterUncreatableMetaObject(LibraryItem::staticMetaObject, "net.blumia.pineapple.comic.reader.libraryitem", 1, 0, "LibraryItem", "enum"); + qmlRegisterUncreatableMetaObject(ComicItem::staticMetaObject, "net.blumia.pineapple.comic.reader.comicitem", 1, 0, "ComicItem", "enum"); + + QmlNetworkAccessManagerFactory namFactory; + + QQmlApplicationEngine engine; + engine.setNetworkAccessManagerFactory(&namFactory); + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreationFailed, + &app, + []() { QCoreApplication::exit(-1); }, + Qt::QueuedConnection); + engine.loadFromModule("net.blumia.pineapple.comic.reader", "Main"); + + return app.exec(); +}