mirror of
https://github.com/venera-app/venera.git
synced 2025-09-28 00:07:24 +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:
|
with:
|
||||||
distribution: 'oracle'
|
distribution: 'oracle'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
- name: Setup Rust
|
||||||
|
run: |
|
||||||
|
rustup update
|
||||||
|
rustup default stable
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
- run: flutter build apk --release
|
- run: flutter build apk --release
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
|
@@ -31,7 +31,7 @@ A comic reader that support reading local and network comics.
|
|||||||
|
|
||||||
## Create a new comic source
|
## Create a new comic source
|
||||||
|
|
||||||
See [venera-configs](https://github.com/venera-app/venera-configs)
|
See [Comic Source](doc/comic_source.md)
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
|
101
assets/init.js
101
assets/init.js
@@ -1205,6 +1205,10 @@ class Image {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI related apis
|
||||||
|
* @since 1.2.0
|
||||||
|
*/
|
||||||
let UI = {
|
let UI = {
|
||||||
/**
|
/**
|
||||||
* Show a message
|
* Show a message
|
||||||
@@ -1222,7 +1226,9 @@ let UI = {
|
|||||||
* Show a dialog. Any action will close the dialog.
|
* Show a dialog. Any action will close the dialog.
|
||||||
* @param title {string}
|
* @param title {string}
|
||||||
* @param content {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) => {
|
showDialog: (title, content, actions) => {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
@@ -1245,4 +1251,97 @@ let UI = {
|
|||||||
url: url,
|
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)",
|
"Size in MB": "大小(MB)",
|
||||||
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
|
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
|
||||||
"Help": "帮助",
|
"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",
|
"Export as cbz": "导出为cbz",
|
||||||
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
||||||
"An archive file" : "一个归档文件",
|
"An archive file" : "一个归档文件",
|
||||||
@@ -318,7 +313,8 @@
|
|||||||
"Imported @a comics": "已导入 @a 本漫画",
|
"Imported @a comics": "已导入 @a 本漫画",
|
||||||
"New Version": "新版本",
|
"New Version": "新版本",
|
||||||
"@c updates": "@c 项更新",
|
"@c updates": "@c 项更新",
|
||||||
"No updates": "无更新"
|
"No updates": "无更新",
|
||||||
|
"Set comic source list url": "设置漫画源列表URL"
|
||||||
},
|
},
|
||||||
"zh_TW": {
|
"zh_TW": {
|
||||||
"Home": "首頁",
|
"Home": "首頁",
|
||||||
@@ -468,11 +464,6 @@
|
|||||||
"Size in MB": "大小(MB)",
|
"Size in MB": "大小(MB)",
|
||||||
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
|
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
|
||||||
"Help": "幫助",
|
"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",
|
"Export as cbz": "匯出為cbz",
|
||||||
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
||||||
"An archive file" : "一個歸檔文件",
|
"An archive file" : "一個歸檔文件",
|
||||||
@@ -639,6 +630,7 @@
|
|||||||
"Imported @a comics": "已匯入 @a 部漫畫",
|
"Imported @a comics": "已匯入 @a 部漫畫",
|
||||||
"New Version": "新版本",
|
"New Version": "新版本",
|
||||||
"@c updates": "@c 項更新",
|
"@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
|
# 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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
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";
|
export "context.dart";
|
||||||
|
|
||||||
class _App {
|
class _App {
|
||||||
final version = "1.2.0";
|
final version = "1.2.1";
|
||||||
|
|
||||||
bool get isAndroid => Platform.isAndroid;
|
bool get isAndroid => Platform.isAndroid;
|
||||||
|
|
||||||
|
@@ -90,13 +90,15 @@ class _Appdata {
|
|||||||
|
|
||||||
/// Sync data from another device
|
/// Sync data from another device
|
||||||
void syncData(Map<String, dynamic> data) {
|
void syncData(Map<String, dynamic> data) {
|
||||||
for (var key in data.keys) {
|
if (data['settings'] is Map) {
|
||||||
if (_disableSync.contains(key)) {
|
var settings = data['settings'] as Map<String, dynamic>;
|
||||||
continue;
|
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();
|
saveData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ class _Settings with ChangeNotifier {
|
|||||||
'customImageProcessing': defaultCustomImageProcessing,
|
'customImageProcessing': defaultCustomImageProcessing,
|
||||||
'sni': true,
|
'sni': true,
|
||||||
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
||||||
|
'comicSourceListUrl': "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json",
|
||||||
};
|
};
|
||||||
|
|
||||||
operator [](String key) {
|
operator [](String key) {
|
||||||
|
@@ -63,7 +63,8 @@ class ReaderImageProvider
|
|||||||
})()
|
})()
|
||||||
''');
|
''');
|
||||||
if (func is JSInvokable) {
|
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) {
|
if (result is Uint8List) {
|
||||||
imageBytes = result;
|
imageBytes = result;
|
||||||
} else if (result is Future) {
|
} else if (result is Future) {
|
||||||
@@ -76,9 +77,9 @@ class ReaderImageProvider
|
|||||||
if (image is Uint8List) {
|
if (image is Uint8List) {
|
||||||
imageBytes = image;
|
imageBytes = image;
|
||||||
} else if (image is Future) {
|
} else if (image is Future) {
|
||||||
JSInvokable? onCancel;
|
JSAutoFreeFunction? onCancel;
|
||||||
if (result['onCancel'] is JSInvokable) {
|
if (result['onCancel'] is JSInvokable) {
|
||||||
onCancel = result['onCancel'];
|
onCancel = JSAutoFreeFunction(result['onCancel']);
|
||||||
}
|
}
|
||||||
if (onCancel == null) {
|
if (onCancel == null) {
|
||||||
var futureImage = await image;
|
var futureImage = await image;
|
||||||
@@ -96,9 +97,7 @@ class ReaderImageProvider
|
|||||||
checkStop();
|
checkStop();
|
||||||
}
|
}
|
||||||
catch(e) {
|
catch(e) {
|
||||||
onCancel.invoke([]);
|
onCancel([]);
|
||||||
onCancel.free();
|
|
||||||
func.free();
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
await Future.delayed(Duration(milliseconds: 50));
|
await Future.delayed(Duration(milliseconds: 50));
|
||||||
@@ -107,10 +106,8 @@ class ReaderImageProvider
|
|||||||
imageBytes = futureImage;
|
imageBytes = futureImage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onCancel?.free();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func.free();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imageBytes!;
|
return imageBytes!;
|
||||||
|
@@ -3,7 +3,6 @@ import 'dart:io';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:dio/io.dart';
|
import 'package:dio/io.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:html/parser.dart' as html;
|
import 'package:html/parser.dart' as html;
|
||||||
import 'package:html/dom.dart' as dom;
|
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/cfb.dart';
|
||||||
import 'package:pointycastle/block/modes/ecb.dart';
|
import 'package:pointycastle/block/modes/ecb.dart';
|
||||||
import 'package:pointycastle/block/modes/ofb.dart';
|
import 'package:pointycastle/block/modes/ofb.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
import 'package:uuid/uuid.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/foundation/app.dart';
|
||||||
import 'package:venera/network/app_dio.dart';
|
import 'package:venera/network/app_dio.dart';
|
||||||
import 'package:venera/network/cookie_jar.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());
|
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||||
|
|
||||||
static JsEngine? _cache;
|
static JsEngine? _cache;
|
||||||
@@ -156,7 +154,11 @@ class JsEngine with _JSEngineApi, _JsUiApi {
|
|||||||
case "delay":
|
case "delay":
|
||||||
return Future.delayed(Duration(milliseconds: message["time"]));
|
return Future.delayed(Duration(milliseconds: message["time"]));
|
||||||
case "UI":
|
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;
|
return null;
|
||||||
@@ -679,6 +681,7 @@ class JSAutoFreeFunction {
|
|||||||
|
|
||||||
/// Automatically free the function when it's not used anymore
|
/// Automatically free the function when it's not used anymore
|
||||||
JSAutoFreeFunction(this.func) {
|
JSAutoFreeFunction(this.func) {
|
||||||
|
func.dup();
|
||||||
finalizer.attach(this, func);
|
finalizer.attach(this, func);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,48 +690,6 @@ class JSAutoFreeFunction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final finalizer = Finalizer<JSInvokable>((func) {
|
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) {
|
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||||
|
a = Map.from(a);
|
||||||
|
b = Map.from(b);
|
||||||
const shouldIgnore = [
|
const shouldIgnore = [
|
||||||
'cache-time',
|
'cache-time',
|
||||||
'prevent-parallel',
|
'prevent-parallel',
|
||||||
|
@@ -246,7 +246,7 @@ class _BodyState extends State<_Body> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (type == "callback") {
|
} else if (type == "callback") {
|
||||||
yield _CallbackSetting(setting: item);
|
yield _CallbackSetting(setting: item, sourceKey: source.key);
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
|
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
|
||||||
@@ -419,7 +419,7 @@ class _BodyState extends State<_Body> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void help() {
|
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 {
|
Future<void> handleAddSource(String url) async {
|
||||||
@@ -469,8 +469,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
|||||||
|
|
||||||
void load() async {
|
void load() async {
|
||||||
var dio = AppDio();
|
var dio = AppDio();
|
||||||
var res = await dio.get<String>(
|
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
|
||||||
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
|
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
context.showMessage(message: "Network error".tl);
|
context.showMessage(message: "Network error".tl);
|
||||||
return;
|
return;
|
||||||
@@ -485,6 +484,27 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopUpWidgetScaffold(
|
return PopUpWidgetScaffold(
|
||||||
title: "Comic Source".tl,
|
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(),
|
body: buildBody(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -682,10 +702,12 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CallbackSetting extends StatefulWidget {
|
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 MapEntry<String, Map<String, dynamic>> setting;
|
||||||
|
|
||||||
|
final String sourceKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_CallbackSetting> createState() => _CallbackSettingState();
|
State<_CallbackSetting> createState() => _CallbackSettingState();
|
||||||
}
|
}
|
||||||
@@ -719,11 +741,11 @@ class _CallbackSettingState extends State<_CallbackSetting> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(title.ts(key)),
|
title: Text(title.ts(widget.sourceKey)),
|
||||||
trailing: Button.normal(
|
trailing: Button.normal(
|
||||||
onPressed: onClick,
|
onPressed: onClick,
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
child: Text(buttonText.ts(key)),
|
child: Text(buttonText.ts(widget.sourceKey)),
|
||||||
).fixHeight(32),
|
).fixHeight(32),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -124,18 +124,7 @@ class _ExplorePageState extends State<ExplorePage>
|
|||||||
}
|
}
|
||||||
return NetworkError(
|
return NetworkError(
|
||||||
message: msg,
|
message: msg,
|
||||||
retry: () {
|
retry: onSettingsChanged,
|
||||||
setState(() {
|
|
||||||
pages = ComicSource.all()
|
|
||||||
.map((e) => e.explorePages)
|
|
||||||
.expand((e) => e.map((e) => e.title))
|
|
||||||
.toList();
|
|
||||||
controller = TabController(
|
|
||||||
length: pages.length,
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
withAppbar: false,
|
withAppbar: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.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/components/components.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
@@ -535,38 +536,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(
|
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
).fixWidth(90).paddingRight(8),
|
).fixWidth(90).paddingRight(8),
|
||||||
Button.filled(
|
Button.filled(
|
||||||
|
10
pubspec.lock
10
pubspec.lock
@@ -417,8 +417,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
|
ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||||
resolved-ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
|
resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||||
url: "https://github.com/wgh136/flutter_qjs"
|
url: "https://github.com/wgh136/flutter_qjs"
|
||||||
source: git
|
source: git
|
||||||
version: "0.3.7"
|
version: "0.3.7"
|
||||||
@@ -1150,10 +1150,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: zip_flutter
|
name: zip_flutter
|
||||||
sha256: be21152c35fcb6d0ef4ce89fc3aed681f7adc0db5490ca3eb5893f23fd20e646
|
sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.6"
|
version: "0.0.8"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.6.0 <4.0.0"
|
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."
|
description: "A comic app."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.2.0+120
|
version: 1.2.1+121
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.6.0 <4.0.0'
|
sdk: '>=3.6.0 <4.0.0'
|
||||||
flutter: 3.27.1
|
flutter: 3.27.2
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
@@ -21,7 +21,7 @@ dependencies:
|
|||||||
flutter_qjs:
|
flutter_qjs:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/wgh136/flutter_qjs
|
url: https://github.com/wgh136/flutter_qjs
|
||||||
ref: 9c99ac258a11f8e91761a5466a190efba3ca64af
|
ref: 598d50572a658f8e04775566fe3789954d9a01e3
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
html: ^0.15.5
|
html: ^0.15.5
|
||||||
@@ -51,7 +51,7 @@ dependencies:
|
|||||||
sliver_tools: ^0.2.12
|
sliver_tools: ^0.2.12
|
||||||
flutter_file_dialog: ^3.0.2
|
flutter_file_dialog: ^3.0.2
|
||||||
file_selector: ^1.0.3
|
file_selector: ^1.0.3
|
||||||
zip_flutter: ^0.0.6
|
zip_flutter: ^0.0.8
|
||||||
lodepng_flutter:
|
lodepng_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/venera-app/lodepng_flutter
|
url: https://github.com/venera-app/lodepng_flutter
|
||||||
|
Reference in New Issue
Block a user