initial commit

This commit is contained in:
Gary Wang 2024-11-09 15:05:14 +08:00
commit 15cef03f08
No known key found for this signature in database
GPG Key ID: 5D30A4F15EA78760
15 changed files with 693 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# User files
*.user
*.user.*
# Generic build dir
[Bb]uild/
# IDE/Editor config folder
.vscode/

62
CMakeLists.txt Normal file
View File

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

67
ComicOverviewPage.qml Normal file
View File

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

42
ComicSelectionPage.qml Normal file
View File

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

89
ComicViewer.qml Normal file
View File

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

43
ConnectServerPage.qml Normal file
View File

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

40
LibrarySelectionPage.qml Normal file
View File

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

25
Main.qml Normal file
View File

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

148
appcontroller.cpp Normal file
View File

@ -0,0 +1,148 @@
#include "appcontroller.h"
#include "dataitems/libraryitem.h"
#include "dataitems/comicitem.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QRestAccessManager>
#include <QRestReply>
#include <QSettings>
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<QJsonDocument> 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<QJsonDocument> 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;
}

57
appcontroller.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <QQmlEngine>
#include <QNetworkRequestFactory>
#include <QRestAccessManager>
#include <QStandardItemModel>
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;
};

11
dataitems/comicitem.cpp Normal file
View File

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

21
dataitems/comicitem.h Normal file
View File

@ -0,0 +1,21 @@
#pragma once
#include <QJsonObject>
#include <QStandardItem>
#include <QQmlEngine>
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);
};

View File

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

16
dataitems/libraryitem.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <QStandardItem>
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);
};

55
main.cpp Normal file
View File

@ -0,0 +1,55 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlNetworkAccessManagerFactory>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#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();
}