commit 15cef03f08eedee701f20392cfed0e907480651e Author: Gary Wang Date: Sat Nov 9 15:05:14 2024 +0800 initial commit 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(); +}