30 Commits

Author SHA1 Message Date
nyne
7fa48cec29 Merge pull request #515 from venera-app/v1.5.1-dev
V1.5.1
2025-09-14 18:56:12 +08:00
e549a18dbf flutter 3.35.3 2025-09-14 18:54:26 +08:00
c17c4abb5b Reduce size of scroll bar. 2025-09-14 18:43:11 +08:00
af57bc31b1 Update version code. 2025-09-14 18:33:19 +08:00
16449a1440 Change page transition animation for Android. 2025-09-14 18:30:54 +08:00
a7c1983f35 Fallback to local cover if loading fails for favorite comic. 2025-09-14 17:19:23 +08:00
4c257d7178 Show read button if loading fails. 2025-09-14 17:05:45 +08:00
3a9d634edf Update android build script. 2025-09-14 10:21:14 +08:00
nyne
e179c8f67f Change padding check condition for Android platform (#503) 2025-09-05 17:52:33 +08:00
nyne
c4b85471c1 Merge pull request #499 from KarlZeo/fix-ios-padding-check
fix padding check error on ios
2025-09-05 17:42:49 +08:00
KarlZeo
a898b57d96 fix padding check error on ios 2025-09-04 20:04:28 +08:00
50c6bec4cd Disable minify 2025-09-04 00:30:01 +08:00
nyne
8c44f83d6c Update Xcode version in GitHub Actions workflow 2025-09-03 22:50:32 +08:00
nyne
103b6b2832 Merge pull request #497 from venera-app/v1.5.0-dev
V1.5.0
2025-09-03 22:12:00 +08:00
4129349c70 Improve js api onResponse 2025-09-03 22:09:07 +08:00
77a9aa5457 Update version code. 2025-09-03 22:05:04 +08:00
97940b9492 Refactor category options. 2025-09-03 22:03:54 +08:00
7945c0e54f Improve compute api. 2025-09-03 20:31:42 +08:00
dfee65c3af Add compute api to js engine. 2025-09-02 22:15:54 +08:00
fa2dbd79f6 Fix invalid js stacktrace. 2025-09-02 20:35:47 +08:00
9a9f539906 Disable cache when updating comic source. 2025-09-02 20:16:13 +08:00
d7331f36e9 flutter 3.35.2 2025-09-01 21:13:57 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
d0b76de465 Use badge from shields.io (#455)
* Use badge from shields.io

* AUR
2025-09-01 20:55:45 +08:00
894a922b8f fix js api onResponse 2025-09-01 20:55:15 +08:00
a91d7fff2d move logic to foundation 2025-09-01 20:54:11 +08:00
Luorix
926a3a530e Venera Headless Mode Update (#476)
* 添加无头模式支持,增强日志功能,优化更新流程
I have successfully implemented the headless mode feature in Venera, fixed all runtime errors, and updated the output to be in JSON format. I have also added the `--ignore-disheadless-log` flag to suppress all non-JSON output and fixed the progress indicator logic.

You can now use the following commands:

- `venera --headless webdav up`: Upload the current WebDAV configuration.
- `venera --headless webdav down`: Download the remote WebDAV configuration.
- `venera --headless updatescript all`: Update all comic source scripts.
- `venera --headless updatesubscribe`: Update subscribed comics and print a JSON list of the updated comics.
- `venera --headless --ignore-disheadless-log ...`: Run any of the above commands while suppressing all non-JSON output.

The implementation involved:

1. Creating a new `lib/headless.dart` file to handle the headless logic.
2. Modifying `lib/main.dart` to recognize the `--headless` argument.
3. Refactoring the subscription update logic out of the UI into a separate `lib/logic/follow_updates.dart` file to be used by both the UI and the headless mode.
4. Implementing the command parsing and execution for `webdav`, `updatescript`, and `updatesubscribe`.
5. Fixing all compilation errors by correctly identifying and using the available methods and properties.
6. Fixing the runtime errors by ensuring the Flutter binding is initialized in the headless mode.
7. Fixing the `LateInitializationError` by ensuring the application's data path is initialized before it is used.
8. Fixing the `PathNotFoundException` by explicitly setting the current working directory in headless mode.
9. Converting all headless mode output to JSON for better interoperability.
10. Fixing the progress indicator bug.
11. Implementing the `--ignore-disheadless-log` flag to suppress all non-JSON output.
12. Including comic metadata in the progress output.
13. Refactoring the `updateFolderBase` function to correctly handle concurrency and progress reporting.
14. Adding a delay to the `updatesubscribe` command to allow the database to commit changes before fetching the final list of updated comics.

* 将封面字段名称从 'cover' 更改为 'coverUrl',以统一 JSON 输出格式

* remove md

* 增强无头模式的更新进度报告,添加错误处理信息

* 修复init没有wait的问题

* 优化init函数中的异步初始化,确保所有组件初始化完成后再继续执行

* 重构更新漫画逻辑,添加错误处理并优化更新进度报告。添加单个漫画更新检查支持

* 添加无头模式文档,描述命令行功能及使用方法

* 增强无头模式下的更新信息,添加源数据的JS表示形式

* 增强无头模式下的更新脚本输出,添加详细进度和最终总结信息;改进错误处理逻辑以支持不同的显示模式
2025-09-01 20:49:47 +08:00
d308c2ac60 Add mouse scroll speed setting. Close #471 2025-08-24 19:52:24 +08:00
ac13807ef4 Add option to ignore certificate errors. Close #485 2025-08-24 19:19:40 +08:00
38a5b2b8cf refactor: comic specific settings 2025-08-24 19:04:42 +08:00
3a7c8d5e38 Fix toolbar overflow. 2025-08-24 17:57:35 +08:00
41 changed files with 1980 additions and 934 deletions

View File

@@ -15,7 +15,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get
# Step 1: Decode and install the certificate
- name: Decode and install certificate
@@ -63,7 +63,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get
- run: flutter build ios --release --no-codesign
- run: |

View File

@@ -1,15 +1,14 @@
# venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera?style=flat)](https://github.com/venera-app/venera/stargazers)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/venera_release)
A comic reader that support reading local and network comics.
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![AUR Version](https://img.shields.io/aur/version/venera-bin)](https://aur.archlinux.org/packages/venera-bin)
[![F-Droid Version](https://img.shields.io/f-droid/v/com.github.wgh136.venera)](https://f-droid.org/packages/com.github.wgh136.venera/)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
A comic reader that support reading local and network comics.
## Features
- Read local comics
@@ -34,4 +33,7 @@ See [Comic Source](doc/comic_source.md)
### Tags Translation
[![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=EhTagTranslation&repo=Database)](https://github.com/EhTagTranslation/Database)
## Headless Mode
See [Headless Doc](doc/headless_doc.md)
The Chinese translation of the manga tags is from this project.

View File

@@ -34,6 +34,12 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion "28.0.13004108"
packaging {
jniLibs {
useLegacyPackaging true
}
}
splits{
abi {
reset()
@@ -78,6 +84,9 @@ android {
buildTypes {
release {
// Temporarily solution to fix crash
minifyEnabled false
shrinkResources false
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}

View File

@@ -16,6 +16,7 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
@@ -58,8 +59,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.9.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

@@ -4,6 +4,18 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app.
*/
/**
* @function sendMessage
* @global
* @param {Object} message
* @returns {any}
*/
/**
* Set a timeout to execute a callback function after a specified delay.
* @param callback {Function}
* @param delay {number} - delay in milliseconds
*/
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
@@ -1411,4 +1423,19 @@ function getClipboard() {
return sendMessage({
method: 'getClipboard'
})
}
/**
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
* @param args {any[]} - The arguments to pass to the function.
* @returns {Promise<any>} - The result of the function.
* @since 1.5.0
*/
function compute(func, ...args) {
return sendMessage({
method: 'compute',
function: func,
args: args
})
}

View File

@@ -409,7 +409,9 @@
"Export logs": "导出日志",
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
"Enable comic specific settings": "为每本漫画保存特定设置"
"Enable comic specific settings": "启用此漫画特定设置",
"Ignore Certificate Errors": "忽略证书错误",
"Mouse scroll speed": "鼠标滚动速度"
},
"zh_TW": {
"Home": "首頁",
@@ -821,6 +823,8 @@
"Export logs": "匯出日誌",
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
"Enable comic specific settings": "為每本漫畫保存特定設定"
"Enable comic specific settings": "啟用此漫畫特定設定",
"Ignore Certificate Errors": "忽略證書錯誤",
"Mouse scroll speed": "滑鼠滾動速度"
}
}

180
doc/headless_doc.md Normal file
View File

@@ -0,0 +1,180 @@
# Venera Headless Mode
Venera's headless mode allows you to run key features from the command line, making it easy to automate tasks and integrate with other tools. This document outlines the available commands and their usage.
## How to Use
To activate headless mode, use the `--headless` flag when running the Venera executable, followed by the desired command.
```bash
venera --headless <command> [subcommand] [options]
```
## Global Options
- **`--ignore-disheadless-log`**: Suppresses log output, providing a cleaner output for scripting.
## Commands
### `webdav`
Manage WebDAV data synchronization.
- **`webdav up`**: Uploads your local configuration to the WebDAV server.
- **`webdav down`**: Downloads and applies the remote configuration from the WebDAV server.
**Example:**
```bash
venera --headless webdav up
```
### `updatescript`
Update comic source scripts.
- **`updatescript all`**: Checks for and applies all available updates for your comic source scripts.
**Example:**
```bash
venera --headless updatescript all
```
**Output Format:**
The `updatescript` command provides detailed progress and a final summary.
**Progress Logs:**
- **`Progress`**: Indicates a successful update for a single script.
- **`ProgressError`**: Indicates a failure during a script update.
**Example `Progress` Log:**
```json
{
"status": "running",
"message": "Progress",
"data": {
"current": 1,
"total": 5,
"source": {
"key": "source-key",
"name": "Source Name",
"version": "1.0.0",
"url": "https://example.com/source.js"
}
}
}
```
**Final Summary:**
A summary is provided at the end, detailing the total number of scripts, how many were updated, and how many failed.
```json
{
"status": "success",
"message": "All scripts updated.",
"data": {
"total": 5,
"updated": 4,
"errors": 1
}
}
```
### `updatesubscribe`
Update your subscribed comics and retrieve a list of updated comics.
- **`updatesubscribe`**: Checks all subscribed comics for updates.
- **`updatesubscribe --update-comic-by-id-type <id> <type>`**: Updates a single comic specified by its `id` and `type`.
**Example:**
```bash
# Update all subscriptions
venera --headless updatesubscribe
# Update a single comic
venera --headless updatesubscribe --update-comic-by-id-type "comic-id" "source-key"
```
## Output Format
All headless commands output JSON objects prefixed with `[CLI PRINT]`. This structured format allows for easy parsing in automated scripts. The JSON object always contains a `status` and a `message`. For commands that return data, a `data` field will also be present.
### `updatesubscribe` Output
The `updatesubscribe` command provides detailed progress and final results in JSON format.
**Progress Logs:**
During an update, you will receive `Progress` or `ProgressError` messages.
- **`Progress`**: Indicates a successful step in the update process.
- **`ProgressError`**: Indicates an error occurred while updating a specific comic.
**Example `Progress` Log:**
```json
{
"status": "running",
"message": "Progress",
"data": {
"current": 1,
"total": 10,
"comic": {
"id": "some-comic-id",
"name": "Some Comic Name",
"coverUrl": "https://example.com/cover.jpg",
"author": "Author Name",
"type": "source-key",
"updateTime": "2023-10-27T12:00:00Z",
"tags": ["tag1", "tag2"]
}
}
}
```
**Example `ProgressError` Log:**
```json
{
"status": "running",
"message": "ProgressError",
"data": {
"current": 2,
"total": 10,
"comic": {
"id": "another-comic-id",
"name": "Another Comic Name",
...
},
"error": "Error message here"
}
}
```
**Final Output:**
Once the update process is complete, a final JSON object is returned with a list of all comics that have been updated.
```json
{
"status": "success",
"message": "Updated comics list.",
"data": [
{
"id": "some-comic-id",
"name": "Some Comic Name",
"coverUrl": "https://example.com/cover.jpg",
"author": "Author Name",
"type": "source-key",
"updateTime": "2023-10-27T12:00:00Z",
"tags": ["tag1", "tag2"]
}
]
}

View File

@@ -17,6 +17,7 @@ ImageProvider? _findImageProvider(Comic comic) {
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
fallbackToLocalCover: comic is FavoriteItem,
);
}
return image;

View File

@@ -7,6 +7,7 @@ class NetworkError extends StatelessWidget {
this.retry,
this.withAppbar = true,
this.buttonText,
this.action,
});
final String message;
@@ -17,6 +18,8 @@ class NetworkError extends StatelessWidget {
final String? buttonText;
final Widget? action;
@override
Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message);
@@ -67,9 +70,16 @@ class NetworkError extends StatelessWidget {
child: Text('Verify'.tl),
)
else
FilledButton(
onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (action != null)
action!.paddingRight(8),
FilledButton(
onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl),
),
],
),
],
),

View File

@@ -7,8 +7,11 @@ class PaneItemEntry {
IconData activeIcon;
PaneItemEntry(
{required this.label, required this.icon, required this.activeIcon});
PaneItemEntry({
required this.label,
required this.icon,
required this.activeIcon,
});
}
class PaneActionEntry {
@@ -18,20 +21,24 @@ class PaneActionEntry {
VoidCallback onTap;
PaneActionEntry(
{required this.label, required this.icon, required this.onTap});
PaneActionEntry({
required this.label,
required this.icon,
required this.onTap,
});
}
class NaviPane extends StatefulWidget {
const NaviPane(
{required this.paneItems,
required this.paneActions,
required this.pageBuilder,
this.initialPage = 0,
this.onPageChanged,
required this.observer,
required this.navigatorKey,
super.key});
const NaviPane({
required this.paneItems,
required this.paneActions,
required this.pageBuilder,
this.initialPage = 0,
this.onPageChanged,
required this.observer,
required this.navigatorKey,
super.key,
});
final List<PaneItemEntry> paneItems;
@@ -187,7 +194,8 @@ class NaviPaneState extends State<NaviPane>
child: buildLeft(),
),
Positioned.fill(
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
left:
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
(_kSideBarWidth - _kFoldedSideBarWidth) *
((value - 2).clamp(0, 1)),
child: buildMainView(),
@@ -202,14 +210,19 @@ class NaviPaneState extends State<NaviPane>
Widget buildMainView() {
return HeroControllerScope(
controller: MaterialApp.createMaterialHeroController(),
child: Navigator(
observers: [widget.observer],
key: widget.navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false,
builder: (context) {
return _NaviMainView(state: this);
},
child: NavigatorPopHandler(
onPopWithResult: (result) {
widget.navigatorKey.currentState?.maybePop(result);
},
child: Navigator(
observers: [widget.observer],
key: widget.navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false,
builder: (context) {
return _NaviMainView(state: this);
},
),
),
),
);
@@ -239,7 +252,7 @@ class NaviPaneState extends State<NaviPane>
icon: Icon(action.icon),
onPressed: action.onTap,
),
)
),
],
),
),
@@ -261,21 +274,18 @@ class NaviPaneState extends State<NaviPane>
),
),
child: Row(
children: List<Widget>.generate(
widget.paneItems.length,
(index) {
return Expanded(
child: _SingleBottomNaviWidget(
enabled: currentPage == index,
entry: widget.paneItems[index],
onTap: () {
updatePage(index);
},
key: ValueKey(index),
),
);
},
),
children: List<Widget>.generate(widget.paneItems.length, (index) {
return Expanded(
child: _SingleBottomNaviWidget(
enabled: currentPage == index,
entry: widget.paneItems[index],
onTap: () {
updatePage(index);
},
key: ValueKey(index),
),
);
}),
),
),
);
@@ -286,7 +296,8 @@ class NaviPaneState extends State<NaviPane>
const paddingHorizontal = 12.0;
return Material(
child: Container(
width: _kFoldedSideBarWidth +
width:
_kFoldedSideBarWidth +
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
height: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
@@ -323,9 +334,7 @@ class NaviPaneState extends State<NaviPane>
key: ValueKey(index + widget.paneItems.length),
),
),
const SizedBox(
height: 16,
)
const SizedBox(height: 16),
],
),
),
@@ -334,12 +343,13 @@ class NaviPaneState extends State<NaviPane>
}
class _SideNaviWidget extends StatelessWidget {
const _SideNaviWidget(
{required this.enabled,
required this.entry,
required this.onTap,
required this.showTitle,
super.key});
const _SideNaviWidget({
required this.enabled,
required this.entry,
required this.onTap,
required this.showTitle,
super.key,
});
final bool enabled;
@@ -368,18 +378,18 @@ class _SideNaviWidget extends StatelessWidget {
? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)],
)
: Align(
alignment: Alignment.centerLeft,
child: icon,
),
: Align(alignment: Alignment.centerLeft, child: icon),
),
).paddingVertical(4);
}
}
class _PaneActionWidget extends StatelessWidget {
const _PaneActionWidget(
{required this.entry, required this.showTitle, super.key});
const _PaneActionWidget({
required this.entry,
required this.showTitle,
super.key,
});
final PaneActionEntry entry;
@@ -399,21 +409,19 @@ class _PaneActionWidget extends StatelessWidget {
? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)],
)
: Align(
alignment: Alignment.centerLeft,
child: icon,
),
: Align(alignment: Alignment.centerLeft, child: icon),
),
).paddingVertical(4);
}
}
class _SingleBottomNaviWidget extends StatefulWidget {
const _SingleBottomNaviWidget(
{required this.enabled,
required this.entry,
required this.onTap,
super.key});
const _SingleBottomNaviWidget({
required this.enabled,
required this.entry,
required this.onTap,
super.key,
});
final bool enabled;
@@ -482,8 +490,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
Widget buildContent() {
final value = controller.value;
final colorScheme = Theme.of(context).colorScheme;
final icon =
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
final icon = Icon(
widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
);
return Center(
child: Container(
width: 64,
@@ -570,8 +579,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
}
class _NaviPopScope extends StatelessWidget {
const _NaviPopScope(
{required this.child, this.popGesture = false, required this.action});
const _NaviPopScope({
required this.child,
this.popGesture = false,
required this.action,
});
final Widget child;
final bool popGesture;
@@ -581,32 +593,25 @@ class _NaviPopScope extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget res = App.isIOS
? child
: PopScope(
canPop: App.isAndroid ? false : true,
onPopInvokedWithResult: (value, result) {
action();
},
child: child,
);
Widget res = child;
if (popGesture) {
res = GestureDetector(
onPanStart: (details) {
if (details.globalPosition.dx < 64) {
panStartAtEdge = true;
onPanStart: (details) {
if (details.globalPosition.dx < 64) {
panStartAtEdge = true;
}
},
onPanEnd: (details) {
if (details.velocity.pixelsPerSecond.dx < 0 ||
details.velocity.pixelsPerSecond.dx > 0) {
if (panStartAtEdge) {
action();
}
},
onPanEnd: (details) {
if (details.velocity.pixelsPerSecond.dx < 0 ||
details.velocity.pixelsPerSecond.dx > 0) {
if (panStartAtEdge) {
action();
}
}
panStartAtEdge = false;
},
child: res);
}
panStartAtEdge = false;
},
child: res,
);
}
return res;
}

View File

@@ -237,7 +237,7 @@ class _AppScrollBarState extends State<AppScrollBar> {
double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
@@ -354,7 +354,7 @@ class _ScrollIndicatorPainter extends CustomPainter {
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawShadow(path, shadowColor, 4, true);
canvas.drawShadow(path, shadowColor, 2, true);
var backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.4.6";
final version = "1.5.1";
bool get isAndroid => Platform.isAndroid;
@@ -30,6 +30,10 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS;
// Whether the app has been initialized.
// If current Isolate is main Isolate, this value is always true.
bool isInitialized = false;
Locale get locale {
Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" &&
@@ -81,6 +85,7 @@ class _App {
if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path;
}
isInitialized = true;
}
Future<void> initComponents() async {

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
const double _kBackGestureWidth = 20.0;
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
@@ -115,7 +116,14 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return SlidePageTransitionBuilder().buildTransitions(
PageTransitionsBuilder builder;
if (App.isAndroid) {
builder = PredictiveBackPageTransitionsBuilder();
} else {
builder = SlidePageTransitionBuilder();
}
return builder.buildTransitions(
this,
context,
animation,

View File

@@ -152,7 +152,6 @@ class Settings with ChangeNotifier {
'blockedWords': [],
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'enableComicSpecificSettings': false,
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
@@ -191,6 +190,8 @@ class Settings with ChangeNotifier {
'reverseChapterOrder': false,
'showSystemStatusBar': false,
'comicSpecificSettings': <String, Map<String, dynamic>>{},
'ignoreBadCertificate': false,
'readerScrollSpeed': 1.0, // 0.5 - 3.0
};
operator [](String key) {
@@ -204,18 +205,19 @@ class Settings with ChangeNotifier {
}
}
bool haveComicSpecificSettings(String comicId, String sourceKey, String key) {
return _data['comicSpecificSettings']?["$comicId@$sourceKey"]?.containsKey(
key,
) ??
false;
void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) {
setReaderSetting(comicId, sourceKey, "enabled", enabled);
}
bool isComicSpecificSettingsEnabled(String? comicId, String? sourceKey) {
if (comicId == null || sourceKey == null) {
return false;
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true;
}
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
if (key == 'enableComicSpecificSettings') {
return _data['enableComicSpecificSettings'];
}
if (_data['enableComicSpecificSettings'] == false) {
if (!isComicSpecificSettingsEnabled(comicId, sourceKey)) {
return _data[key];
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
@@ -228,16 +230,6 @@ class Settings with ChangeNotifier {
String key,
dynamic value,
) {
if (key == 'enableComicSpecificSettings') {
_data['enableComicSpecificSettings'] = value;
notifyListeners();
return;
}
if (_data['enableComicSpecificSettings'] == false) {
_data[key] = value;
notifyListeners();
return;
}
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
"$comicId@$sourceKey",
() => <String, dynamic>{},
@@ -245,16 +237,8 @@ class Settings with ChangeNotifier {
notifyListeners();
}
void resetComicReaderSettings(String comicId, String sourceKey) {
final allComicSettings = _data['comicSpecificSettings'] as Map;
if (allComicSettings.containsKey("$comicId@$sourceKey")) {
allComicSettings.remove("$comicId@$sourceKey");
}
notifyListeners();
}
void resetAllComicReaderSettings() {
_data['comicSpecificSettings'] = <String, Map<String, dynamic>>{};
void resetComicReaderSettings(String key) {
(_data['comicSpecificSettings'] as Map).remove(key);
notifyListeners();
}

View File

@@ -401,9 +401,14 @@ class SearchOptions {
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page);
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
String category, String? param);
class CategoryComicsData {
/// options
final List<CategoryComicsOptions> options;
final List<CategoryComicsOptions>? options;
final CategoryOptionsLoader? optionsLoader;
/// [category] is the one clicked by the user on the category page.
///
@@ -414,7 +419,7 @@ class CategoryComicsData {
final RankingData? rankingData;
const CategoryComicsData(this.options, this.load, {this.rankingData});
const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
}
class RankingData {
@@ -429,6 +434,9 @@ class RankingData {
}
class CategoryComicsOptions {
// The label will not be displayed if it is empty.
final String label;
/// Use a [LinkedHashMap] to describe an option list.
/// key is for loading comics, value is the name displayed on screen.
/// Default value will be the first of the Map.
@@ -439,7 +447,7 @@ class CategoryComicsOptions {
final List<String>? showWhen;
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
}
class LinkHandler {

View File

@@ -64,8 +64,13 @@ class ComicSourceParser {
if (file.existsSync()) {
int i = 0;
while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source",
"${fileName.split('.').first}($i).js"));
file = File(
FilePath.join(
App.dataPath,
"comic_source",
"${fileName.split('.').first}($i).js",
),
);
i++;
}
}
@@ -80,8 +85,9 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n");
var line1 =
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
var line1 = js
.split('\n')
.firstWhereOrNull((e) => e.trim().startsWith("class "));
if (line1 == null ||
!line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) {
@@ -89,24 +95,27 @@ class ComicSourceParser {
}
var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim();
JsEngine().runCode("""
(() => { $js
JsEngine().runCode("""(() => { $js
this['temp'] = new $className()
}).call()
""", className);
_name = JsEngine().runCode("this['temp'].name") ??
_name =
JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ??
var key =
JsEngine().runCode("this['temp'].key") ??
(throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") ??
var version =
JsEngine().runCode("this['temp'].version") ??
(throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url");
if (minAppVersion != null) {
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException(
"minAppVersion @version is required"
.tlParams({"version": minAppVersion}),
"minAppVersion @version is required".tlParams({
"version": minAppVersion,
}),
);
}
}
@@ -175,8 +184,10 @@ class ComicSourceParser {
}
bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined");
return JsEngine().runCode(
"ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined",
);
}
dynamic _getValue(String index) {
@@ -277,16 +288,24 @@ class ComicSourceParser {
if (type == "singlePageWithMultiPart") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(List.from(res.keys
.map((e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null))
.toList()));
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load()",
);
return Res(
List.from(
res.keys
.map(
(e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null,
),
)
.toList(),
),
);
} catch (e, s) {
Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString());
@@ -297,11 +316,15 @@ class ComicSourceParser {
loadPage = (int page) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -311,10 +334,13 @@ class ComicSourceParser {
loadNext = (next) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -326,8 +352,9 @@ class ComicSourceParser {
} else if (type == "multiPartPage") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load()",
);
return Res(
List.from(
(res as List).map((e) {
@@ -350,19 +377,22 @@ class ComicSourceParser {
loadMixed = (index) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
);
var list = <Object>[];
for (var data in (res['data'] as List)) {
if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) {
list.add(ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
));
list.add(
ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
),
);
}
}
return Res(list, subData: res['maxPage']);
@@ -372,21 +402,25 @@ class ComicSourceParser {
}
};
}
pages.add(ExplorePageData(
title,
switch (type) {
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed,
_ =>
throw ComicSourceParseException("Unknown explore page type $type")
},
loadPage,
loadNext,
loadMultiPart,
loadMixed,
));
pages.add(
ExplorePageData(
title,
switch (type) {
"singlePageWithMultiPart" =>
ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed,
_ => throw ComicSourceParseException(
"Unknown explore page type $type",
),
},
loadPage,
loadNext,
loadMultiPart,
loadMixed,
),
);
}
return pages;
}
@@ -426,18 +460,17 @@ class ComicSourceParser {
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1));
categoryParts.add(
RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
);
} else if (type == "dynamic" && categories == null) {
var loader = c["loader"];
if (loader is! JSInvokable) {
throw "DynamicCategoryPart loader must be a function";
}
categoryParts.add(DynamicCategoryPart(
name,
JSAutoFreeFunction(loader),
_key!,
));
categoryParts.add(
DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
);
}
} else {
// old format
@@ -454,30 +487,16 @@ class ComicSourceParser {
for (int i = 0; i < tags.length; i++) {
PageJumpTarget target;
if (itemType == 'category') {
target = PageJumpTarget(
_key!,
'category',
{
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
},
);
target = PageJumpTarget(_key!, 'category', {
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
});
} else if (itemType == 'search') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": tags[i],
},
);
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
} else if (itemType == 'search_with_namespace') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": "$name:$tags[i]",
},
);
target = PageJumpTarget(_key!, 'search', {
"keyword": "$name:$tags[i]",
});
} else {
target = PageJumpTarget(_key!, itemType, null);
}
@@ -486,38 +505,96 @@ class ComicSourceParser {
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
categoryParts.add(
RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
);
}
}
}
return CategoryData(
title: title,
categories: categoryParts,
enableRankingPage: enableRankingPage ?? false,
key: title);
title: title,
categories: categoryParts,
enableRankingPage: enableRankingPage ?? false,
key: title,
);
}
CategoryComicsData? _loadCategoryComicsData() {
if (!_checkExists("categoryComics")) return null;
var options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) {
if (option.isEmpty || !option.contains("-")) {
continue;
List<CategoryComicsOptions>? options;
if (_checkExists("categoryComics.optionList")) {
options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) {
if (option.isEmpty || !option.contains("-")) {
continue;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
options.add(
CategoryComicsOptions(
element["label"] ?? "",
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]),
),
);
}
options.add(CategoryComicsOptions(
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"])));
}
CategoryOptionsLoader? optionLoader;
if (_checkExists("categoryComics.optionLoader")) {
optionLoader = (category, param) async {
try {
dynamic res = JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.optionLoader(
${jsonEncode(category)}, ${jsonEncode(param)})
""");
if (res is Future) {
res = await res;
}
if (res is! List) {
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}");
}
var options = <CategoryComicsOptions>[];
for (var element in res) {
if (element is! Map) {
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}");
}
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"] ?? []) {
if (option.isEmpty || !option.contains("-")) {
continue;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
}
options.add(
CategoryComicsOptions(
element["label"] ?? "",
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]),
),
);
}
return Res(options);
}
catch(e) {
Log.error("Data Analysis", "Failed to load category options.\n$e");
return Res.error(e.toString());
}
};
}
RankingData? rankingData;
if (_checkExists("categoryComics.ranking")) {
var options = <String, String>{};
@@ -532,7 +609,7 @@ class ComicSourceParser {
}
Future<Res<List<Comic>>> Function(String option, int page)? load;
Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
loadWithNext;
if (_checkExists("categoryComics.ranking.load")) {
load = (option, page) async {
try {
@@ -541,9 +618,12 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(page)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -557,8 +637,10 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(next)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -569,25 +651,38 @@ class ComicSourceParser {
}
rankingData = RankingData(options, load, loadWithNext);
}
return CategoryComicsData(options, (category, param, options, page) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
}, rankingData: rankingData);
if (options == null && optionLoader == null) {
options = [];
}
return CategoryComicsData(
options: options,
optionsLoader: optionLoader,
load: (category, param, options, page) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
return Res(
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
rankingData: rankingData,
);
}
SearchPageData? _loadSearchData() {
@@ -604,12 +699,14 @@ class ComicSourceParser {
var value = split.join("-");
map[key] = value;
}
options.add(SearchOptions(
map,
element["label"],
element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']),
));
options.add(
SearchOptions(
map,
element["label"],
element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']),
),
);
}
SearchFunction? loadPage;
@@ -624,9 +721,12 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -640,8 +740,10 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -690,8 +792,9 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic =
_getValue("favorites.singleFolderForSingleComic");
final bool? singleFolderForSingleComic = _getValue(
"favorites.singleFolderForSingleComic",
);
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -744,9 +847,12 @@ class ComicSourceParser {
${jsonEncode(page)}, ${jsonEncode(folder)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -766,8 +872,10 @@ class ComicSourceParser {
${jsonEncode(next)}, ${jsonEncode(folder)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -858,8 +966,9 @@ class ComicSourceParser {
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
""");
return Res(
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"]);
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -1114,7 +1223,8 @@ class ComicSourceParser {
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
""");
return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());

View File

@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
class ComicUpdateResult {
final bool updated;
final String? errorMessage;
ComicUpdateResult(this.updated, this.errorMessage);
}
Future<ComicUpdateResult> updateComic(
FavoriteItemWithUpdateInfo c, String folder) async {
int retries = 3;
while (true) {
try {
var comicSource = c.type.comicSource;
if (comicSource == null) {
return ComicUpdateResult(false, "Comic source not found");
}
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item, false);
var updated = false;
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
updated = true;
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
return ComicUpdateResult(updated, null);
} catch (e, s) {
Log.error("Check Updates", e, s);
retries--;
if (retries == 0) {
return ComicUpdateResult(false, e.toString());
}
}
}
}
class UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
final FavoriteItemWithUpdateInfo? comic;
final String? errorMessage;
UpdateProgress(this.total, this.current, this.errors, this.updated,
[this.comic, this.errorMessage]);
}
void updateFolderBase(
String folder,
StreamController<UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int total = comics.length;
int current = 0;
int errors = 0;
int updated = 0;
stream.add(UpdateProgress(total, current, errors, updated));
var comicsToUpdate = <FavoriteItemWithUpdateInfo>[];
for (var comic in comics) {
if (!ignoreCheckTime) {
var lastCheckTime = comic.lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(UpdateProgress(total, current, errors, updated));
continue;
}
}
comicsToUpdate.add(comic);
}
total = comicsToUpdate.length;
current = 0;
stream.add(UpdateProgress(total, current, errors, updated));
var futures = <Future>[];
for (var comic in comicsToUpdate) {
var future = updateComic(comic, folder).then((result) {
current++;
if (result.updated) {
updated++;
}
if (result.errorMessage != null) {
errors++;
}
stream.add(
UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
});
futures.add(future);
}
await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
Stream<UpdateProgress> updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<UpdateProgress>();
updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
Future<String> getUpdatedComicsAsJson(String folder) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
var updatedComics = comics.where((c) => c.hasNewUpdate).toList();
var jsonList = updatedComics.map((c) => {
'id': c.id,
'name': c.name,
'coverUrl': c.coverPath,
'author': c.author,
'type': c.type.sourceKey,
'updateTime': c.updateTime,
'tags': c.tags,
}).toList();
return jsonEncode(jsonList);
}

View File

@@ -1,6 +1,8 @@
import 'dart:async' show Future;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart';
@@ -11,7 +13,12 @@ class CachedImageProvider
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
const CachedImageProvider(this.url, {
this.headers,
this.sourceKey,
this.cid,
this.fallbackToLocalCover = false,
});
final String url;
@@ -21,6 +28,9 @@ class CachedImageProvider
final String? cid;
// Use local cover if network image fails to load.
final bool fallbackToLocalCover;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@@ -49,6 +59,24 @@ class CachedImageProvider
}
throw "Error: Empty response body.";
}
catch(e) {
if (fallbackToLocalCover && sourceKey != null && cid != null) {
final localComic = LocalManager().find(
cid!,
ComicType.fromKey(sourceKey!),
);
if (localComic != null) {
var file = localComic.coverFile;
if (await file.exists()) {
var data = await file.readAsBytes();
if (data.isNotEmpty) {
return data;
}
}
}
}
rethrow;
}
finally {
loadingCount--;
}

View File

@@ -24,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/js_pool.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart';
@@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, validateStatus: (status) => true));
}
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override
@protected
Future<void> doInit() async {
@@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return;
}
try {
if (App.isInitialized) {
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
}
_dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!;
_closed = false;
_engine = FlutterQjs();
_engine!.dispatch();
@@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js");
Uint8List jsInit;
if (_jsInitCache != null) {
jsInit = _jsInitCache!;
} else {
var buffer = await rootBundle.load("assets/init.js");
jsInit = buffer.buffer.asUint8List();
}
_engine!
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
.evaluate(utf8.decode(jsInit), name: "<init>");
} catch (e, s) {
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
}
@@ -97,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
Object? _messageReceiver(dynamic message) {
try {
if (message is Map<dynamic, dynamic>) {
if (message["method"] == null) return null;
String method = message["method"] as String;
switch (method) {
case "log":
@@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text;
});
case "compute":
final func = message["function"];
final args = message["args"];
if (func is JSInvokable) {
func.free();
throw "Function must be a string";
}
if (func is! String) {
throw "Function must be a string";
}
if (args != null && args is! List) {
throw "Args must be a list";
}
return JSPool().execute(func, args ?? []);
}
}
return null;

163
lib/foundation/js_pool.dart Normal file
View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/log.dart';
class JSPool {
static final int _maxInstances = 4;
final List<IsolateJsEngine> _instances = [];
bool _isInitializing = false;
static final JSPool _singleton = JSPool._internal();
factory JSPool() {
return _singleton;
}
JSPool._internal();
Future<void> init() async {
if (_isInitializing) return;
_isInitializing = true;
var jsInitBuffer = await rootBundle.load("assets/init.js");
var jsInit = jsInitBuffer.buffer.asUint8List();
for (int i = 0; i < _maxInstances; i++) {
_instances.add(IsolateJsEngine(jsInit));
}
_isInitializing = false;
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
await init();
var selectedInstance = _instances[0];
for (var instance in _instances) {
if (instance.pendingTasks < selectedInstance.pendingTasks) {
selectedInstance = instance;
}
}
return selectedInstance.execute(jsFunction, args);
}
}
class _IsolateJsEngineInitParam {
final SendPort sendPort;
final Uint8List jsInit;
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
}
class IsolateJsEngine {
Isolate? _isolate;
SendPort? _sendPort;
ReceivePort? _receivePort;
int _counter = 0;
final Map<int, Completer<dynamic>> _tasks = {};
bool _isClosed = false;
int get pendingTasks => _tasks.length;
IsolateJsEngine(Uint8List jsInit) {
_receivePort = ReceivePort();
_receivePort!.listen(_onMessage);
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
}
void _onMessage(dynamic message) {
if (message is SendPort) {
_sendPort = message;
} else if (message is TaskResult) {
final completer = _tasks.remove(message.id);
if (completer != null) {
if (message.error != null) {
completer.completeError(message.error!);
} else {
completer.complete(message.result);
}
}
} else if (message is Exception) {
Log.error("IsolateJsEngine", message.toString());
for (var completer in _tasks.values) {
completer.completeError(message);
}
_tasks.clear();
close();
}
}
static void _run(_IsolateJsEngineInitParam params) async {
var sendPort = params.sendPort;
final port = ReceivePort();
sendPort.send(port.sendPort);
final engine = JsEngine();
try {
JsEngine.cacheJsInit(params.jsInit);
await engine.init();
}
catch(e, s) {
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
return;
}
await for (final message in port) {
if (message is Task) {
try {
final jsFunc = engine.runCode(message.jsFunction);
if (jsFunc is! JSInvokable) {
throw Exception("The provided code does not evaluate to a function.");
}
final result = jsFunc.invoke(message.args);
jsFunc.free();
sendPort.send(TaskResult(message.id, result, null));
} catch (e) {
sendPort.send(TaskResult(message.id, null, e.toString()));
}
}
}
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
if (_isClosed) {
throw Exception("IsolateJsEngine is closed.");
}
while (_sendPort == null) {
await Future.delayed(const Duration(milliseconds: 10));
}
final completer = Completer<dynamic>();
final taskId = _counter++;
_tasks[taskId] = completer;
final task = Task(taskId, jsFunction, args);
_sendPort?.send(task);
return completer.future;
}
void close() async {
if (!_isClosed) {
_isClosed = true;
while (_tasks.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 100));
}
_receivePort?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
}
}
}
class Task {
final int id;
final String jsFunction;
final List<dynamic> args;
const Task(this.id, this.jsFunction, this.args);
}
class TaskResult {
final int id;
final Object? result;
final String? error;
const TaskResult(this.id, this.result, this.error);
}

View File

@@ -28,6 +28,8 @@ class Log {
static bool ignoreLimitation = false;
static bool isMuted = false;
static void printWarning(String text) {
debugPrint('\x1B[33m$text\x1B[0m');
}
@@ -39,7 +41,8 @@ class Log {
static IOSink? _file;
static void addLog(LogLevel level, String title, String content) {
if (_file == null) {
if (isMuted) return;
if (_file == null && App.isInitialized) {
Directory dir;
if (App.isAndroid) {
dir = Directory(App.externalStoragePath!);

244
lib/headless.dart Normal file
View File

@@ -0,0 +1,244 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/init.dart';
import 'package:venera/foundation/follow_updates.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
void cliPrint(Map<String, dynamic> data) {
print('[CLI PRINT] ${jsonEncode(data)}');
}
Future<void> runHeadlessMode(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
if (args.contains('--ignore-disheadless-log')) {
Log.isMuted = true;
}
if(Platform.isLinux || Platform.isMacOS){
Directory.current = Platform.environment['HOME']!;
}
// The first arg is '--headless', so we look at the next ones.
var commandIndex = args.indexOf('--headless') + 1;
if (commandIndex >= args.length) {
cliPrint({'status': 'error', 'message': 'No command provided for headless mode.'});
exit(1);
}
// Need to initialize the app for some features to work
await init();
var command = args[commandIndex];
var subCommand = (commandIndex + 1 < args.length) ? args[commandIndex + 1] : null;
switch (command) {
case 'webdav':
if (subCommand == 'up') {
cliPrint({'status': 'running', 'message': 'Uploading WebDAV data...'});
await DataSync().uploadData();
cliPrint({'status': 'success', 'message': 'Upload complete.'});
} else if (subCommand == 'down') {
cliPrint({'status': 'running', 'message': 'Downloading WebDAV data...'});
await DataSync().downloadData();
cliPrint({'status': 'success', 'message': 'Download complete.'});
} else {
cliPrint({'status': 'error', 'message': 'Invalid webdav command. Use "up" or "down".'});
exit(1);
}
break;
case 'updatescript':
if (subCommand == 'all') {
cliPrint({'status': 'running', 'message': 'Checking for comic source script updates...'});
await ComicSourcePage.checkComicSourceUpdate();
var updates = ComicSourceManager().availableUpdates;
if (updates.isEmpty) {
cliPrint({'status': 'success', 'message': 'No updates found.'});
} else {
var total = updates.length;
var current = 0;
var errors = 0;
var updated = 0;
cliPrint({
'status': 'running',
'message': 'Updating all comic source scripts...',
'data': {
'total': total,
'current': 0,
'updated': 0,
'errors': 0,
}
});
for (var key in updates.keys) {
var source = ComicSource.find(key);
if (source != null) {
current++;
var data = {
'current': current,
'total': total,
'source': {
'key': source.key,
'name': source.name,
'version': source.version,
'url': source.url,
}
};
try {
await ComicSourcePage.update(source, false);
updated++;
cliPrint({
'status': 'running',
'message': 'Progress',
'data': data,
});
} catch (e) {
errors++;
cliPrint({
'status': 'running',
'message': 'ProgressError',
'data': {
...data,
'error': e.toString(),
},
});
}
}
}
cliPrint({
'status': 'success',
'message': 'All scripts updated.',
'data': {
'total': total,
'updated': updated,
'errors': errors,
}
});
}
} else {
cliPrint({'status': 'error', 'message': 'Invalid updatescript command. Use "all".'});
exit(1);
}
break;
case 'updatesubscribe':
cliPrint({'status': 'running', 'message': 'Updating subscribed comics...'});
var folder = appdata.settings["followUpdatesFolder"];
if (folder == null) {
cliPrint({'status': 'error', 'message': 'Follow updates folder is not configured.'});
exit(1);
}
var updateIndex = args.indexOf('--update-comic-by-id-type');
if (updateIndex != -1) {
var id = args[updateIndex + 1];
var type = args[updateIndex + 2];
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
var comic = comics.firstWhere((c) => c.id == id && c.type.sourceKey == type);
var result = await updateComic(comic, folder);
Map<String, dynamic> data = {
'current': 1,
'total': 1,
'comic': {
'id': comic.id,
'name': comic.name,
'coverUrl': comic.coverPath,
'author': comic.author,
'type': comic.type.sourceKey,
'updateTime': comic.updateTime,
'tags': comic.tags,
}
};
var message = 'Progress';
if (result.errorMessage != null) {
message = 'ProgressError';
data['error'] = result.errorMessage;
}
cliPrint({
'status': 'running',
'message': message,
'data': data,
});
cliPrint({
'status': 'running',
'message': 'Update check complete.',
'data': {
'total': 1,
'updated': result.updated ? 1 : 0,
'errors': result.errorMessage != null ? 1 : 0,
}
});
await Future.delayed(const Duration(milliseconds: 500));
var json = await getUpdatedComicsAsJson(folder);
cliPrint({
'status': result.errorMessage != null ? 'error' : 'success',
'message': 'Updated comics list.',
'data': jsonDecode(json),
});
} else {
int total = 0;
int updated = 0;
int errors = 0;
await for (var progress in updateFolder(folder, true)) {
total = progress.total;
updated = progress.updated;
errors = progress.errors;
Map<String, dynamic> data = {
'current': progress.current,
'total': progress.total,
};
if (progress.comic != null) {
data['comic'] = {
'id': progress.comic!.id,
'name': progress.comic!.name,
'coverUrl': progress.comic!.coverPath,
'author': progress.comic!.author,
'type': progress.comic!.type.sourceKey,
'updateTime': progress.comic!.updateTime,
'tags': progress.comic!.tags,
};
}
var message = 'Progress';
if (progress.errorMessage != null) {
message = 'ProgressError';
data['error'] = progress.errorMessage;
}
cliPrint({
'status': 'running',
'message': message,
'data': data,
});
}
cliPrint({
'status': 'running',
'message': 'Update check complete.',
'data': {
'total': total,
'updated': updated,
'errors': errors,
}
});
await Future.delayed(const Duration(milliseconds: 500));
var json = await getUpdatedComicsAsJson(folder);
cliPrint({
'status': errors > 0 ? 'error' : 'success',
'message': 'Updated comics list.',
'data': jsonDecode(json),
});
}
break;
default:
cliPrint({'status': 'error', 'message': 'Unknown command: $command'});
exit(1);
}
// Exit after command execution
exit(0);
}

View File

@@ -37,17 +37,21 @@ extension _FutureInit<T> on Future<T> {
Future<void> init() async {
await App.init().wait();
await SingleInstanceCookieJar.createInstance();
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
OpenCC.init(),
];
await Future.wait(futures);
try {
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
OpenCC.init(),
];
await Future.wait(futures);
} catch (e, s) {
Log.error("init", "$e\n$s");
}
CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {

View File

@@ -14,9 +14,14 @@ import 'components/components.dart';
import 'components/window_frame.dart';
import 'foundation/app.dart';
import 'foundation/appdata.dart';
import 'headless.dart';
import 'init.dart';
void main(List<String> args) {
if (args.contains('--headless')) {
runHeadlessMode(args);
return;
}
if (runWebViewTitleBarWidget(args)) return;
overrideIO(() {
runZonedGuarded(() async {
@@ -243,7 +248,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 50;
if (isPaddingCheckError) {
if (isPaddingCheckError && Platform.isAndroid) {
widget = MediaQuery(
data: MediaQuery.of(context).copyWith(
viewPadding: const EdgeInsets.only(

View File

@@ -112,10 +112,12 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter();
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
if (App.isInitialized) {
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
}
static final Map<String, bool> _requests = {};
@@ -173,6 +175,7 @@ class RHttpAdapter implements HttpClientAdapter {
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
verifyCertificates: appdata.settings['ignoreBadCertificate'] != true,
),
);
}

View File

@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async {
static Future<SingleInstanceCookieJar> createInstance() async {
if (instance != null) {
return instance!;
}
var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
return instance!;
}
}

View File

@@ -71,7 +71,8 @@ abstract class ImageDownloader {
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
final uint8List = Uint8List.fromList(buffer);
buffer = (configs['onResponse'] as JSInvokable)([uint8List]);
(configs['onResponse'] as JSInvokable).free();
}
@@ -180,12 +181,17 @@ abstract class ImageDownloader {
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
(configs['onResponse'] as JSInvokable).free();
}
var data = Uint8List.fromList(buffer);
buffer.clear();
Uint8List data;
if (buffer is Uint8List) {
data = buffer;
} else {
data = Uint8List.fromList(buffer);
buffer.clear();
}
if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript(

View File

@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
class _CategoryComicsPageState extends State<CategoryComicsPage> {
late final CategoryComicsData data;
late final List<CategoryComicsOptions> options;
late List<CategoryComicsOptions>? options;
late final CategoryOptionsLoader? optionsLoader;
late List<String> optionsValue;
late String sourceKey;
String? error;
void findData() {
for (final source in ComicSource.all()) {
@@ -38,24 +40,23 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "The comic source ${source.name} does not support category comics";
}
data = source.categoryComicsData!;
options = data.options.where((element) {
if (element.notShowWhen.contains(widget.category)) {
return false;
} else if (element.showWhen != null) {
return element.showWhen!.contains(widget.category);
}
return true;
}).toList();
var defaultOptionsValue =
options.map((e) => e.options.keys.first).toList();
if (optionsValue.length != options.length) {
var newOptionsValue = List<String>.filled(options.length, "");
for (var i = 0; i < options.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
if (data.options != null) {
options = data.options!.where((element) {
if (element.notShowWhen.contains(widget.category)) {
return false;
} else if (element.showWhen != null) {
return element.showWhen!.contains(widget.category);
}
return true;
}).toList();
} else {
options = null;
}
if (data.optionsLoader != null) {
optionsLoader = data.optionsLoader;
loadOptions();
}
resetOptionsValue();
sourceKey = source.key;
return;
}
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "${widget.categoryKey} Not found";
}
void resetOptionsValue() {
if (options == null) return;
var defaultOptionsValue = options!
.map((e) => e.options.keys.first)
.toList();
if (optionsValue.length != options!.length) {
var newOptionsValue = List<String>.filled(options!.length, "");
for (var i = 0; i < options!.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
}
}
void loadOptions() async {
final res = await optionsLoader!(widget.category, widget.param);
if (res.error) {
setState(() {
error = res.errorMessage;
});
} else {
setState(() {
options = res.data;
resetOptionsValue();
error = null;
});
}
}
@override
void initState() {
if (widget.options != null) {
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override
Widget build(BuildContext context) {
var topPadding = context.padding.top + 56.0;
Widget body;
if (options == null) {
body = Center(child: CircularProgressIndicator());
} else if (error != null) {
body = NetworkError(
message: error!,
retry: () {
setState(() {
error = null;
});
loadOptions();
},
);
} else {
body = ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: buildOptions().paddingTop(topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) =>
data.load(widget.category, widget.param, optionsValue, i),
);
}
return Scaffold(
extendBodyBehindAppBar: true,
appBar: Appbar(
title: Text(widget.category),
),
body: ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: SizedBox(height: topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) => data.load(
widget.category,
widget.param,
optionsValue,
i,
),
),
appBar: Appbar(title: Text(widget.category)),
body: body,
);
}
Widget buildOptionItem(
String text, String value, int group, BuildContext context) {
String text,
String value,
int group,
BuildContext context,
) {
return OptionChip(
text: text.ts(sourceKey),
isSelected: value == optionsValue[group],
@@ -112,23 +160,57 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
Widget buildOptions() {
List<Widget> children = [];
for (var optionList in options) {
children.add(Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var option in optionList.options.entries)
buildOptionItem(
option.value.tl,
option.key,
options.indexOf(optionList),
context,
)
],
));
if (options.last != optionList) {
var group = 0;
for (var optionList in options!) {
if (optionList.label.isNotEmpty) {
children.add(Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
left: 4.0,
),
child: Text(
optionList.label.ts(sourceKey),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
));
}
if (optionList.options.length <= 8) {
children.add(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var option in optionList.options.entries)
buildOptionItem(
option.value.tl,
option.key,
group,
context,
),
],
),
);
} else {
var g = group;
children.add(Select(
current: optionList.options[optionsValue[g]],
values: optionList.options.values.toList(),
onTap: (i) {
var key = optionList.options.keys.elementAt(i);
if (key == optionsValue[g]) return;
setState(() {
optionsValue[g] = key;
});
},
));
}
if (options!.last != optionList) {
children.add(const SizedBox(height: 8));
}
group++;
}
return Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -77,8 +77,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override
void onReadEnd() {
history ??=
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
history ??= HistoryManager().find(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
update();
}
@@ -93,6 +95,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
);
}
@override
Widget buildError() {
final isDownloaded = LocalManager().isDownloaded(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
Widget? action;
if (isDownloaded) {
action = FilledButton.tonal(
child: Text("Read".tl),
onPressed: () {
final localComic = LocalManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
if (localComic == null) {
context.showMessage(message: "Local comic not found".tl);
return;
}
localComic.read();
},
);
}
return NetworkError(message: error!, retry: retry, action: action);
}
@override
void initState() {
scrollController.addListener(onScroll);
@@ -114,7 +142,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ComicDetails get comic => data!;
void onScroll() {
var offset = scrollController.position.pixels -
var offset =
scrollController.position.pixels -
scrollController.position.minScrollExtent;
var showFAB = offset > 0;
if (showFAB != this.showFAB) {
@@ -145,9 +174,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
floatingActionButton: showFAB
? FloatingActionButton(
onPressed: () {
scrollController.animateTo(0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease);
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
child: const Icon(Icons.arrow_upward),
)
@@ -164,7 +195,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildThumbnails(),
buildRecommend(),
SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
padding: EdgeInsets.only(
bottom: context.padding.bottom + 80,
), // Add additional padding for FAB
),
],
),
@@ -190,12 +223,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
initialPage: history?.page,
initialChapter: history?.ep,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: localComic,
ep: 0,
page: 0,
),
history:
history ??
History.fromModel(model: localComic, ep: 0, page: 0),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
@@ -215,8 +245,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history =
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
history = HistoryManager().find(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
return comicSource.loadComicInfo!(widget.id);
}
@@ -225,11 +257,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) {
isDownloaded = LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
);
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
}
}
@@ -242,7 +270,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
onPressed: showMoreActions,
icon: const Icon(Icons.more_horiz),
),
],
);
@@ -288,8 +318,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
SelectableText(
comic.subTitle!,
style: ts.s14,
).paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
@@ -338,10 +370,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: isLiked,
text: ((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
text:
((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
@@ -383,9 +416,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
Expanded(
child: hasHistory
? FilledButton(
onPressed: continueRead, child: Text("Continue".tl))
onPressed: continueRead,
child: Text("Continue".tl),
)
: FilledButton(onPressed: read, child: Text("Read".tl)),
)
),
],
).paddingHorizontal(16).paddingVertical(8),
if (history != null)
@@ -412,19 +447,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
var epName = "E$ep";
String? groupName;
try {
if (group == null){
if (group == null) {
epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
);
} else {
groupName = comic.chapters!.groups.elementAt(group - 1);
groupName = comic.chapters!.groups.elementAt(
group - 1,
);
epName = comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
}
}
catch(e) {
} catch (e) {
// ignore
}
text = groupName == null
@@ -453,9 +489,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
ListTile(title: Text("Description".tl)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!).fixWidth(double.infinity),
@@ -539,10 +573,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
child: Text(text).padding(padding),
);
}
@@ -552,13 +583,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
return DateTime.fromMillisecondsSinceEpoch(
t,
).toString().substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
return DateTime.fromMillisecondsSinceEpoch(
t * 1000,
).toString().substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
@@ -583,17 +614,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
ListTile(title: Text("Information".tl)),
if (comic.stars != null)
Row(
children: [
StarRating(
value: comic.stars!,
size: 24,
onTap: starRating,
),
StarRating(value: comic.stars!, size: 24, onTap: starRating),
const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)),
],
@@ -671,24 +696,19 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.recommend == null || comic.recommend!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: ListTile(title: Text("Related".tl))),
SliverGridComics(comics: comic.recommend!),
],
);
}
Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _CommentsPart(
comments: comic.comments!,
showMore: showComments,
);
return _CommentsPart(comments: comic.comments!, showMore: showComments);
}
}
@@ -792,20 +812,21 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
itemCount: widget.eps.length,
itemBuilder: (context, i) {
return CheckboxListTile(
title: Text(widget.eps[i]),
value: selected.contains(i) ||
widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
title: Text(widget.eps[i]),
value:
selected.contains(i) || widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
},
);
},
),
),
@@ -813,9 +834,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
height: 50,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
),
top: BorderSide(color: context.colorScheme.outlineVariant),
),
),
child: Row(
@@ -880,8 +899,12 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height,
{Color? color, double? radius}) {
Widget buildContainer(
double? width,
double? height, {
Color? color,
double? radius,
}) {
return Container(
height: height,
width: width,
@@ -923,13 +946,9 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
if (context.width < changePoint)
Row(
children: [
Expanded(
child: buildContainer(null, 36, radius: 18),
),
Expanded(child: buildContainer(null, 36, radius: 18)),
const SizedBox(width: 16),
Expanded(
child: buildContainer(null, 36, radius: 18),
),
Expanded(child: buildContainer(null, 36, radius: 18)),
],
).paddingHorizontal(16),
const Divider(),
@@ -938,7 +957,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
child: CircularProgressIndicator(
strokeWidth: 2.4,
).fixHeight(24).fixWidth(24),
)
),
],
),
);
@@ -948,11 +967,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
Widget child;
if (cover != null) {
child = AnimatedImage(
image: CachedImageProvider(
cover!,
sourceKey: sourceKey,
cid: cid,
),
image: CachedImageProvider(cover!, sourceKey: sourceKey, cid: cid),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,

View File

@@ -18,6 +18,57 @@ import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatelessWidget {
const ComicSourcePage({super.key});
static Future<void> update(
ComicSource source, [
bool showLoading = true,
]) async {
if (!source.url.isURL) {
if (showLoading) {
App.rootContext.showMessage(message: "Invalid url config");
return;
} else {
throw Exception("Invalid url config");
}
}
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
}
try {
var res = await AppDio().get<String>(
source.url,
options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
);
if (cancel) return;
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await io.File(source.filePath).writeAsString(res.data!);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
if (showLoading) {
App.rootContext.showMessage(message: e.toString());
} else {
rethrow;
}
}
await ComicSourceManager().reload();
if (showLoading) {
App.forceRebuild();
}
}
static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) {
return 0;
@@ -152,42 +203,8 @@ class _BodyState extends State<_Body> {
);
}
static Future<void> update(
ComicSource source, [
bool showLoading = true,
]) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
}
try {
var res = await AppDio().get<String>(
source.url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return;
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSourceManager().reload();
App.forceRebuild();
void update(ComicSource source, [bool showLoading = true]) {
ComicSourcePage.update(source, showLoading);
}
Widget buildCard(BuildContext context) {
@@ -287,7 +304,10 @@ class _BodyState extends State<_Body> {
try {
var res = await AppDio().get<String>(
url,
options: Options(responseType: ResponseType.plain),
options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
);
if (cancel) return;
controller.close();
@@ -679,7 +699,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await _BodyState.update(source, false);
await ComicSourcePage.update(source, false);
current++;
loadingController.setProgress(current / total);
}
@@ -693,11 +713,13 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
@override
Widget build(BuildContext context) {
return FilledButton.tonalIcon(
icon: isLoading ? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
) : Icon(Icons.update),
icon: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.update),
label: Text("Check updates".tl),
onPressed: check,
);

View File

@@ -512,6 +512,18 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
);
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
text: "Read".tl,
onClick: () {
final c = selectedComics.keys.first as FavoriteItem;
App.rootContext.to(() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
));
},
),
]),
],
)

View File

@@ -5,10 +5,10 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart';
import 'package:venera/foundation/follow_updates.dart';
class FollowUpdatesWidget extends StatefulWidget {
const FollowUpdatesWidget({super.key});
@@ -460,7 +460,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
message: "Updating comics...".tl,
);
await for (var progress in _updateFolder(folder, true)) {
await for (var progress in updateFolder(folder, true)) {
if (isCanceled) {
return;
}
@@ -497,7 +497,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
int updated = 0;
await for (var progress in _updateFolder(folder!, true)) {
await for (var progress in updateFolder(folder!, true)) {
if (isCanceled) {
return;
}
@@ -532,128 +532,6 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
Object? get key => 'FollowUpdatesPage';
}
class _UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
_UpdateProgress(this.total, this.current, this.errors, this.updated);
}
void _updateFolderBase(
String folder,
StreamController<_UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int current = 0;
int errors = 0;
int updated = 0;
var futures = <Future>[];
const maxConcurrent = 5;
for (int i = 0; i < comics.length; i++) {
if (stream.isClosed) {
return;
}
if (!ignoreCheckTime) {
var lastCheckTime = comics[i].lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
continue;
}
}
if (futures.length >= maxConcurrent) {
await Future.any(futures);
}
var future = () async {
int retries = 3;
while (true) {
try {
var c = comics[i];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item, false);
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
updated++;
return;
} catch (e, s) {
Log.error("Check Updates", e, s);
retries--;
if (retries == 0) {
errors++;
return;
}
} finally {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
}
}
}();
future.then((_) {
futures.remove(future);
});
futures.add(future);
}
await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<_UpdateProgress>();
_updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool _isChecking = false;
@@ -683,7 +561,7 @@ abstract class FollowUpdatesService {
int updated = 0;
try {
await for (var progress in _updateFolder(folder, false)) {
await for (var progress in updateFolder(folder, false)) {
if (isCanceled) {
return;
}

View File

@@ -131,11 +131,11 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
}
if (context.reader.mode.key.startsWith('gallery')) {
if (forward) {
if (!context.reader.toNextPage(reader.cid, reader.type) && !context.reader.isLastChapterOfGroup) {
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
context.reader.toNextChapter();
}
} else {
if (!context.reader.toPrevPage(reader.cid, reader.type) && !context.reader.isFirstChapterOfGroup) {
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
context.reader.toPrevChapter();
}
}
@@ -209,12 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
isBottom = true;
}
bool isCenter = false;
var prev = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
var next = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
var prev = () => context.reader.toPrevPage();
var next = () => context.reader.toNextPage();
if (appdata.settings.getReaderSetting(
reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
prev = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
next = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
prev = () => context.reader.toNextPage();
next = () => context.reader.toPrevPage();
}
switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight:

View File

@@ -138,14 +138,14 @@ class _GalleryModeState extends State<_GalleryMode>
/// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page.
int get totalPages {
if (!reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
if (!reader.showSingleImageOnFirstPage()) {
return (reader.images!.length /
reader.imagesPerPage(reader.cid, reader.type))
reader.imagesPerPage())
.ceil();
} else {
return 1 +
((reader.images!.length - 1) /
reader.imagesPerPage(reader.cid, reader.type))
reader.imagesPerPage())
.ceil();
}
}
@@ -169,8 +169,8 @@ class _GalleryModeState extends State<_GalleryMode>
/// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) {
var imagesPerPage = reader.imagesPerPage(reader.cid, reader.type);
if (reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
var imagesPerPage = reader.imagesPerPage();
if (reader.showSingleImageOnFirstPage()) {
if (page == 1) {
return (0, 1);
} else {
@@ -259,7 +259,7 @@ class _GalleryModeState extends State<_GalleryMode>
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage(reader.cid, reader.type) == 1 ||
if (reader.imagesPerPage() == 1 ||
pageImages.length == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
@@ -301,11 +301,11 @@ class _GalleryModeState extends State<_GalleryMode>
onPageChanged: (i) {
if (i == 0) {
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
reader.toPage(reader.cid, reader.type, 1);
reader.toPage(1);
}
} else if (i == totalPages + 1) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(reader.cid, reader.type, totalPages);
reader.toPage(totalPages);
}
} else {
reader.setPage(i);
@@ -485,9 +485,9 @@ class _GalleryModeState extends State<_GalleryMode>
keyRepeatTimer = null;
}
if (forward == true) {
reader.toPage(reader.cid, reader.type, reader.page + 1);
reader.toPage(reader.page + 1);
} else if (forward == false) {
reader.toPage(reader.cid, reader.type, reader.page - 1);
reader.toPage(reader.page - 1);
}
}
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
@@ -500,9 +500,9 @@ class _GalleryModeState extends State<_GalleryMode>
timer.cancel();
return;
} else if (forward == true) {
reader.toPage(reader.cid, reader.type, reader.page + 1);
reader.toPage(reader.page + 1);
} else if (forward == false) {
reader.toPage(reader.cid, reader.type, reader.page - 1);
reader.toPage(reader.page - 1);
}
},
);
@@ -534,7 +534,7 @@ class _GalleryModeState extends State<_GalleryMode>
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
if (reader.imagesPerPage(reader.cid, reader.type) == 1) {
if (reader.imagesPerPage() == 1) {
imageKey = reader.images![reader.page - 1];
} else {
for (var imageState in imageStates) {
@@ -638,27 +638,52 @@ class _ContinuousModeState extends State<_ContinuousMode>
cacheImages(page);
}
double? futurePosition;
double? _futurePosition;
void smoothTo(double offset) {
futurePosition ??= scrollController.offset;
if (futurePosition! > scrollController.position.maxScrollExtent &&
offset > 0) {
return;
} else if (futurePosition! < scrollController.position.minScrollExtent &&
offset < 0) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
futurePosition = futurePosition! + offset * 1.2;
futurePosition = futurePosition!.clamp(
var currentLocation = scrollController.position.pixels;
var old = _futurePosition;
_futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
final customSpeed = appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
"readerScrollSpeed",
);
if (customSpeed is num) {
k *= customSpeed;
}
_futurePosition = _futurePosition! + offset * k;
var beforeOffset = (_futurePosition! - currentLocation).abs();
_futurePosition = _futurePosition!.clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(
futurePosition!,
duration: const Duration(milliseconds: 200),
var afterOffset = (_futurePosition! - currentLocation).abs();
if (_futurePosition == old) return;
var target = _futurePosition!;
var duration = const Duration(milliseconds: 160);
if (afterOffset < beforeOffset) {
duration = duration * (afterOffset / beforeOffset);
if (duration < Duration(milliseconds: 10)) {
duration = Duration(milliseconds: 10);
}
}
scrollController
.animateTo(
_futurePosition!,
duration: duration,
curve: Curves.linear,
);
)
.then((_) {
var current = scrollController.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
}
void onPointerSignal(PointerSignalEvent event) {
@@ -787,7 +812,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
disableScroll = true;
});
}
futurePosition = null;
_futurePosition = null;
if (_isMouseScrolling) {
setState(() {
_isMouseScrolling = false;
@@ -1009,7 +1034,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
void toPage(int page) {
itemScrollController.jumpTo(index: page);
futurePosition = null;
_futurePosition = null;
}
@override

View File

@@ -115,15 +115,17 @@ class _ReaderState extends State<Reader>
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage(cid, type)) {
return (images!.length / imagesPerPage(cid, type)).ceil();
if (!showSingleImageOnFirstPage()) {
return (images!.length / imagesPerPage()).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage(cid, type)).ceil();
return 1 + ((images!.length - 1) / imagesPerPage()).ceil();
}
}
@override
ComicType get type => widget.type;
@override
String get cid => widget.cid;
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
@@ -169,7 +171,7 @@ class _ReaderState extends State<Reader>
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
handleVolumeEvent(cid, type);
handleVolumeEvent();
}
setImageCacheSize();
Future.delayed(const Duration(milliseconds: 200), () {
@@ -184,11 +186,11 @@ class _ReaderState extends State<Reader>
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInitialized) {
initImagesPerPage(cid, type, widget.initialPage ?? 1);
initImagesPerPage(widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange(cid, type);
_checkImagesPerPageChange();
}
initReaderWindow();
}
@@ -230,7 +232,7 @@ class _ReaderState extends State<Reader>
@override
Widget build(BuildContext context) {
_checkImagesPerPageChange(cid, type);
_checkImagesPerPageChange();
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
@@ -275,13 +277,13 @@ class _ReaderState extends State<Reader>
history!.page = images?.length ?? 1;
} else {
/// Record the first image of the page
if (!showSingleImageOnFirstPage(cid, type) || imagesPerPage(cid, type) == 1) {
history!.page = (page - 1) * imagesPerPage(cid, type) + 1;
if (!showSingleImageOnFirstPage() || imagesPerPage() == 1) {
history!.page = (page - 1) * imagesPerPage() + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage(cid, type) + 2;
history!.page = (page - 2) * imagesPerPage() + 2;
}
}
}
@@ -364,23 +366,27 @@ abstract mixin class _ImagePerPageHandler {
ReaderMode get mode;
void initImagesPerPage(String cid, ComicType type, int initialPage) {
_lastImagesPerPage = imagesPerPage(cid, type);
String get cid;
ComicType get type;
void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage();
_lastOrientation = isPortrait;
if (imagesPerPage(cid, type) != 1) {
if (showSingleImageOnFirstPage(cid, type)) {
page = ((initialPage - 1) / imagesPerPage(cid, type)).ceil() + 1;
if (imagesPerPage() != 1) {
if (showSingleImageOnFirstPage()) {
page = ((initialPage - 1) / imagesPerPage()).ceil() + 1;
} else {
page = (initialPage / imagesPerPage(cid, type)).ceil();
page = (initialPage / imagesPerPage()).ceil();
}
}
}
bool showSingleImageOnFirstPage(String cid, ComicType type) =>
bool showSingleImageOnFirstPage() =>
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
/// The number of images displayed on one screen
int imagesPerPage(String cid, ComicType type) {
int imagesPerPage() {
if (mode.isContinuous) return 1;
if (isPortrait) {
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
@@ -390,23 +396,21 @@ abstract mixin class _ImagePerPageHandler {
}
/// Check if the number of images per page has changed
void _checkImagesPerPageChange(String cid, ComicType type) {
int currentImagesPerPage = imagesPerPage(cid, type);
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage();
bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(
cid, type, _lastImagesPerPage, currentImagesPerPage);
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
String cid, ComicType type, int oldImagesPerPage, int newImagesPerPage) {
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = 1;
if (!showSingleImageOnFirstPage(cid, type) || oldImagesPerPage == 1) {
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
} else {
if (page == 1) {
@@ -418,7 +422,7 @@ abstract mixin class _ImagePerPageHandler {
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage(cid, type)) {
if (showSingleImageOnFirstPage()) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
@@ -432,9 +436,9 @@ abstract mixin class _ImagePerPageHandler {
}
abstract mixin class _VolumeListener {
bool toNextPage(String cid, ComicType type);
bool toNextPage();
bool toPrevPage(String cid, ComicType type);
bool toPrevPage();
bool toNextChapter();
@@ -442,19 +446,19 @@ abstract mixin class _VolumeListener {
VolumeListener? volumeListener;
void onDown(String cid, ComicType type) {
if (!toNextPage(cid, type)) {
void onDown() {
if (!toNextPage()) {
toNextChapter();
}
}
void onUp(String cid, ComicType type) {
if (!toPrevPage(cid, type)) {
void onUp() {
if (!toPrevPage()) {
toPrevChapter();
}
}
void handleVolumeEvent(String cid, ComicType type) {
void handleVolumeEvent() {
if (!App.isAndroid) {
// Currently only support Android
return;
@@ -463,8 +467,8 @@ abstract mixin class _VolumeListener {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: () => onDown(cid, type),
onUp: () => onUp(cid, type),
onDown: onDown,
onUp: onUp,
)..listen();
}
@@ -494,6 +498,10 @@ abstract mixin class _ReaderLocation {
bool get isLoading;
String get cid;
ComicType get type;
void update();
bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
@@ -515,18 +523,18 @@ abstract mixin class _ReaderLocation {
}
/// Returns true if the page is changed
bool toNextPage(String cid, ComicType type) {
return toPage(cid, type, page + 1);
bool toNextPage() {
return toPage(page + 1);
}
/// Returns true if the page is changed
bool toPrevPage(String cid, ComicType type) {
return toPage(cid, type, page - 1);
bool toPrevPage() {
return toPage(page - 1);
}
int _animationCount = 0;
bool toPage(String cid, ComicType type, int page) {
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page && page != 1 && page != maxPage) {
return false;
@@ -582,7 +590,7 @@ abstract mixin class _ReaderLocation {
if (page == maxPage) {
autoPageTurningTimer!.cancel();
}
toNextPage(cid, type);
toNextPage();
});
}
}

View File

@@ -348,6 +348,99 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
text = "P${context.reader.page}";
}
final buttons = [
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite,
),
),
if (App.isDesktop)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
context.reader.fullscreen();
},
),
),
if (App.isAndroid)
Tooltip(
message: "Screen Rotation".tl,
child: IconButton(
icon: () {
if (rotation == null) {
return const Icon(Icons.screen_rotation);
} else if (rotation == false) {
return const Icon(Icons.screen_lock_portrait);
} else {
return const Icon(Icons.screen_lock_landscape);
}
}.call(),
onPressed: () {
if (rotation == null) {
setState(() {
rotation = false;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else if (rotation == false) {
setState(() {
rotation = true;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
setState(() {
rotation = null;
});
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
},
),
),
Tooltip(
message: "Auto Page Turning".tl,
child: IconButton(
icon: context.reader.autoPageTurningTimer != null
? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp),
onPressed: () {
context.reader.autoPageTurning(
context.reader.cid,
context.reader.type,
);
update();
},
),
),
if (context.reader.widget.chapters != null)
Tooltip(
message: "Chapters".tl,
child: IconButton(
icon: const Icon(Icons.library_books),
onPressed: openChapterDrawer,
),
),
Tooltip(
message: "Save Image".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: saveCurrentImage,
),
),
Tooltip(
message: "Share".tl,
child: IconButton(icon: const Icon(Icons.share), onPressed: share),
),
];
Widget child = SizedBox(
height: kBottomBarHeight,
child: Column(
@@ -360,18 +453,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onPressed: () => !isReversed
? context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(
context.reader.cid,
context.reader.type,
1,
)
: context.reader.toPage(1)
: context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(
context.reader.cid,
context.reader.type,
context.reader.maxPage,
),
: context.reader.toPage(context.reader.maxPage),
icon: const Icon(Icons.first_page),
),
Expanded(child: buildSlider()),
@@ -379,135 +464,35 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(
context.reader.cid,
context.reader.type,
context.reader.maxPage,
)
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(
context.reader.cid,
context.reader.type,
1,
),
: context.reader.toPage(1),
icon: const Icon(Icons.last_page),
),
const SizedBox(width: 8),
],
),
Row(
children: [
const SizedBox(width: 16),
Container(
height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(text)),
),
const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border,
),
onPressed: addImageFavorite,
),
),
if (App.isDesktop)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
context.reader.fullscreen();
},
),
),
if (App.isAndroid)
Tooltip(
message: "Screen Rotation".tl,
child: IconButton(
icon: () {
if (rotation == null) {
return const Icon(Icons.screen_rotation);
} else if (rotation == false) {
return const Icon(Icons.screen_lock_portrait);
} else {
return const Icon(Icons.screen_lock_landscape);
}
}.call(),
onPressed: () {
if (rotation == null) {
setState(() {
rotation = false;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else if (rotation == false) {
setState(() {
rotation = true;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
setState(() {
rotation = null;
});
SystemChrome.setPreferredOrientations(
DeviceOrientation.values,
);
}
},
),
),
Tooltip(
message: "Auto Page Turning".tl,
child: IconButton(
icon: context.reader.autoPageTurningTimer != null
? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp),
onPressed: () {
context.reader.autoPageTurning(
context.reader.cid,
context.reader.type,
);
update();
},
),
),
if (context.reader.widget.chapters != null)
Tooltip(
message: "Chapters".tl,
child: IconButton(
icon: const Icon(Icons.library_books),
onPressed: openChapterDrawer,
),
),
Tooltip(
message: "Save Image".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: saveCurrentImage,
),
),
Tooltip(
message: "Share".tl,
child: IconButton(
icon: const Icon(Icons.share),
onPressed: share,
),
),
const SizedBox(width: 4),
],
LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
if ((constrains.maxWidth - buttons.length * 42) > 80)
Container(
height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(text)),
).paddingLeft(16),
const Spacer(),
...buttons,
const SizedBox(width: 4),
],
);
},
),
],
),
@@ -545,11 +530,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) {
context.reader.toPage(
context.reader.cid,
context.reader.type,
i.toInt(),
);
context.reader.toPage(i.toInt());
},
);
}
@@ -659,10 +640,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.type.sourceKey,
key,
)) {
context.reader.handleVolumeEvent(
context.reader.cid,
context.reader.type,
);
context.reader.handleVolumeEvent();
} else {
context.reader.stopVolumeEvent();
}

View File

@@ -31,6 +31,10 @@ class DebugPageState extends State<DebugPage> {
},
actionTitle: 'Open'.tl,
).toSliver(),
_SwitchSetting(
title: "Ignore Certificate Errors".tl,
settingKey: "ignoreBadCertificate",
).toSliver(),
SliverToBoxAdapter(
child: Column(
children: [
@@ -58,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
TextButton(
onPressed: () {
try {
var res = JsEngine().runCode(controller.text);
var res = JsEngine().runCode(controller.text, "<debug>");
setState(() {
result = res.toString();
});

View File

@@ -19,17 +19,57 @@ class ReaderSettings extends StatefulWidget {
class _ReaderSettingsState extends State<ReaderSettings> {
@override
Widget build(BuildContext context) {
final comicId = widget.comicId;
final sourceKey = widget.comicSource;
final key = "$comicId@$sourceKey";
bool isEnabledSpecificSettings =
comicId != null &&
appdata.settings.isComicSpecificSettingsEnabled(comicId, sourceKey);
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Reading".tl)),
if (comicId != null && sourceKey != null)
SliverMainAxisGroup(
slivers: [
SwitchListTile(
title: Text("Enable comic specific settings".tl),
value: isEnabledSpecificSettings,
onChanged: (b) {
setState(() {
appdata.settings.setEnabledComicSpecificSettings(
comicId,
sourceKey,
b,
);
});
},
).toSliver(),
if (isEnabledSpecificSettings)
Center(
child: TextButton(
onPressed: () {
setState(() {
appdata.settings.resetComicReaderSettings(key);
});
},
child: Text(
"Clear specific reader settings for this comic".tl,
),
),
).toSliver(),
Divider().toSliver(),
],
),
_SwitchSetting(
title: "Tap to turn Pages".tl,
settingKey: "enableTapToTurnPages",
onChanged: () {
widget.onChanged?.call("enableTapToTurnPages");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Reverse tap to turn Pages".tl,
@@ -37,8 +77,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Page animation".tl,
@@ -46,15 +86,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("enablePageAnimation");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(),
_SwitchSetting(
title: "Enable comic specific settings".tl,
settingKey: "enableComicSpecificSettings",
onChanged: () {
widget.onChanged?.call("enableComicSpecificSettings");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SelectSetting(
title: "Reading mode".tl,
@@ -78,8 +111,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
}
widget.onChanged?.call("readerMode");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SliderSetting(
title: "Auto page turning interval".tl,
@@ -91,8 +124,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery'),
@@ -108,8 +141,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
@@ -125,8 +158,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
@@ -140,8 +173,23 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('continuous'),
child: _SliderSetting(
title: "Mouse scroll speed".tl,
settingsIndex: "readerScrollSpeed",
interval: 0.1,
min: 0.5,
max: 3,
onChanged: () {
widget.onChanged?.call("readerScrollSpeed");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
_SwitchSetting(
@@ -151,8 +199,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: 'Long press to zoom'.tl,
@@ -161,8 +209,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {});
widget.onChanged?.call('enableLongPressToZoom');
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['enableLongPressToZoom'] == true,
@@ -173,8 +221,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"press": "Press position".tl,
"center": "Screen center".tl,
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
_SwitchSetting(
@@ -184,8 +232,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call('limitImageWidth');
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
if (App.isAndroid)
_SwitchSetting(
@@ -194,8 +242,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey');
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Display time & battery info in reader".tl,
@@ -203,8 +251,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
@@ -212,8 +260,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SelectSetting(
title: "Quick collect image".tl,
@@ -229,8 +277,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl,
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_CallbackSetting(
title: "Custom Image Processing".tl,
@@ -243,8 +291,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
interval: 1,
min: 1,
max: 16,
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show Page Number".tl,
@@ -252,39 +300,9 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("showPageNumberInReader");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
// reset button
SliverToBoxAdapter(
child: TextButton(
onPressed: () {
if (widget.comicId == null) {
appdata.settings.resetAllComicReaderSettings();
} else {
var keys = appdata
.settings['comicSpecificSettings']["${widget.comicId}@${widget.comicSource}"]
?.keys;
appdata.settings.resetComicReaderSettings(
widget.comicId!,
widget.comicSource!,
);
if (keys != null) {
setState(() {});
for (var key in keys) {
widget.onChanged?.call(key);
}
}
}
},
child: Text(
(widget.comicId == null
? "Clear specific reader settings for all comics"
: "Clear specific reader settings for this comic")
.tl,
),
),
),
],
);
}

View File

@@ -556,26 +556,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.1"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -941,10 +941,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.6"
typed_data:
dependency: transitive
description:
@@ -1037,10 +1037,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -1116,4 +1116,4 @@ packages:
version: "0.0.12"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.32.6"
flutter: ">=3.35.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.4.6+146
version: 1.5.1+151
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.32.6
flutter: 3.35.3
dependencies:
flutter: