mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
578c06fdc1 | ||
8645dda967 | |||
ded9055363 | |||
ff42c726fa | |||
53b033258a | |||
6ec4817dc1 | |||
283afbc6d4 | |||
c3a09c8870 | |||
f2388c81e0 | |||
c334e4fa05 | |||
![]() |
cc8277d462 | ||
e6b7f5b014 | |||
1edf284709 | |||
6033a3cde9 | |||
27e7356721 | |||
d88ae57320 | |||
7b7710b441 | |||
63346396e0 | |||
51b7df02e7 | |||
![]() |
811fbb04dc | ||
![]() |
eaf94363ae | ||
5e3ff48d35 | |||
c6ec38632f | |||
1c1f418019 |
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -86,6 +86,10 @@ jobs:
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
- name: Setup Rust
|
||||
run: |
|
||||
rustup update
|
||||
rustup default stable
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
@@ -31,7 +31,7 @@ A comic reader that support reading local and network comics.
|
||||
|
||||
## Create a new comic source
|
||||
|
||||
See [venera-configs](https://github.com/venera-app/venera-configs)
|
||||
See [Comic Source](doc/comic_source.md)
|
||||
|
||||
## Thanks
|
||||
|
||||
|
101
assets/init.js
101
assets/init.js
@@ -1205,6 +1205,10 @@ class Image {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI related apis
|
||||
* @since 1.2.0
|
||||
*/
|
||||
let UI = {
|
||||
/**
|
||||
* Show a message
|
||||
@@ -1222,7 +1226,9 @@ let UI = {
|
||||
* Show a dialog. Any action will close the dialog.
|
||||
* @param title {string}
|
||||
* @param content {string}
|
||||
* @param actions {{text:string, callback: () => void}[]}
|
||||
* @param actions {{text:string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved.
|
||||
* @returns {Promise<void>} - Resolved when the dialog is closed.
|
||||
* @since 1.2.1
|
||||
*/
|
||||
showDialog: (title, content, actions) => {
|
||||
sendMessage({
|
||||
@@ -1245,4 +1251,97 @@ let UI = {
|
||||
url: url,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a loading dialog.
|
||||
* @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user.
|
||||
* @returns {number} - A number that can be used to cancel the loading dialog.
|
||||
* @since 1.2.1
|
||||
*/
|
||||
showLoading: (onCancel) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showLoading',
|
||||
onCancel: onCancel
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel a loading dialog.
|
||||
* @param id {number} - returned by [showLoading]
|
||||
* @since 1.2.1
|
||||
*/
|
||||
cancelLoading: (id) => {
|
||||
sendMessage({
|
||||
method: 'UI',
|
||||
function: 'cancelLoading',
|
||||
id: id
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an input dialog
|
||||
* @param title {string}
|
||||
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showInputDialog',
|
||||
title: title,
|
||||
validator: validator
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a select dialog
|
||||
* @param title {string}
|
||||
* @param options {string[]}
|
||||
* @param initialIndex {number?}
|
||||
* @returns {Promise<number | null>} - The selected index. If the dialog is canceled, return null.
|
||||
*/
|
||||
showSelectDialog: (title, options, initialIndex) => {
|
||||
return sendMessage({
|
||||
method: 'UI',
|
||||
function: 'showSelectDialog',
|
||||
title: title,
|
||||
options: options,
|
||||
initialIndex: initialIndex
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App related apis
|
||||
* @since 1.2.1
|
||||
*/
|
||||
let APP = {
|
||||
/**
|
||||
* Get the app version
|
||||
* @returns {string} - The app version
|
||||
*/
|
||||
get version() {
|
||||
return appVersion // defined in the engine
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current app locale
|
||||
* @returns {string} - The app locale, in the format of [languageCode]_[countryCode]
|
||||
*/
|
||||
get locale() {
|
||||
return sendMessage({
|
||||
method: 'getLocale'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current running platform
|
||||
* @returns {string} - The platform name, "android", "ios", "windows", "macos", "linux"
|
||||
*/
|
||||
get platform() {
|
||||
return sendMessage({
|
||||
method: 'getPlatform'
|
||||
})
|
||||
}
|
||||
}
|
@@ -148,11 +148,6 @@
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
|
||||
"Help": "帮助",
|
||||
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
|
||||
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
|
||||
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
|
||||
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
|
||||
"Export as cbz": "导出为cbz",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file" : "一个归档文件",
|
||||
@@ -318,7 +313,8 @@
|
||||
"Imported @a comics": "已导入 @a 本漫画",
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 项更新",
|
||||
"No updates": "无更新"
|
||||
"No updates": "无更新",
|
||||
"Set comic source list url": "设置漫画源列表URL"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -468,11 +464,6 @@
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
|
||||
"Help": "幫助",
|
||||
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
|
||||
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
|
||||
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
|
||||
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
|
||||
"Export as cbz": "匯出為cbz",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file" : "一個歸檔文件",
|
||||
@@ -639,6 +630,7 @@
|
||||
"Imported @a comics": "已匯入 @a 部漫畫",
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 項更新",
|
||||
"No updates": "無更新"
|
||||
"No updates": "無更新",
|
||||
"Set comic source list url": "設置漫畫源列表URL"
|
||||
}
|
||||
}
|
655
doc/comic_source.md
Normal file
655
doc/comic_source.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Comic Source
|
||||
|
||||
## Introduction
|
||||
|
||||
Venera is a comic reader that can read comics from various sources.
|
||||
|
||||
All comic sources are written in javascript.
|
||||
Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine which is forked from [ekibun](https://github.com/ekibun/flutter_qjs).
|
||||
|
||||
This document will describe how to write a comic source for Venera.
|
||||
|
||||
## Preparation
|
||||
|
||||
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
|
||||
- An editor that supports javascript.
|
||||
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
|
||||
|
||||
## Start Writing
|
||||
|
||||
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
|
||||
|
||||
Here is a brief introduction to the template:
|
||||
|
||||
> Note: Javascript api document is [here](js_api.md).
|
||||
|
||||
### Write basic information
|
||||
|
||||
```javascript
|
||||
class NewComicSource extends ComicSource {
|
||||
// Note: The fields which are marked as [Optional] should be removed if not used
|
||||
|
||||
// name of the source
|
||||
name = ""
|
||||
|
||||
// unique id of the source
|
||||
key = ""
|
||||
|
||||
version = "1.0.0"
|
||||
|
||||
minAppVersion = "1.0.0"
|
||||
|
||||
// update url
|
||||
url = ""
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
In this part, you need to do the following:
|
||||
- Change the class name to your source name.
|
||||
- Fill in the name, key, version, minAppVersion, and url fields.
|
||||
|
||||
### init function
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* [Optional] init function
|
||||
*/
|
||||
init() {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The function will be called when the source is initialized. You can do some initialization work here.
|
||||
|
||||
Remove this function if not used.
|
||||
|
||||
### Account
|
||||
|
||||
```javascript
|
||||
// [Optional] account related
|
||||
account = {
|
||||
/**
|
||||
* [Optional] login with account and password, return any value to indicate success
|
||||
* @param account {string}
|
||||
* @param pwd {string}
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
login: async (account, pwd) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* [Optional] login with webview
|
||||
*/
|
||||
loginWithWebview: {
|
||||
url: "",
|
||||
/**
|
||||
* check login status
|
||||
* @param url {string} - current url
|
||||
* @param title {string} - current title
|
||||
* @returns {boolean} - return true if login success
|
||||
*/
|
||||
checkStatus: (url, title) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] Callback when login success
|
||||
*/
|
||||
onLoginSuccess: () => {
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* [Optional] login with cookies
|
||||
* Note: If `this.account.login` is implemented, this will be ignored
|
||||
*/
|
||||
loginWithCookies: {
|
||||
fields: [
|
||||
"ipb_member_id",
|
||||
"ipb_pass_hash",
|
||||
"igneous",
|
||||
"star",
|
||||
],
|
||||
/**
|
||||
* Validate cookies, return false if cookies are invalid.
|
||||
*
|
||||
* Use `Network.setCookies` to set cookies before validate.
|
||||
* @param values {string[]} - same order as `fields`
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
validate: async (values) => {
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* logout function, clear account related data
|
||||
*/
|
||||
logout: () => {
|
||||
|
||||
},
|
||||
|
||||
// {string?} - register url
|
||||
registerWebsite: null
|
||||
}
|
||||
```
|
||||
|
||||
In this part, you can implement login, logout, and register functions.
|
||||
|
||||
Remove this part if not used.
|
||||
|
||||
### Explore page
|
||||
|
||||
```javascript
|
||||
// explore page list
|
||||
explore = [
|
||||
{
|
||||
// title of the page.
|
||||
// title is used to identify the page, it should be unique
|
||||
title: "",
|
||||
|
||||
/// multiPartPage or multiPageComicList or mixed
|
||||
type: "multiPartPage",
|
||||
|
||||
/**
|
||||
* load function
|
||||
* @param page {number | null} - page number, null for `singlePageWithMultiPart` type
|
||||
* @returns {{}}
|
||||
* - for `multiPartPage` type, return {title: string, comics: Comic[], viewMore: string?}[]
|
||||
* - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number}
|
||||
* - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?}
|
||||
*/
|
||||
load: async (page) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Only use for `multiPageComicList` type.
|
||||
* `loadNext` would be ignored if `load` function is implemented.
|
||||
* @param next {string | null} - next page token, null if first page
|
||||
* @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page.
|
||||
*/
|
||||
loadNext(next) {},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In this part, you can implement the explore page.
|
||||
|
||||
A comic source can have multiple explore pages.
|
||||
|
||||
There are three types of explore pages:
|
||||
- multiPartPage: An explore page contains multiple parts, each part contains multiple comics.
|
||||
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
|
||||
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
|
||||
|
||||
### Category Page
|
||||
|
||||
```javascript
|
||||
// categories
|
||||
category = {
|
||||
/// title of the category page, used to identify the page, it should be unique
|
||||
title: "",
|
||||
parts: [
|
||||
{
|
||||
// title of the part
|
||||
name: "Theme",
|
||||
|
||||
// fixed or random
|
||||
// if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time
|
||||
type: "fixed",
|
||||
|
||||
// number of comics to display at the same time
|
||||
// randomNumber: 5,
|
||||
|
||||
categories: ["All", "Adventure", "School"],
|
||||
|
||||
// category or search
|
||||
// if `category`, use categoryComics.load to load comics
|
||||
// if `search`, use search.load to load comics
|
||||
itemType: "category",
|
||||
|
||||
// [Optional] {string[]?} must have same length as categories, used to provide loading param for each category
|
||||
categoryParams: ["all", "adventure", "school"],
|
||||
|
||||
// [Optional] {string} cannot be used with `categoryParams`, set all category params to this value
|
||||
groupParam: null,
|
||||
}
|
||||
],
|
||||
// enable ranking page
|
||||
enableRankingPage: false,
|
||||
}
|
||||
```
|
||||
|
||||
Category page is a static page that contains multiple parts, each part contains multiple categories.
|
||||
|
||||
A comic source can only have one category page.
|
||||
|
||||
### Category Comics Page
|
||||
|
||||
```javascript
|
||||
/// category comic loading related
|
||||
categoryComics = {
|
||||
/**
|
||||
* load comics of a category
|
||||
* @param category {string} - category name
|
||||
* @param param {string?} - category param
|
||||
* @param options {string[]} - options from optionList
|
||||
* @param page {number} - page number
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (category, param, options, page) => {
|
||||
|
||||
},
|
||||
// provide options for category comic loading
|
||||
optionList: [
|
||||
{
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"newToOld-New to Old",
|
||||
"oldToNew-Old to New"
|
||||
],
|
||||
// [Optional] {string[]} - show this option only when the value not in the list
|
||||
notShowWhen: null,
|
||||
// [Optional] {string[]} - show this option only when the value in the list
|
||||
showWhen: null
|
||||
}
|
||||
],
|
||||
ranking: {
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"day-Day",
|
||||
"week-Week"
|
||||
],
|
||||
/**
|
||||
* load ranking comics
|
||||
* @param option {string} - option from optionList
|
||||
* @param page {number} - page number
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (option, page) => {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When user clicks on a category, the category comics page will be displayed.
|
||||
|
||||
This part is used to load comics of a category.
|
||||
|
||||
### Search
|
||||
|
||||
```javascript
|
||||
/// search related
|
||||
search = {
|
||||
/**
|
||||
* load search result
|
||||
* @param keyword {string}
|
||||
* @param options {(string | null)[]} - options from optionList
|
||||
* @param page {number}
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
load: async (keyword, options, page) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* load search result with next page token.
|
||||
* The field will be ignored if `load` function is implemented.
|
||||
* @param keyword {string}
|
||||
* @param options {(string)[]} - options from optionList
|
||||
* @param next {string | null}
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
loadNext: async (keyword, options, next) => {
|
||||
|
||||
},
|
||||
|
||||
// provide options for search
|
||||
optionList: [
|
||||
{
|
||||
// [Optional] default is `select`
|
||||
// type: select, multi-select, dropdown
|
||||
// For select, there is only one selected value
|
||||
// For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values
|
||||
// For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null
|
||||
type: "select",
|
||||
// For a single option, use `-` to separate the value and text, left for value, right for text
|
||||
options: [
|
||||
"0-time",
|
||||
"1-popular"
|
||||
],
|
||||
// option label
|
||||
label: "sort",
|
||||
// default selected options
|
||||
default: null,
|
||||
}
|
||||
],
|
||||
|
||||
// enable tags suggestions
|
||||
enableTagsSuggestions: false,
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to load search results.
|
||||
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
### Favorites
|
||||
|
||||
```javascript
|
||||
// favorite related
|
||||
favorites = {
|
||||
// whether support multi folders
|
||||
multiFolder: false,
|
||||
/**
|
||||
* add or delete favorite.
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite
|
||||
* @param comicId {string}
|
||||
* @param folderId {string}
|
||||
* @param isAdding {boolean} - true for add, false for delete
|
||||
* @param favoriteId {string?} - [Comic.favoriteId]
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load favorite folders.
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
|
||||
* if comicId is not null, return favorite folders which contains the comic.
|
||||
* @param comicId {string?}
|
||||
* @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic
|
||||
*/
|
||||
loadFolders: async (comicId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* add a folder
|
||||
* @param name {string}
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
addFolder: async (name) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* delete a folder
|
||||
* @param folderId {string}
|
||||
* @returns {Promise<void>} - return any value to indicate success
|
||||
*/
|
||||
deleteFolder: async (folderId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load comics in a folder
|
||||
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
|
||||
* @param page {number}
|
||||
* @param folder {string?} - folder id, null for non-multi-folder
|
||||
* @returns {Promise<{comics: Comic[], maxPage: number}>}
|
||||
*/
|
||||
loadComics: async (page, folder) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* load comics with next page token
|
||||
* @param next {string | null} - next page token, null for first page
|
||||
* @param folder {string}
|
||||
* @returns {Promise<{comics: Comic[], next: string?}>}
|
||||
*/
|
||||
loadNext: async (next, folder) => {
|
||||
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to manage network favorites of the source.
|
||||
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
### Comic Details
|
||||
|
||||
```javascript
|
||||
/// single comic related
|
||||
comic = {
|
||||
/**
|
||||
* load comic info
|
||||
* @param id {string}
|
||||
* @returns {Promise<ComicDetails>}
|
||||
*/
|
||||
loadInfo: async (id) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load thumbnails of a comic
|
||||
*
|
||||
* To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}`
|
||||
* - If width is not provided, use full width
|
||||
* - If height is not provided, use full height
|
||||
* @param id {string}
|
||||
* @param next {string?} - next page token, null for first page
|
||||
* @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more
|
||||
*/
|
||||
loadThumbnails: async (id, next) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* rate a comic
|
||||
* @param id
|
||||
* @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars,
|
||||
* @returns {Promise<any>} - return any value to indicate success
|
||||
*/
|
||||
starRating: async (id, rating) => {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* load images of a chapter
|
||||
* @param comicId {string}
|
||||
* @param epId {string?}
|
||||
* @returns {Promise<{images: string[]}>}
|
||||
*/
|
||||
loadEp: async (comicId, epId) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] provide configs for an image loading
|
||||
* @param url
|
||||
* @param comicId
|
||||
* @param epId
|
||||
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
|
||||
*/
|
||||
onImageLoad: (url, comicId, epId) => {
|
||||
return {}
|
||||
},
|
||||
/**
|
||||
* [Optional] provide configs for a thumbnail loading
|
||||
* @param url {string}
|
||||
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
|
||||
*
|
||||
* `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored.
|
||||
* They are not supported for thumbnails.
|
||||
*/
|
||||
onThumbnailLoad: (url) => {
|
||||
return {}
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comic
|
||||
* @param id {string}
|
||||
* @param isLike {boolean} - true for like, false for unlike
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
likeComic: async (id, isLike) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load comments
|
||||
*
|
||||
* Since app version 1.0.6, rich text is supported in comments.
|
||||
* Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img'].
|
||||
* span tag supports style attribute, but only support font-weight, font-style, text-decoration.
|
||||
* All images will be placed at the end of the comment.
|
||||
* Auto link detection is enabled, but only http/https links are supported.
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param page {number}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
|
||||
*/
|
||||
loadComments: async (comicId, subId, page, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] send a comment, return any value to indicate success
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param content {string}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sendComment: async (comicId, subId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comment
|
||||
* @param comicId {string}
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param commentId {string}
|
||||
* @param isLike {boolean} - true for like, false for unlike
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
likeComment: async (comicId, subId, commentId, isLike) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] vote a comment
|
||||
* @param id {string} - comicId
|
||||
* @param subId {string?} - ComicDetails.subId
|
||||
* @param commentId {string} - commentId
|
||||
* @param isUp {boolean} - true for up, false for down
|
||||
* @param isCancel {boolean} - true for cancel, false for vote
|
||||
* @returns {Promise<number>} - new score
|
||||
*/
|
||||
voteComment: async (id, subId, commentId, isUp, isCancel) => {
|
||||
|
||||
},
|
||||
// {string?} - regex string, used to identify comic id from user input
|
||||
idMatch: null,
|
||||
/**
|
||||
* [Optional] Handle tag click event
|
||||
* @param namespace {string}
|
||||
* @param tag {string}
|
||||
* @returns {{action: string, keyword: string, param: string?}}
|
||||
*/
|
||||
onClickTag: (namespace, tag) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] Handle links
|
||||
*/
|
||||
link: {
|
||||
/**
|
||||
* set accepted domains
|
||||
*/
|
||||
domains: [
|
||||
'example.com'
|
||||
],
|
||||
/**
|
||||
* parse url to comic id
|
||||
* @param url {string}
|
||||
* @returns {string | null}
|
||||
*/
|
||||
linkToId: (url) => {
|
||||
|
||||
}
|
||||
},
|
||||
// enable tags translate
|
||||
enableTagsTranslate: false,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
This part is used to load comic details.
|
||||
|
||||
### Settings
|
||||
|
||||
```javascript
|
||||
/*
|
||||
[Optional] settings related
|
||||
Use this.loadSetting to load setting
|
||||
```
|
||||
let setting1Value = this.loadSetting('setting1')
|
||||
console.log(setting1Value)
|
||||
```
|
||||
*/
|
||||
settings = {
|
||||
setting1: {
|
||||
// title
|
||||
title: "Setting1",
|
||||
// type: input, select, switch
|
||||
type: "select",
|
||||
// options
|
||||
options: [
|
||||
{
|
||||
// value
|
||||
value: 'o1',
|
||||
// [Optional] text, if not set, use value as text
|
||||
text: 'Option 1',
|
||||
},
|
||||
],
|
||||
default: 'o1',
|
||||
},
|
||||
setting2: {
|
||||
title: "Setting2",
|
||||
type: "switch",
|
||||
default: true,
|
||||
},
|
||||
setting3: {
|
||||
title: "Setting3",
|
||||
type: "input",
|
||||
validator: null, // string | null, regex string
|
||||
default: '',
|
||||
},
|
||||
setting4: {
|
||||
title: "Setting4",
|
||||
type: "callback",
|
||||
buttonText: "Click me",
|
||||
/**
|
||||
* callback function
|
||||
*
|
||||
* If the callback function returns a Promise, the button will show a loading indicator until the promise is resolved.
|
||||
* @returns {void | Promise<any>}
|
||||
*/
|
||||
callback: () => {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to provide settings for the source.
|
||||
|
||||
|
||||
### Translations
|
||||
|
||||
```javascript
|
||||
// [Optional] translations for the strings in this config
|
||||
translation = {
|
||||
'zh_CN': {
|
||||
'Setting1': '设置1',
|
||||
'Setting2': '设置2',
|
||||
'Setting3': '设置3',
|
||||
},
|
||||
'zh_TW': {},
|
||||
'en': {}
|
||||
}
|
||||
```
|
||||
|
||||
This part is used to provide translations for the source.
|
||||
|
||||
> Note: strings in the UI api will not be translated automatically. You need to translate them manually.
|
61
doc/import_comic.md
Normal file
61
doc/import_comic.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Import Comic
|
||||
|
||||
## Introduction
|
||||
|
||||
Venera supports importing comics from local files.
|
||||
However, the comic files must be in a specific format.
|
||||
|
||||
## Comic Directory
|
||||
|
||||
A directory considered as a comic directory only if it follows one of the following two types of structure:
|
||||
|
||||
**Without Chapter**
|
||||
|
||||
```
|
||||
comic_directory
|
||||
├── cover.[ext]
|
||||
├── img1.[ext]
|
||||
├── img2.[ext]
|
||||
├── img3.[ext]
|
||||
├── ...
|
||||
```
|
||||
|
||||
**With Chapter**
|
||||
|
||||
```
|
||||
comic_directory
|
||||
├── cover.[ext]
|
||||
├── chapter1
|
||||
│ ├── img1.[ext]
|
||||
│ ├── img2.[ext]
|
||||
│ ├── img3.[ext]
|
||||
│ ├── ...
|
||||
├── chapter2
|
||||
│ ├── img1.[ext]
|
||||
│ ├── img2.[ext]
|
||||
│ ├── img3.[ext]
|
||||
│ ├── ...
|
||||
├── ...
|
||||
```
|
||||
|
||||
The file name can be anything, but the extension must be a valid image extension.
|
||||
|
||||
The page order is determined by the file name. App will sort the files by name and display them in that order.
|
||||
|
||||
Cover image is optional.
|
||||
If there is a file named `cover.[ext]` in the directory, it will be considered as the cover image.
|
||||
Otherwise, the first image will be considered as the cover image.
|
||||
|
||||
The name of directory will be used as comic title. And the name of chapter directory will be used as chapter title.
|
||||
|
||||
## Archive
|
||||
|
||||
Venera supports importing comics from archive files.
|
||||
|
||||
The archive file must follow [Comic Book Archive](https://en.wikipedia.org/wiki/Comic_book_archive_file) format.
|
||||
|
||||
Currently, Venera supports the following archive formats:
|
||||
- `.cbz`
|
||||
- `.cb7`
|
||||
- `.zip`
|
||||
- `.7z`
|
513
doc/js_api.md
Normal file
513
doc/js_api.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Javascript API
|
||||
|
||||
## Overview
|
||||
|
||||
The Javascript API is a set of functions that used to interact application.
|
||||
|
||||
There are following parts in the API:
|
||||
- [Convert](#Convert)
|
||||
- [Network](#Network)
|
||||
- [Html](#Html)
|
||||
- [UI](#UI)
|
||||
- [Utils](#Utils)
|
||||
- [Types](#Types)
|
||||
|
||||
|
||||
## Convert
|
||||
|
||||
Convert is a set of functions that used to convert data between different types.
|
||||
|
||||
### `Convert.encodeUtf8(str: string): ArrayBuffer`
|
||||
|
||||
Convert a string to an ArrayBuffer.
|
||||
|
||||
### `Convert.decodeUtf8(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a string.
|
||||
|
||||
### `Convert.encodeBase64(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a base64 string.
|
||||
|
||||
### `Convert.decodeBase64(value: string): ArrayBuffer`
|
||||
|
||||
Convert a base64 string to an ArrayBuffer.
|
||||
|
||||
### `Convert.md5(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the md5 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha1(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha1 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha256(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha256 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.sha512(value: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Calculate the sha512 hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.hmac(key: ArrayBuffer, value: ArrayBuffer, hash: string): ArrayBuffer`
|
||||
|
||||
Calculate the hmac hash of an ArrayBuffer.
|
||||
|
||||
### `Convert.hmacString(key: ArrayBuffer, value: ArrayBuffer, hash: string): string`
|
||||
|
||||
Calculate the hmac hash of an ArrayBuffer and return a string.
|
||||
|
||||
### `Convert.decryptAesEcb(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES ECB mode.
|
||||
|
||||
### `Convert.decryptAesCbc(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES CBC mode.
|
||||
|
||||
### `Convert.decryptAesCfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES CFB mode.
|
||||
|
||||
### `Convert.decryptAesOfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with AES OFB mode.
|
||||
|
||||
### `Convert.decryptRsa(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
|
||||
|
||||
Decrypt an ArrayBuffer with RSA.
|
||||
|
||||
### `Convert.hexEncode(value: ArrayBuffer): string`
|
||||
|
||||
Convert an ArrayBuffer to a hex string.
|
||||
|
||||
## Network
|
||||
|
||||
Network is a set of functions that used to send network requests and manage network resources.
|
||||
|
||||
### `Network.fetchBytes(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: ArrayBuffer}>`
|
||||
|
||||
Send a network request and return the response as an ArrayBuffer.
|
||||
|
||||
### `Network.sendRequest(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a network request and return the response as a string.
|
||||
|
||||
### `Network.get(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a GET request and return the response as a string.
|
||||
|
||||
### `Network.post(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a POST request and return the response as a string.
|
||||
|
||||
### `Network.put(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a PUT request and return the response as a string.
|
||||
|
||||
### `Network.delete(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a DELETE request and return the response as a string.
|
||||
|
||||
### `Network.patch(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
|
||||
|
||||
Send a PATCH request and return the response as a string.
|
||||
|
||||
### `Network.setCookies(url: string, cookies: Cookie[]): void`
|
||||
|
||||
Set cookies for a specific url.
|
||||
|
||||
### `Network.getCookies(url: string): Cookie[]`
|
||||
|
||||
Get cookies for a specific url.
|
||||
|
||||
### `Network.deleteCookies(url: string): void`
|
||||
|
||||
Delete cookies for a specific url.
|
||||
|
||||
### `fetch`
|
||||
|
||||
The fetch function is a wrapper of the `Network.fetchBytes` function. Same as the `fetch` function in the browser.
|
||||
|
||||
## Html
|
||||
|
||||
Api for parsing HTML.
|
||||
|
||||
### `new HtmlDocument(html: string): HtmlDocument`
|
||||
|
||||
Create a HtmlDocument object from a html string.
|
||||
|
||||
### `HtmlDocument.querySelector(selector: string): HtmlElement`
|
||||
|
||||
Find the first element that matches the selector.
|
||||
|
||||
### `HtmlDocument.querySelectorAll(selector: string): HtmlElement[]`
|
||||
|
||||
Find all elements that match the selector.
|
||||
|
||||
### `HtmlDocument.getElementById(id: string): HtmlElement`
|
||||
|
||||
Find the element with the id.
|
||||
|
||||
### `HtmlDocument.dispose(): void`
|
||||
|
||||
Dispose the HtmlDocument object.
|
||||
|
||||
### `HtmlElement.querySelector(selector: string): HtmlElement`
|
||||
|
||||
Find the first element that matches the selector.
|
||||
|
||||
### `HtmlElement.querySelectorAll(selector: string): HtmlElement[]`
|
||||
|
||||
Find all elements that match the selector.
|
||||
|
||||
### `HtmlElement.getElementById(id: string): HtmlElement`
|
||||
|
||||
Find the element with the id.
|
||||
|
||||
### `get HtmlElement.text(): string`
|
||||
|
||||
Get the text content of the element.
|
||||
|
||||
### `get HtmlElement.attributes(): object`
|
||||
|
||||
Get the attributes of the element.
|
||||
|
||||
### `get HtmlElement.children(): HtmlElement[]`
|
||||
|
||||
Get the children
|
||||
|
||||
### `get HtmlElement.nodes(): HtmlNode[]`
|
||||
|
||||
Get the child nodes
|
||||
|
||||
### `get HtmlElement.parent(): HtmlElement | null`
|
||||
|
||||
Get the parent element
|
||||
|
||||
### `get HtmlElement.innerHtml(): string`
|
||||
|
||||
Get the inner html
|
||||
|
||||
### `get HtmlElement.classNames(): string[]`
|
||||
|
||||
Get the class names
|
||||
|
||||
### `get HtmlElement.id(): string | null`
|
||||
|
||||
Get the id
|
||||
|
||||
### `get HtmlElement.localName(): string`
|
||||
|
||||
Get the local name
|
||||
|
||||
### `get HtmlElement.previousSibling(): HtmlElement | null`
|
||||
|
||||
Get the previous sibling
|
||||
|
||||
### `get HtmlElement.nextSibling(): HtmlElement | null`
|
||||
|
||||
Get the next sibling
|
||||
|
||||
### `get HtmlNode.type(): string`
|
||||
|
||||
Get the node type ("text", "element", "comment", "document", "unknown")
|
||||
|
||||
### `HtmlNode.toElement(): HtmlElement | null`
|
||||
|
||||
Convert the node to an element
|
||||
|
||||
### `get HtmlNode.text(): string`
|
||||
|
||||
Get the text content of the node
|
||||
|
||||
## UI
|
||||
|
||||
### `UI.showMessage(message: string): void`
|
||||
|
||||
Show a message.
|
||||
|
||||
### `UI.showDialog(title: string, content: string, actions: {text: string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]): void`
|
||||
|
||||
Show a dialog. Any action will close the dialog.
|
||||
|
||||
### `UI.launchUrl(url: string): void`
|
||||
|
||||
Open a url in external browser.
|
||||
|
||||
### `UI.showLoading(onCancel: () => void | null | undefined): number`
|
||||
|
||||
Show a loading dialog.
|
||||
|
||||
### `UI.cancelLoading(id: number): void`
|
||||
|
||||
Cancel a loading dialog.
|
||||
|
||||
### `UI.showInputDialog(title: string, validator: (string) => string | null | undefined): string | null`
|
||||
|
||||
Show an input dialog.
|
||||
|
||||
### `UI.showSelectDialog(title: string, options: string[], initialIndex?: number): number | null`
|
||||
|
||||
Show a select dialog.
|
||||
|
||||
## Utils
|
||||
|
||||
### `createUuid(): string`
|
||||
|
||||
create a time-based uuid.
|
||||
|
||||
### `randomInt(min: number, max: number): number`
|
||||
|
||||
Generate a random integer between min and max.
|
||||
|
||||
### `randomDouble(min: number, max: number): number`
|
||||
|
||||
Generate a random double between min and max.
|
||||
|
||||
### console
|
||||
|
||||
Send log to application console. Same api as the browser console.
|
||||
|
||||
## Types
|
||||
|
||||
### `Cookie`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Create a cookie object.
|
||||
* @param name {string}
|
||||
* @param value {string}
|
||||
* @param domain {string}
|
||||
* @constructor
|
||||
*/
|
||||
function Cookie({name, value, domain}) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.domain = domain;
|
||||
}
|
||||
```
|
||||
|
||||
### `Comic`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Create a comic object
|
||||
* @param id {string}
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param tags {string[]}
|
||||
* @param description {string}
|
||||
* @param maxPage {number?}
|
||||
* @param language {string?}
|
||||
* @param favoriteId {string?} - Only set this field if the comic is from favorites page
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @constructor
|
||||
*/
|
||||
function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.subTitle = subTitle;
|
||||
this.cover = cover;
|
||||
this.tags = tags;
|
||||
this.description = description;
|
||||
this.maxPage = maxPage;
|
||||
this.language = language;
|
||||
this.favoriteId = favoriteId;
|
||||
this.stars = stars;
|
||||
}
|
||||
```
|
||||
|
||||
### `ComicDetails`
|
||||
```javascript
|
||||
/**
|
||||
* Create a comic details object
|
||||
* @param title {string}
|
||||
* @param subtitle {string}
|
||||
* @param subTitle {string} - equal to subtitle
|
||||
* @param cover {string}
|
||||
* @param description {string?}
|
||||
* @param tags {Map<string, string[]> | {} | null | undefined}
|
||||
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
|
||||
* @param subId {string?} - a param which is passed to comments api
|
||||
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
|
||||
* @param recommend {Comic[]?} - related comics
|
||||
* @param commentCount {number?}
|
||||
* @param likesCount {number?}
|
||||
* @param isLiked {boolean?}
|
||||
* @param uploader {string?}
|
||||
* @param updateTime {string?}
|
||||
* @param uploadTime {string?}
|
||||
* @param url {string?}
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @param maxPage {number?}
|
||||
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
|
||||
* @constructor
|
||||
*/
|
||||
function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle ?? subTitle;
|
||||
this.cover = cover;
|
||||
this.description = description;
|
||||
this.tags = tags;
|
||||
this.chapters = chapters;
|
||||
this.isFavorite = isFavorite;
|
||||
this.subId = subId;
|
||||
this.thumbnails = thumbnails;
|
||||
this.recommend = recommend;
|
||||
this.commentCount = commentCount;
|
||||
this.likesCount = likesCount;
|
||||
this.isLiked = isLiked;
|
||||
this.uploader = uploader;
|
||||
this.updateTime = updateTime;
|
||||
this.uploadTime = uploadTime;
|
||||
this.url = url;
|
||||
this.stars = stars;
|
||||
this.maxPage = maxPage;
|
||||
this.comments = comments;
|
||||
}
|
||||
```
|
||||
|
||||
### `Comment`
|
||||
```javascript
|
||||
/**
|
||||
* Create a comment object
|
||||
* @param userName {string}
|
||||
* @param avatar {string?}
|
||||
* @param content {string}
|
||||
* @param time {string?}
|
||||
* @param replyCount {number?}
|
||||
* @param id {string?}
|
||||
* @param isLiked {boolean?}
|
||||
* @param score {number?}
|
||||
* @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none
|
||||
* @constructor
|
||||
*/
|
||||
function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) {
|
||||
this.userName = userName;
|
||||
this.avatar = avatar;
|
||||
this.content = content;
|
||||
this.time = time;
|
||||
this.replyCount = replyCount;
|
||||
this.id = id;
|
||||
this.isLiked = isLiked;
|
||||
this.score = score;
|
||||
this.voteStatus = voteStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### `ImageLoadingConfig`
|
||||
```javascript
|
||||
/**
|
||||
* Create image loading config
|
||||
* @param url {string?}
|
||||
* @param method {string?} - http method, uppercase
|
||||
* @param data {any} - request data, may be null
|
||||
* @param headers {Object?} - request headers
|
||||
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
|
||||
* @param modifyImage {string?}
|
||||
* A js script string.
|
||||
* The script will be executed in a new Isolate.
|
||||
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
|
||||
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
|
||||
* @constructor
|
||||
* @since 1.0.5
|
||||
*
|
||||
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
|
||||
*/
|
||||
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
|
||||
this.url = url;
|
||||
this.method = method;
|
||||
this.data = data;
|
||||
this.headers = headers;
|
||||
this.onResponse = onResponse;
|
||||
this.modifyImage = modifyImage;
|
||||
this.onLoadFailed = onLoadFailed;
|
||||
}
|
||||
```
|
||||
|
||||
### `ComicSource`
|
||||
```javascript
|
||||
class ComicSource {
|
||||
name = ""
|
||||
|
||||
key = ""
|
||||
|
||||
version = ""
|
||||
|
||||
minAppVersion = ""
|
||||
|
||||
url = ""
|
||||
|
||||
/**
|
||||
* load data with its key
|
||||
* @param {string} dataKey
|
||||
* @returns {any}
|
||||
*/
|
||||
loadData(dataKey) {
|
||||
return sendMessage({
|
||||
method: 'load_data',
|
||||
key: this.key,
|
||||
data_key: dataKey
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* load a setting with its key
|
||||
* @param key {string}
|
||||
* @returns {any}
|
||||
*/
|
||||
loadSetting(key) {
|
||||
return sendMessage({
|
||||
method: 'load_setting',
|
||||
key: this.key,
|
||||
setting_key: key
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* save data
|
||||
* @param {string} dataKey
|
||||
* @param data
|
||||
*/
|
||||
saveData(dataKey, data) {
|
||||
return sendMessage({
|
||||
method: 'save_data',
|
||||
key: this.key,
|
||||
data_key: dataKey,
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* delete data
|
||||
* @param {string} dataKey
|
||||
*/
|
||||
deleteData(dataKey) {
|
||||
return sendMessage({
|
||||
method: 'delete_data',
|
||||
key: this.key,
|
||||
data_key: dataKey,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isLogged() {
|
||||
return sendMessage({
|
||||
method: 'isLogged',
|
||||
key: this.key,
|
||||
});
|
||||
}
|
||||
|
||||
init() { }
|
||||
|
||||
static sources = {}
|
||||
}
|
||||
```
|
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '15.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
242
lib/components/js_ui.dart
Normal file
242
lib/components/js_ui.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
|
||||
import 'components.dart';
|
||||
|
||||
mixin class JsUiApi {
|
||||
final Map<int, LoadingDialogController> _loadingDialogControllers = {};
|
||||
|
||||
dynamic handleUIMessage(Map<String, dynamic> message) {
|
||||
switch (message['function']) {
|
||||
case 'showMessage':
|
||||
var m = message['message'];
|
||||
if (m.toString().isNotEmpty) {
|
||||
App.rootContext.showMessage(message: m.toString());
|
||||
}
|
||||
case 'showDialog':
|
||||
return _showDialog(message);
|
||||
case 'launchUrl':
|
||||
var url = message['url'];
|
||||
if (url.toString().isNotEmpty) {
|
||||
launchUrlString(url.toString());
|
||||
}
|
||||
case 'showLoading':
|
||||
var onCancel = message['onCancel'];
|
||||
if (onCancel != null && onCancel is! JSInvokable) {
|
||||
return;
|
||||
}
|
||||
return _showLoading(onCancel);
|
||||
case 'cancelLoading':
|
||||
var id = message['id'];
|
||||
if (id is int) {
|
||||
_cancelLoading(id);
|
||||
}
|
||||
case 'showInputDialog':
|
||||
var title = message['title'];
|
||||
var validator = message['validator'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
return _showInputDialog(title, validator);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
var options = message['options'];
|
||||
var initialIndex = message['initialIndex'];
|
||||
if (title is! String) return;
|
||||
if (options is! List) return;
|
||||
if (initialIndex != null && initialIndex is! int) return;
|
||||
return _showSelectDialog(
|
||||
title,
|
||||
options.whereType<String>().toList(),
|
||||
initialIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDialog(Map<String, dynamic> message) {
|
||||
BuildContext? dialogContext;
|
||||
var title = message['title'];
|
||||
var content = message['content'];
|
||||
var actions = <Widget>[];
|
||||
for (var action in message['actions']) {
|
||||
if (action['callback'] is! JSInvokable) {
|
||||
continue;
|
||||
}
|
||||
var callback = action['callback'] as JSInvokable;
|
||||
var text = action['text'].toString();
|
||||
var style = (action['style'] ?? 'text').toString();
|
||||
actions.add(_JSCallbackButton(
|
||||
text: text,
|
||||
callback: JSAutoFreeFunction(callback),
|
||||
style: style,
|
||||
onCallbackFinished: () {
|
||||
dialogContext?.pop();
|
||||
},
|
||||
));
|
||||
}
|
||||
if (actions.isEmpty) {
|
||||
actions.add(TextButton(
|
||||
onPressed: () {
|
||||
dialogContext?.pop();
|
||||
},
|
||||
child: Text('OK'),
|
||||
));
|
||||
}
|
||||
return showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
dialogContext = context;
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: Text(content).paddingHorizontal(16),
|
||||
actions: actions,
|
||||
);
|
||||
},
|
||||
).then((value) {
|
||||
dialogContext = null;
|
||||
});
|
||||
}
|
||||
|
||||
int _showLoading(JSInvokable? onCancel) {
|
||||
var func = onCancel == null ? null : JSAutoFreeFunction(onCancel);
|
||||
var controller = showLoadingDialog(
|
||||
App.rootContext,
|
||||
barrierDismissible: onCancel != null,
|
||||
allowCancel: onCancel != null,
|
||||
onCancel: onCancel == null
|
||||
? null
|
||||
: () {
|
||||
func?.call([]);
|
||||
},
|
||||
);
|
||||
var i = 0;
|
||||
while (_loadingDialogControllers.containsKey(i)) {
|
||||
i++;
|
||||
}
|
||||
_loadingDialogControllers[i] = controller;
|
||||
return i;
|
||||
}
|
||||
|
||||
void _cancelLoading(int id) {
|
||||
var controller = _loadingDialogControllers.remove(id);
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
if (res != null) {
|
||||
return res.toString();
|
||||
} else {
|
||||
result = v;
|
||||
}
|
||||
} else {
|
||||
result = v;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int?> _showSelectDialog(
|
||||
String title,
|
||||
List<String> options,
|
||||
int? initialIndex,
|
||||
) {
|
||||
if (options.isEmpty) {
|
||||
return Future.value(null);
|
||||
}
|
||||
if (initialIndex != null &&
|
||||
(initialIndex >= options.length || initialIndex < 0)) {
|
||||
initialIndex = null;
|
||||
}
|
||||
return showSelectDialog(
|
||||
title: title,
|
||||
options: options,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JSCallbackButton extends StatefulWidget {
|
||||
const _JSCallbackButton({
|
||||
required this.text,
|
||||
required this.callback,
|
||||
required this.style,
|
||||
this.onCallbackFinished,
|
||||
});
|
||||
|
||||
final JSAutoFreeFunction callback;
|
||||
|
||||
final String text;
|
||||
|
||||
final String style;
|
||||
|
||||
final void Function()? onCallbackFinished;
|
||||
|
||||
@override
|
||||
State<_JSCallbackButton> createState() => _JSCallbackButtonState();
|
||||
}
|
||||
|
||||
class _JSCallbackButtonState extends State<_JSCallbackButton> {
|
||||
bool isLoading = false;
|
||||
|
||||
void onClick() async {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
var res = widget.callback.call([]);
|
||||
if (res is Future) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await res;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
widget.onCallbackFinished?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (widget.style) {
|
||||
"filled" => FilledButton(
|
||||
onPressed: onClick,
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
"danger" => FilledButton(
|
||||
onPressed: onClick,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.all(context.colorScheme.error),
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
_ => TextButton(
|
||||
onPressed: onClick,
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(strokeWidth: 1.4)
|
||||
.fixWidth(18)
|
||||
.fixHeight(18)
|
||||
: Text(widget.text),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
@@ -402,3 +402,59 @@ void showInfoDialog({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<int?> showSelectDialog({
|
||||
required String title,
|
||||
required List<String> options,
|
||||
int? initialIndex,
|
||||
}) async {
|
||||
int? current = initialIndex;
|
||||
|
||||
await showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Select(
|
||||
current: current == null ? "" : options[current!],
|
||||
values: options,
|
||||
minWidth: 156,
|
||||
onTap: (i) {
|
||||
setState(() {
|
||||
current = i;
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
current = null;
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Cancel'.tl),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: current == null
|
||||
? null
|
||||
: context.pop,
|
||||
child: Text('Confirm'.tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.2.0";
|
||||
final version = "1.2.1";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
@@ -90,13 +90,15 @@ class _Appdata {
|
||||
|
||||
/// Sync data from another device
|
||||
void syncData(Map<String, dynamic> data) {
|
||||
for (var key in data.keys) {
|
||||
if (_disableSync.contains(key)) {
|
||||
continue;
|
||||
if (data['settings'] is Map) {
|
||||
var settings = data['settings'] as Map<String, dynamic>;
|
||||
for (var key in settings.keys) {
|
||||
if (!_disableSync.contains(key)) {
|
||||
this.settings[key] = settings[key];
|
||||
}
|
||||
}
|
||||
settings[key] = data[key];
|
||||
}
|
||||
searchHistory = List.from(data['searchHistory']);
|
||||
searchHistory = List.from(data['searchHistory'] ?? []);
|
||||
saveData();
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ class _Settings with ChangeNotifier {
|
||||
'customImageProcessing': defaultCustomImageProcessing,
|
||||
'sni': true,
|
||||
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
||||
'comicSourceListUrl': "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json",
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
|
@@ -63,7 +63,8 @@ class ReaderImageProvider
|
||||
})()
|
||||
''');
|
||||
if (func is JSInvokable) {
|
||||
var result = func.invoke([imageBytes, cid, eid, page, sourceKey]);
|
||||
var autoFreeFunc = JSAutoFreeFunction(func);
|
||||
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
|
||||
if (result is Uint8List) {
|
||||
imageBytes = result;
|
||||
} else if (result is Future) {
|
||||
@@ -76,9 +77,9 @@ class ReaderImageProvider
|
||||
if (image is Uint8List) {
|
||||
imageBytes = image;
|
||||
} else if (image is Future) {
|
||||
JSInvokable? onCancel;
|
||||
JSAutoFreeFunction? onCancel;
|
||||
if (result['onCancel'] is JSInvokable) {
|
||||
onCancel = result['onCancel'];
|
||||
onCancel = JSAutoFreeFunction(result['onCancel']);
|
||||
}
|
||||
if (onCancel == null) {
|
||||
var futureImage = await image;
|
||||
@@ -96,9 +97,7 @@ class ReaderImageProvider
|
||||
checkStop();
|
||||
}
|
||||
catch(e) {
|
||||
onCancel.invoke([]);
|
||||
onCancel.free();
|
||||
func.free();
|
||||
onCancel([]);
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: 50));
|
||||
@@ -107,10 +106,8 @@ class ReaderImageProvider
|
||||
imageBytes = futureImage;
|
||||
}
|
||||
}
|
||||
onCancel?.free();
|
||||
}
|
||||
}
|
||||
func.free();
|
||||
}
|
||||
}
|
||||
return imageBytes!;
|
||||
|
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -20,9 +19,8 @@ import 'package:pointycastle/block/modes/cbc.dart';
|
||||
import 'package:pointycastle/block/modes/cfb.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/components/js_ui.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
@@ -42,7 +40,7 @@ class JavaScriptRuntimeException implements Exception {
|
||||
}
|
||||
}
|
||||
|
||||
class JsEngine with _JSEngineApi, _JsUiApi {
|
||||
class JsEngine with _JSEngineApi, JsUiApi {
|
||||
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||
|
||||
static JsEngine? _cache;
|
||||
@@ -156,7 +154,11 @@ class JsEngine with _JSEngineApi, _JsUiApi {
|
||||
case "delay":
|
||||
return Future.delayed(Duration(milliseconds: message["time"]));
|
||||
case "UI":
|
||||
handleUIMessage(Map.from(message));
|
||||
return handleUIMessage(Map.from(message));
|
||||
case "getLocale":
|
||||
return "${App.locale.languageCode}-${App.locale.countryCode}";
|
||||
case "getPlatform":
|
||||
return Platform.operatingSystem;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -679,6 +681,7 @@ class JSAutoFreeFunction {
|
||||
|
||||
/// Automatically free the function when it's not used anymore
|
||||
JSAutoFreeFunction(this.func) {
|
||||
func.dup();
|
||||
finalizer.attach(this, func);
|
||||
}
|
||||
|
||||
@@ -687,48 +690,6 @@ class JSAutoFreeFunction {
|
||||
}
|
||||
|
||||
static final finalizer = Finalizer<JSInvokable>((func) {
|
||||
func.free();
|
||||
func.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
mixin class _JsUiApi {
|
||||
void handleUIMessage(Map<String, dynamic> message) {
|
||||
switch (message['function']) {
|
||||
case 'showMessage':
|
||||
var m = message['message'];
|
||||
if (m.toString().isNotEmpty) {
|
||||
App.rootContext.showMessage(message: m.toString());
|
||||
}
|
||||
case 'showDialog':
|
||||
_showDialog(message);
|
||||
case 'launchUrl':
|
||||
var url = message['url'];
|
||||
if (url.toString().isNotEmpty) {
|
||||
launchUrlString(url.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showDialog(Map<String, dynamic> message) {
|
||||
var title = message['title'];
|
||||
var content = message['content'];
|
||||
var actions = <String, JSAutoFreeFunction>{};
|
||||
for (var action in message['actions']) {
|
||||
actions[action['text']] = JSAutoFreeFunction(action['callback']);
|
||||
}
|
||||
showDialog(context: App.rootContext, builder: (context) {
|
||||
return ContentDialog(
|
||||
title: title,
|
||||
content: Text(content).paddingHorizontal(16),
|
||||
actions: actions.entries.map((entry) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
entry.value.call([]);
|
||||
},
|
||||
child: Text(entry.key),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -135,6 +135,8 @@ class NetworkCacheManager implements Interceptor {
|
||||
}
|
||||
|
||||
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||
a = Map.from(a);
|
||||
b = Map.from(b);
|
||||
const shouldIgnore = [
|
||||
'cache-time',
|
||||
'prevent-parallel',
|
||||
|
@@ -246,7 +246,7 @@ class _BodyState extends State<_Body> {
|
||||
),
|
||||
);
|
||||
} else if (type == "callback") {
|
||||
yield _CallbackSetting(setting: item);
|
||||
yield _CallbackSetting(setting: item, sourceKey: source.key);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
|
||||
@@ -419,7 +419,7 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
void help() {
|
||||
launchUrlString("https://github.com/venera-app/venera-configs");
|
||||
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
|
||||
}
|
||||
|
||||
Future<void> handleAddSource(String url) async {
|
||||
@@ -469,8 +469,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
||||
|
||||
void load() async {
|
||||
var dio = AppDio();
|
||||
var res = await dio.get<String>(
|
||||
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
|
||||
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
|
||||
if (res.statusCode != 200) {
|
||||
context.showMessage(message: "Network error".tl);
|
||||
return;
|
||||
@@ -485,6 +484,27 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
||||
Widget build(BuildContext context) {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Comic Source".tl,
|
||||
tailing: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () async {
|
||||
await showInputDialog(
|
||||
context: context,
|
||||
title: "Set comic source list url".tl,
|
||||
initialValue: appdata.settings['comicSourceListUrl'],
|
||||
onConfirm: (value) {
|
||||
appdata.settings['comicSourceListUrl'] = value;
|
||||
appdata.saveData();
|
||||
setState(() {
|
||||
loading = true;
|
||||
json = null;
|
||||
});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
body: buildBody(),
|
||||
);
|
||||
}
|
||||
@@ -682,10 +702,12 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
}
|
||||
|
||||
class _CallbackSetting extends StatefulWidget {
|
||||
const _CallbackSetting({required this.setting});
|
||||
const _CallbackSetting({required this.setting, required this.sourceKey});
|
||||
|
||||
final MapEntry<String, Map<String, dynamic>> setting;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
@override
|
||||
State<_CallbackSetting> createState() => _CallbackSettingState();
|
||||
}
|
||||
@@ -719,11 +741,11 @@ class _CallbackSettingState extends State<_CallbackSetting> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(title.ts(key)),
|
||||
title: Text(title.ts(widget.sourceKey)),
|
||||
trailing: Button.normal(
|
||||
onPressed: onClick,
|
||||
isLoading: isLoading,
|
||||
child: Text(buttonText.ts(key)),
|
||||
child: Text(buttonText.ts(widget.sourceKey)),
|
||||
).fixHeight(32),
|
||||
);
|
||||
}
|
||||
|
@@ -124,18 +124,7 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
}
|
||||
return NetworkError(
|
||||
message: msg,
|
||||
retry: () {
|
||||
setState(() {
|
||||
pages = ComicSource.all()
|
||||
.map((e) => e.explorePages)
|
||||
.expand((e) => e.map((e) => e.title))
|
||||
.toList();
|
||||
controller = TabController(
|
||||
length: pages.length,
|
||||
vsync: this,
|
||||
);
|
||||
});
|
||||
},
|
||||
retry: onSettingsChanged,
|
||||
withAppbar: false,
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
@@ -535,38 +536,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.toOpacity(0.2),
|
||||
builder: (context) {
|
||||
var help = '';
|
||||
help +=
|
||||
'${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n';
|
||||
help += '${'1. The directory only contains image files.'.tl}\n';
|
||||
help +=
|
||||
'${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n';
|
||||
help +=
|
||||
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
|
||||
help +=
|
||||
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
|
||||
.tl;
|
||||
help +=
|
||||
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database."
|
||||
.tl;
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(help).paddingHorizontal(16),
|
||||
actions: [
|
||||
Button.filled(
|
||||
child: Text("OK".tl),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
|
||||
},
|
||||
).fixWidth(90).paddingRight(8),
|
||||
Button.filled(
|
||||
|
10
pubspec.lock
10
pubspec.lock
@@ -417,8 +417,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
|
||||
resolved-ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
|
||||
ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||
resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||
url: "https://github.com/wgh136/flutter_qjs"
|
||||
source: git
|
||||
version: "0.3.7"
|
||||
@@ -1150,10 +1150,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: zip_flutter
|
||||
sha256: be21152c35fcb6d0ef4ce89fc3aed681f7adc0db5490ca3eb5893f23fd20e646
|
||||
sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
version: "0.0.8"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.27.1"
|
||||
flutter: ">=3.27.2"
|
||||
|
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.0+120
|
||||
version: 1.2.1+121
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
flutter: 3.27.1
|
||||
flutter: 3.27.2
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -21,7 +21,7 @@ dependencies:
|
||||
flutter_qjs:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_qjs
|
||||
ref: 9c99ac258a11f8e91761a5466a190efba3ca64af
|
||||
ref: 598d50572a658f8e04775566fe3789954d9a01e3
|
||||
crypto: ^3.0.6
|
||||
dio: ^5.7.0
|
||||
html: ^0.15.5
|
||||
@@ -51,7 +51,7 @@ dependencies:
|
||||
sliver_tools: ^0.2.12
|
||||
flutter_file_dialog: ^3.0.2
|
||||
file_selector: ^1.0.3
|
||||
zip_flutter: ^0.0.6
|
||||
zip_flutter: ^0.0.8
|
||||
lodepng_flutter:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
|
Reference in New Issue
Block a user