90 Commits

Author SHA1 Message Date
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
enximi
ce0d10aeb2 Add a feature to allow saving custom reader settings for each comic. (#459)
* Add a feature to allow saving custom reader settings for each  comic.

* Comic-specific settings disabled by default
2025-08-10 16:02:44 +08:00
角砂糖
0ac857ef9a Temp solution for hyper os multi window display issue (#467)
Temp solution for hyper os multi window display
2025-08-10 16:02:00 +08:00
3928f5afe7 Improve smooth scroll. Close #462 2025-08-03 17:05:31 +08:00
8a61a4750b Add avif format. 2025-08-03 16:40:25 +08:00
nyne
1bc3fef47b Fix workflow 2025-07-23 15:36:12 +08:00
nyne
4dac132bee Remove appimage. 2025-07-23 15:07:28 +08:00
nyne
7c60c00962 Merge pull request #454 from venera-app/v1.4.6-dev
V1.4.6
2025-07-23 14:38:42 +08:00
9d8ade6fe0 Add log export functionality. 2025-07-23 14:35:27 +08:00
6245399810 Improve UI of comic source page. 2025-07-23 14:28:40 +08:00
c074e7f9d1 Add default source list url. 2025-07-23 14:16:53 +08:00
f822e198ea Update version code. 2025-07-22 17:55:51 +08:00
7035f11eb5 Add optional image parameter to showInputDialog for captcha support. Close #422 2025-07-22 17:51:40 +08:00
f2f5a4f573 Convert between Simplified Chinese and Traditional Chinese when searching favorites. Close #438 2025-07-20 18:52:05 +08:00
2acf234f7d Fix response handling for unhandled method calls in flutter_window.cpp 2025-07-20 18:47:49 +08:00
9ed8f351c7 Add heartbeat monitoring in release builds. 2025-07-20 18:45:42 +08:00
7c35dc7cf7 Update doc. 2025-07-20 16:39:21 +08:00
nyne
17b8b9ea8f Update README.md 2025-07-16 14:10:00 +08:00
ccb03343f4 Fix the issue where the toolbar can not be open when chapter data loading failed. Close #415 2025-07-13 20:22:56 +08:00
Selene29
951bcae603 Local Comic: Add "Open Folder" button (#443) 2025-07-13 18:52:23 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
0b9de68c86 fastlane workflow: path condition (#442) 2025-07-11 14:11:59 +08:00
boa
81b27fd941 update iOS privacy permission descriptions in AltStore config (#432) 2025-07-01 22:14:44 +08:00
角砂糖
b9817ec030 Fix page calculation logic && trigger recalculation on orientation change (#428) 2025-06-26 19:55:21 +08:00
角砂糖
5ebb554e54 Add an option to filter logs by level (#427) 2025-06-26 19:55:07 +08:00
Gandum2077
d5d72911ed Add custom tag suggestion handler (#424) 2025-06-24 19:47:14 +08:00
boa
838d5c9c3e Add AltStore Source Support (#416)
* add altstore source

* rename altstore source
2025-06-24 19:46:22 +08:00
23ee79fe9d Set high refresh rate on Android. 2025-06-23 19:39:47 +08:00
nyne
85baac657a Merge pull request #421 from lings03/local
Allow user to keep favorite and history when delete local comic. Close #420
2025-06-23 19:06:34 +08:00
nyne
cceca6b96f Merge branch 'master' into local 2025-06-23 19:04:20 +08:00
角砂糖
b5b0dc85e3 Show group in last read and history when group existing. (#419) 2025-06-23 19:03:24 +08:00
nyne
50044c4372 Merge pull request #418 from lings03/reverse
Add a option to reverse the default chapter order. Close #414
2025-06-23 19:02:52 +08:00
nyne
5fd7f1b880 Merge branch 'master' into reverse 2025-06-23 19:00:48 +08:00
角砂糖
058fde3f5a Add a button to show system status bar (#417) 2025-06-23 19:00:04 +08:00
角砂糖
a2d46123dd Add missing translation
debug时发现之前少了这一句翻译,也不至于提个pr,顺便加在这里吧
2025-06-22 20:07:15 +08:00
角砂糖
01acc4f9de Allow user to keep favorite and history when delete local comic. Close #420 2025-06-22 19:50:38 +08:00
角砂糖
856aae0769 Add a option to reverse the default chapter order. Close #414 2025-06-22 00:29:49 +08:00
nyne
8eda8adcc8 Merge pull request #410 from venera-app/v1.4.5-dev
V1.4.5
2025-06-18 16:52:59 +08:00
defd4b8624 Update version code. 2025-06-18 16:39:02 +08:00
b2a164e066 Remove the config file repository url from app. 2025-06-18 16:34:49 +08:00
a46ceebf19 Fixed the issue where the update dialog was not showed on startup. 2025-06-18 16:07:36 +08:00
cc08445f13 Set initial chapter to first downloaded chapter if there is no history when starting to read a local comic. Close #405 2025-06-17 17:36:13 +08:00
93f7f72d07 Fixed some issues when using custom download path on Android. Close #400 2025-06-17 17:15:35 +08:00
20f7ab4866 Clear folder value if it does not exist in local favorites. Close #389 2025-06-15 15:02:45 +08:00
54363919cd Fixed RangeError when translating tags. Close #356 2025-06-15 14:58:15 +08:00
182a821fc5 Fixed the issue where the download task would stop after exiting the reader. Close #387 2025-06-15 14:58:15 +08:00
8868c6edb3 Update Flutter SDK version to 3.32.4 2025-06-15 14:58:15 +08:00
角砂糖
fffbb4ed23 Only add closeListener when app is desktop (#397) 2025-06-04 12:11:45 +08:00
角砂糖
b057be0311 Fix abnormal history recording when not flipping pages. Close #392 (#395) 2025-06-03 17:36:20 +08:00
角砂糖
fc5fed1707 Fix history of page when show single image on first page (#393) 2025-06-03 17:35:45 +08:00
角砂糖
8525f5318f Fix page calculate when showSingleImageOnFirstPage is enabled (#391) 2025-06-03 17:35:17 +08:00
角砂糖
d58cafc4a0 Fix abnormal single image height when imagesPerPage > 1. Close #379 2025-05-31 10:50:17 +08:00
23afafd1d6 Update rhttp 2025-05-26 19:05:15 +08:00
nyne
3b6e0adbbb Merge pull request #377 from venera-app/v1.4.4
V1.4.4
2025-05-26 18:18:43 +08:00
20a57c7a36 Update version code 2025-05-26 18:10:07 +08:00
665f50ed2a Fixed an issue where comic counts would become invalid after renaming a favorite folder. Close #357 2025-05-26 16:42:05 +08:00
55733ef505 Update selectAll method to handle search mode for selecting comics. Close #359 2025-05-26 16:09:23 +08:00
0c46214619 Reduce maximum length for comic directory names to improve consistency. Close #362 2025-05-26 15:35:24 +08:00
749a1a47fb Fix dialog content overflow. Close #363 2025-05-25 20:33:31 +08:00
76e9ef87d4 Add functionality to delete specific comic chapters. Close #368 2025-05-25 20:26:35 +08:00
dcd6466547 Improve performance of deleting favorites, coping favorites, moving favorites and deleting downloads. Close #365 2025-05-24 16:24:53 +08:00
ed70fdba93 Improve reordering local comics. Close #374 2025-05-22 20:51:47 +08:00
ded0068ea6 Improve performance for clearing history. 2025-05-22 20:37:25 +08:00
nyne
7dc6be622a fix clearing history. 2025-05-22 20:01:07 +08:00
nyne
88f093f7e5 Add clear unfavorited history functionality. Close #372 2025-05-22 19:59:42 +08:00
8f357b3e6c Merge branch 'master' into v1.4.4 2025-05-20 15:51:28 +08:00
9ee82975e8 Handle invalid appdata file. 2025-05-20 15:40:30 +08:00
nyne
9f048685e4 fix decryptAesCbc 2025-05-05 18:29:46 +08:00
nyne
bc1f5e11b5 Update version code 2025-05-05 18:26:01 +08:00
1f2147ef72 Add support for gbk. Close #354 2025-05-05 12:51:36 +08:00
fba365fd93 Fix crash caused by cache manager. Close #351 2025-05-04 23:03:37 +08:00
a5e3fbaee5 Improve image loading 2025-05-04 22:24:39 +08:00
190e645a12 Update translation 2025-04-29 11:35:54 +08:00
nyne
8a83ff5367 Merge pull request #349 from venera-app/v1.4.2-dev
V1.4.2
2025-04-29 11:32:40 +08:00
67 changed files with 7830 additions and 1354 deletions

View File

@@ -4,8 +4,12 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ "master" ] branches: [ "master" ]
paths:
- 'fastlane/**'
pull_request: pull_request:
branches: [ "master" ] branches: [ "master" ]
paths:
- 'fastlane/**'
jobs: jobs:
go: go:

View File

@@ -15,7 +15,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 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 pub get
# Step 1: Decode and install the certificate # Step 1: Decode and install the certificate
- name: Decode and install certificate - name: Decode and install certificate
@@ -63,7 +63,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 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 pub get
- run: flutter build ios --release --no-codesign - run: flutter build ios --release --no-codesign
- run: | - run: |
@@ -149,45 +149,6 @@ jobs:
sudo rm -rf build/linux/arch/pkg sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD sudo rm -rf build/linux/arch/PKGBUILD
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/x64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-x86_64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-x86_64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: deb_build name: deb_build
@@ -210,45 +171,6 @@ jobs:
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1 sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian dart pub global activate flutter_to_debian
- run: python3 debian/build.py arm64 - run: python3 debian/build.py arm64
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/arm64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-aarch64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-aarch64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_arm64_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: deb_arm64_build name: deb_arm64_build
@@ -287,14 +209,6 @@ jobs:
with: with:
name: deb_arm64_build name: deb_arm64_build
path: outputs path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}

76
.github/workflows/update_alt_store.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Update AltStore Source
on:
workflow_run:
workflows: ["Build ALL"]
types: [completed]
workflow_dispatch:
jobs:
update-source:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Record job start time
id: job_start_time
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
- name: Update AltStore source
id: update_source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python update_alt_store.py
git config --global user.name 'GitHub Action'
git config --global user.email 'action@github.com'
git add alt_store.json
if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT
else
git commit -m "Updated source with latest release"
git push
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Calculate job duration
id: duration
if: always()
run: |
end_time=$(date +%s)
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
- name: Create job summary
run: |
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "✅ Changes Detected and Applied" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The alt_store.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
else
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The alt_store.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The next scheduled run will be tomorrow at midnight UTC." >> $GITHUB_STEP_SUMMARY

View File

@@ -1,16 +1,15 @@
# venera # venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/) [![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) [![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) [![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/+Ws-IpmUutzkxMjhl) [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/venera_release)
[![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/)
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.
[<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/)
## Features ## Features
- Read local comics - Read local comics
- Use javascript to create comic sources - Use javascript to create comic sources
@@ -34,4 +33,7 @@ See [Comic Source](doc/comic_source.md)
### Tags Translation ### Tags Translation
[![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=EhTagTranslation&repo=Database)](https://github.com/EhTagTranslation/Database) [![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. The Chinese translation of the manga tags is from this project.

64
alt_store.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "Venera",
"identifier": "com.github.wgh136.venera.source",
"website": "https://github.com/venera-app/venera",
"subtitle": "Venera official AltStore Source.",
"description": "This is the official AltStore Source for Venera.\n\n A comic reader that supports reading local and network comics",
"tintColor": "#0784FC",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"apps": [
{
"beta": false,
"name": "Venera",
"bundleIdentifier": "com.github.wgh136.venera",
"developerName": "wgh136",
"subtitle": "A comic reader that supports reading local and network comics",
"version": "1.4.5",
"versionDate": "2025-06-18",
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
"localizedDescription": "A comic reader that supports reading local and network comics",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"tintColor": "#0784FC",
"category": "utilities",
"size": 14960268,
"appPermissions": {
"entitlements": [
"application-identifier",
"com.apple.security.application-groups",
"get-task-allow",
"keychain-access-groups",
"com.apple.developer.kernel.extended-virtual-addressing",
"com.apple.developer.kernel.increased-memory-limit",
"com.apple.developer.healthkit.background-delivery"
],
"privacy": {
"NSFaceIDUsageDescription": "Face ID or Touch ID is used to protect your privacy when opening the app, ensuring secure access to your reading content.",
"NSPhotoLibraryAddUsageDescription": "Used to save comic images you've favorited or downloaded to your photo library for easy access and sharing.",
"NSPhotoLibraryUsageDescription": "Used to select images from your photo library when needed, and to save comic images you've collected to your device."
}
},
"versions": [
{
"version": "1.4.5",
"date": "2025-06-18",
"localizedDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
"size": 14960268
}
]
}
],
"news": [
{
"appID": "com.github.wgh136.venera",
"caption": "Update of Venera just got released!",
"date": "2025-06-18T09:02:01Z",
"identifier": "release-v1.4.5",
"notify": true,
"tintColor": "#0784FC",
"title": "v1.4.5 - Venera 18/06/25",
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
}
]
}

View File

@@ -4,6 +4,18 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. 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) { function setTimeout(callback, delay) {
sendMessage({ sendMessage({
method: 'delay', method: 'delay',
@@ -39,6 +51,32 @@ let Convert = {
}); });
}, },
/**
* @param str {string}
* @returns {ArrayBuffer}
*/
encodeGbk: (str) => {
return sendMessage({
method: "convert",
type: "gbk",
value: str,
isEncode: true
});
},
/**
* @param value {ArrayBuffer}
* @returns {string}
*/
decodeGbk: (value) => {
return sendMessage({
method: "convert",
type: "gbk",
value: value,
isEncode: false
});
},
/** /**
* @param {ArrayBuffer} value * @param {ArrayBuffer} value
* @returns {string} * @returns {string}
@@ -176,7 +214,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => { decryptAesCbc: (value, key, iv) => {
return sendMessage({ return sendMessage({
method: "convert", method: "convert",
type: "aes-ecb", type: "aes-cbc",
value: value, value: value,
key: key, key: key,
iv: iv, iv: iv,
@@ -1296,13 +1334,15 @@ let UI = {
* Show an input dialog * Show an input dialog
* @param title {string} * @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message. * @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null. * @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/ */
showInputDialog: (title, validator) => { showInputDialog: (title, validator, image) => {
return sendMessage({ return sendMessage({
method: 'UI', method: 'UI',
function: 'showInputDialog', function: 'showInputDialog',
title: title, title: title,
image: image,
validator: validator validator: validator
}) })
}, },
@@ -1384,3 +1424,18 @@ function getClipboard() {
method: 'getClipboard' 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
})
}

3982
assets/opencc.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -234,8 +234,10 @@
"Please add some sources": "请添加一些源", "Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Group @group": "第 @group 组",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页", "Page @page": "第 @page 页",
"Remove local favorite and history": "删除本地收藏和历史记录",
"Also remove files on disk": "同时删除磁盘上的文件", "Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中", "Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏", "Delete all unavailable local favorite items": "删除所有无效的本地收藏",
@@ -388,9 +390,28 @@
"Suggestions": "建议", "Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题", "Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"Show single image on first page": "在首页显示单张图片", "Show single image on first page": "在首页显示单张图片",
"Show system status bar": "显示系统状态栏",
"Click to select an image": "点击选择一张图片", "Click to select an image": "点击选择一张图片",
"Source URL": "地址", "Repo URL": "仓库地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件" "The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反转",
"Delete Chapters": "删除章节",
"Open Folder": "打开文件夹",
"Path copied to clipboard": "路径已复制到剪贴板",
"Reverse default chapter order": "反转默认章节顺序",
"Reload Configs": "重新加载配置文件",
"Reload": "重载",
"Disable Length Limitation": "禁用长度限制",
"Only valid for this run": "仅对本次运行有效",
"Logs": "日志",
"Export logs": "导出日志",
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
"Enable comic specific settings": "启用此漫画特定设置",
"Ignore Certificate Errors": "忽略证书错误",
"Mouse scroll speed": "鼠标滚动速度"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -627,8 +648,10 @@
"Please add some sources": "請添加一些源", "Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁", "Page @page": "第 @page 頁",
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Also remove files on disk": "同時刪除磁碟上的文件", "Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中", "Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏", "Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
@@ -781,8 +804,27 @@
"Suggestions": "建議", "Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題", "Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"Show single image on first page": "在首頁顯示單張圖片", "Show single image on first page": "在首頁顯示單張圖片",
"Show system status bar": "顯示系統狀態欄",
"Click to select an image": "點擊選擇一張圖片", "Click to select an image": "點擊選擇一張圖片",
"Source URL": "地址", "Repo URL": "倉庫地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件" "The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反轉",
"Delete Chapters": "刪除章節",
"Open Folder": "打開資料夾",
"Path copied to clipboard": "路徑已複製到剪貼簿",
"Reverse default chapter order": "反轉預設章節順序",
"Reload Configs": "重新載入設定檔",
"Reload": "重載",
"Disable Length Limitation": "禁用長度限制",
"Only valid for this run": "僅對本次運行有效",
"Logs": "日誌",
"Export logs": "匯出日誌",
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
"Enable comic specific settings": "啟用此漫畫特定設定",
"Ignore Certificate Errors": "忽略證書錯誤",
"Mouse scroll speed": "滑鼠滾動速度"
} }
} }

View File

@@ -9,13 +9,47 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
This document will describe how to write a comic source for Venera. This document will describe how to write a comic source for Venera.
## Preparation ## Comic Source List
Venera can display a list of comic sources in the app.
You can use the following repo url:
```
https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json
```
The repo is maintained by the Venera team.
> The link is a mirror of the original repo. To contribute your comic source, please visit the [original repo](https://github.com/venera-app/venera-configs)
You should provide a repository url to let the app load the comic source list.
The url should point to a JSON file that contains the list of comic sources.
The JSON file should have the following format:
```json
[
{
"name": "Source Name",
"url": "https://example.com/source.js",
"filename": "Relative path to the source file",
"version": "1.0.0",
"description": "A brief description of the source"
}
]
```
Only one of `url` and `filename` should be provided.
The description field is optional.
## Create a Comic Source
### Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug. - Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript. - An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs). - Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing ### Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source. The template contains detailed comments and examples. You can refer to it when writing your own comic source.
@@ -23,7 +57,7 @@ Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md). > Note: Javascript api document is [here](js_api.md).
### Write basic information #### Write basic information
```javascript ```javascript
class NewComicSource extends ComicSource { class NewComicSource extends ComicSource {
@@ -49,7 +83,7 @@ In this part, you need to do the following:
- Change the class name to your source name. - Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields. - Fill in the name, key, version, minAppVersion, and url fields.
### init function #### init function
```javascript ```javascript
/** /**
@@ -64,7 +98,7 @@ The function will be called when the source is initialized. You can do some init
Remove this function if not used. Remove this function if not used.
### Account #### Account
```javascript ```javascript
// [Optional] account related // [Optional] account related
@@ -140,7 +174,7 @@ In this part, you can implement login, logout, and register functions.
Remove this part if not used. Remove this part if not used.
### Explore page #### Explore page
```javascript ```javascript
// explore page list // explore page list
@@ -185,7 +219,7 @@ There are three types of explore pages:
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page. - multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button. - mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page #### Category Page
```javascript ```javascript
// categories // categories
@@ -227,7 +261,7 @@ Category page is a static page that contains multiple parts, each part contains
A comic source can only have one category page. A comic source can only have one category page.
### Category Comics Page #### Category Comics Page
```javascript ```javascript
/// category comic loading related /// category comic loading related
@@ -280,7 +314,7 @@ When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category. This part is used to load comics of a category.
### Search #### Search
```javascript ```javascript
/// search related /// search related
@@ -331,6 +365,11 @@ This part is used to load comics of a category.
// enable tags suggestions // enable tags suggestions
enableTagsSuggestions: false, enableTagsSuggestions: false,
// [Optional] handle tag suggestion click
onTagSuggestionSelected: (namespace, tag) => {
// return the text to insert into search box
return `${namespace}:${tag}`
},
} }
``` ```
@@ -339,7 +378,7 @@ This part is used to load search results.
`load` and `loadNext` functions are used to load search results. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Favorites #### Favorites
```javascript ```javascript
// favorite related // favorite related
@@ -411,7 +450,7 @@ This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details #### Comic Details
```javascript ```javascript
/// single comic related /// single comic related
@@ -576,7 +615,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
This part is used to load comic details. This part is used to load comic details.
### Settings #### Settings
```javascript ```javascript
/* /*
@@ -635,7 +674,7 @@ This part is used to load comic details.
This part is used to provide settings for the source. This part is used to provide settings for the source.
### Translations #### Translations
```javascript ```javascript
// [Optional] translations for the strings in this config // [Optional] translations for the strings in this config

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

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
@@ -21,11 +22,13 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart'; import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart'; import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

@@ -37,9 +37,11 @@ mixin class JsUiApi {
case 'showInputDialog': case 'showInputDialog':
var title = message['title']; var title = message['title'];
var validator = message['validator']; var validator = message['validator'];
var image = message['image'];
if (title is! String) return; if (title is! String) return;
if (validator != null && validator is! JSInvokable) return; if (validator != null && validator is! JSInvokable) return;
return _showInputDialog(title, validator); if (image != null && image is! String) return;
return _showInputDialog(title, validator, image);
case 'showSelectDialog': case 'showSelectDialog':
var title = message['title']; var title = message['title'];
var options = message['options']; var options = message['options'];
@@ -124,12 +126,13 @@ mixin class JsUiApi {
controller?.close(); controller?.close();
} }
Future<String?> _showInputDialog(String title, JSInvokable? validator) async { Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
String? result; String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator); var func = validator == null ? null : JSAutoFreeFunction(validator);
await showInputDialog( await showInputDialog(
context: App.rootContext, context: App.rootContext,
title: title, title: title,
image: image,
onConfirm: (v) { onConfirm: (v) {
if (func != null) { if (func != null) {
var res = func.call([v]); var res = func.call([v]);

View File

@@ -41,18 +41,22 @@ class NetworkError extends StatelessWidget {
], ],
), ),
), ),
const SizedBox( const SizedBox(height: 8),
height: 8,
),
Text( Text(
cfe == null ? message : "Cloudflare verification required".tl, cfe == null ? message : "Cloudflare verification required".tl,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 3, maxLines: 3,
), ),
if (retry != null) TextButton(
const SizedBox( onPressed: () {
height: 12, saveFile(
data: utf8.encode(Log().toString()),
filename: 'log.txt',
);
},
child: Text("Export logs".tl),
), ),
const SizedBox(height: 8),
if (retry != null) if (retry != null)
if (cfe != null) if (cfe != null)
FilledButton( FilledButton(
@@ -74,15 +78,11 @@ class NetworkError extends StatelessWidget {
body = Column( body = Column(
children: [ children: [
const Appbar(title: Text("")), const Appbar(title: Text("")),
Expanded( Expanded(child: body),
child: body,
)
], ],
); );
} }
return Material( return Material(child: body);
child: body,
);
} }
} }
@@ -94,9 +94,7 @@ class ListLoadingIndicator extends StatelessWidget {
return const SizedBox( return const SizedBox(
width: double.infinity, width: double.infinity,
height: 80, height: 80,
child: Center( child: Center(child: FiveDotLoadingAnimation()),
child: FiveDotLoadingAnimation(),
),
); );
} }
} }
@@ -108,10 +106,9 @@ class SliverListLoadingIndicator extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// SliverToBoxAdapter can not been lazy loaded. // SliverToBoxAdapter can not been lazy loaded.
// Use SliverList to make sure the animation can be lazy loaded. // Use SliverList to make sure the animation can be lazy loaded.
return SliverList.list(children: const [ return SliverList.list(
SizedBox(), children: const [SizedBox(), ListLoadingIndicator()],
ListLoadingIndicator(), );
]);
} }
} }
@@ -178,10 +175,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError() { Widget buildError() {
return NetworkError( return NetworkError(message: error!, retry: retry);
message: error!,
retry: retry,
);
} }
@override @override
@@ -323,11 +317,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError(BuildContext context, String error) { Widget buildError(BuildContext context, String error) {
return NetworkError( return NetworkError(withAppbar: false, message: error, retry: reset);
withAppbar: false,
message: error,
retry: reset,
);
} }
@override @override
@@ -388,7 +378,7 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
Colors.green, Colors.green,
Colors.blue, Colors.blue,
Colors.yellow, Colors.yellow,
Colors.purple Colors.purple,
]; ];
static const _padding = 12.0; static const _padding = 12.0;
@@ -405,11 +395,10 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
return SizedBox( return SizedBox(
width: _dotSize * 5 + _padding * 6, width: _dotSize * 5 + _padding * 6,
height: _height, height: _height,
child: Stack( child: Stack(children: List.generate(5, (index) => buildDot(index))),
children: List.generate(5, (index) => buildDot(index)), );
), },
); );
});
} }
Widget buildDot(int index) { Widget buildDot(int index) {
@@ -417,7 +406,8 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
var startValue = index * 0.8; var startValue = index * 0.8;
return Positioned( return Positioned(
left: index * _dotSize + (index + 1) * _padding, left: index * _dotSize + (index + 1) * _padding,
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) * bottom:
(math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
(_height - _dotSize), (_height - _dotSize),
child: Container( child: Container(
width: _dotSize, width: _dotSize,

View File

@@ -290,7 +290,8 @@ class ContentDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = Column( var content = SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -312,6 +313,7 @@ class ContentDialog extends StatelessWidget {
).paddingRight(12), ).paddingRight(12),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
),
); );
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -357,6 +359,7 @@ Future<void> showInputDialog({
String confirmText = "Confirm", String confirmText = "Confirm",
String cancelText = "Cancel", String cancelText = "Cancel",
RegExp? inputValidator, RegExp? inputValidator,
String? image,
}) { }) {
var controller = TextEditingController(text: initialValue); var controller = TextEditingController(text: initialValue);
bool isLoading = false; bool isLoading = false;
@@ -369,7 +372,14 @@ Future<void> showInputDialog({
builder: (context, setState) { builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: title, title: title,
content: TextField( content: Column(
children: [
if (image != null)
SizedBox(
height: 108,
child: Image.network(image, fit: BoxFit.none),
).paddingBottom(8),
TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
@@ -377,6 +387,8 @@ Future<void> showInputDialog({
errorText: error, errorText: error,
), ),
).paddingHorizontal(12), ).paddingHorizontal(12),
],
),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: isLoading, isLoading: isLoading,

View File

@@ -117,16 +117,25 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_futurePosition ??= currentLocation; _futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1; double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k; _futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
var beforeOffset = (_futurePosition! - currentLocation).abs();
_futurePosition = _futurePosition!.clamp( _futurePosition = _futurePosition!.clamp(
_controller.position.minScrollExtent, _controller.position.minScrollExtent,
_controller.position.maxScrollExtent, _controller.position.maxScrollExtent,
); );
var afterOffset = (_futurePosition! - currentLocation).abs();
if (_futurePosition == old) return; if (_futurePosition == old) return;
var target = _futurePosition!; var target = _futurePosition!;
var duration = _fastAnimationDuration;
if (afterOffset < beforeOffset) {
duration = duration * (afterOffset / beforeOffset);
if (duration < Duration(milliseconds: 10)) {
duration = Duration(milliseconds: 10);
}
}
_controller _controller
.animateTo( .animateTo(
_futurePosition!, _futurePosition!,
duration: _fastAnimationDuration, duration: duration,
curve: Curves.linear, curve: Curves.linear,
) )
.then((_) { .then((_) {

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.4.2"; final version = "1.5.0";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -30,6 +30,10 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS; 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 get locale {
Locale deviceLocale = PlatformDispatcher.instance.locale; Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" && if (deviceLocale.languageCode == "zh" &&
@@ -81,6 +85,7 @@ class _App {
if (isAndroid) { if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path; externalStoragePath = (await getExternalStorageDirectory())!.path;
} }
isInitialized = true;
} }
Future<void> initComponents() async { Future<void> initComponents() async {

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -25,8 +26,7 @@ class Appdata with Init {
var data = jsonEncode(toJson()); var data = jsonEncode(toJson());
var file = File(FilePath.join(App.dataPath, 'appdata.json')); var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data); await file.writeAsString(data);
} } finally {
finally {
_isSavingData = false; _isSavingData = false;
} }
if (sync) { if (sync) {
@@ -56,10 +56,7 @@ class Appdata with Init {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'settings': settings._data, 'searchHistory': searchHistory};
'settings': settings._data,
'searchHistory': searchHistory,
};
} }
/// Following fields are related to device-specific data and should not be synced. /// Following fields are related to device-specific data and should not be synced.
@@ -94,8 +91,7 @@ class Appdata with Init {
try { try {
var file = File(FilePath.join(App.dataPath, 'implicitData.json')); var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
await file.writeAsString(jsonEncode(implicitData)); await file.writeAsString(jsonEncode(implicitData));
} } finally {
finally {
_isSavingData = false; _isSavingData = false;
} }
} }
@@ -103,13 +99,11 @@ class Appdata with Init {
@override @override
Future<void> doInit() async { Future<void> doInit() async {
var dataPath = (await getApplicationSupportDirectory()).path; var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join( var file = File(FilePath.join(dataPath, 'appdata.json'));
dataPath,
'appdata.json',
));
if (!await file.exists()) { if (!await file.exists()) {
return; return;
} }
try {
var json = jsonDecode(await file.readAsString()); var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) { for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) { if (json['settings'][key] != null) {
@@ -117,14 +111,21 @@ class Appdata with Init {
} }
} }
searchHistory = List.from(json['searchHistory']); searchHistory = List.from(json['searchHistory']);
} catch (e) {
Log.error("Appdata", "Failed to load appdata", e);
Log.info("Appdata", "Resetting appdata");
file.deleteIgnoreError();
}
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json')); var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) { if (await implicitDataFile.exists()) {
try {
implicitData = jsonDecode(await implicitDataFile.readAsString()); implicitData = jsonDecode(await implicitDataFile.readAsString());
} }
catch(_) { } catch (e) {
// ignore Log.error("Appdata", "Failed to load implicit data", e);
} Log.info("Appdata", "Resetting implicit data");
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
implicitDataFile.deleteIgnoreError();
} }
} }
} }
@@ -178,7 +179,7 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': defaultComicSourceUrl, 'comicSourceListUrl': _defaultSourceListUrl,
'preloadImageCount': 4, 'preloadImageCount': 4,
'followUpdatesFolder': null, 'followUpdatesFolder': null,
'initialPage': '0', 'initialPage': '0',
@@ -186,6 +187,11 @@ class Settings with ChangeNotifier {
'showPageNumberInReader': true, 'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false, 'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true, 'enableDoubleTapToZoom': true,
'reverseChapterOrder': false,
'showSystemStatusBar': false,
'comicSpecificSettings': <String, Map<String, dynamic>>{},
'ignoreBadCertificate': false,
'readerScrollSpeed': 1.0, // 0.5 - 3.0
}; };
operator [](String key) { operator [](String key) {
@@ -194,6 +200,45 @@ class Settings with ChangeNotifier {
operator []=(String key, dynamic value) { operator []=(String key, dynamic value) {
_data[key] = value; _data[key] = value;
if (key != "dataVersion") {
notifyListeners();
}
}
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 (!isComicSpecificSettingsEnabled(comicId, sourceKey)) {
return _data[key];
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
_data[key];
}
void setReaderSetting(
String comicId,
String sourceKey,
String key,
dynamic value,
) {
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
"$comicId@$sourceKey",
() => <String, dynamic>{},
)[key] = value;
notifyListeners();
}
void resetComicReaderSettings(String key) {
(_data['comicSpecificSettings'] as Map).remove(key);
notifyListeners(); notifyListeners();
} }
@@ -221,4 +266,5 @@ function processImage(image, cid, eid, page, sourceKey) {
} }
'''; ''';
const defaultComicSourceUrl = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json"; const _defaultSourceListUrl =
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";

View File

@@ -1,5 +1,7 @@
import 'dart:ffi';
import 'dart:isolate';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -21,6 +23,51 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024; int _limitSize = 2 * 1024 * 1024 * 1024;
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
var res = await Isolate.run(() async {
int totalSize = 0;
List<String> unmanagedFiles = [];
var db = sqlite3.fromPointer(dbP);
await for (var file in Directory(dir).list(recursive: true)) {
if (file is File) {
var size = await file.length();
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
var res = db.select('''
SELECT * FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
if (res.isEmpty) {
unmanagedFiles.add(file.path);
} else {
totalSize += size;
}
}
}
return {
'totalSize': totalSize,
'unmanagedFiles': unmanagedFiles,
};
});
// delete unmanaged files
// Only modify the database in the main isolate to avoid deadlock
for (var filePath in res['unmanagedFiles'] as List<String>) {
var file = File(filePath);
if (await file.exists()) {
await file.delete();
}
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
CacheManager()._db.execute('''
DELETE FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
}
return res['totalSize'] as int;
}
CacheManager._create() { CacheManager._create() {
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db'); _db = sqlite3.open('${App.dataPath}/cache.db');
@@ -33,7 +80,7 @@ class CacheManager {
type TEXT type TEXT
) )
'''); ''');
compute((path) => Directory(path).size, cachePath).then((value) { _scanDir(_db.handle, cachePath).then((value) {
_currentSize = value; _currentSize = value;
checkCache(); checkCache();
}); });
@@ -50,6 +97,7 @@ class CacheManager {
/// Write cache to disk. /// Write cache to disk.
Future<void> writeCache(String key, List<int> data, Future<void> writeCache(String key, List<int> data,
[int duration = 7 * 24 * 60 * 60 * 1000]) async { [int duration = 7 * 24 * 60 * 60 * 1000]) async {
await delete(key);
this.dir++; this.dir++;
this.dir %= 100; this.dir %= 100;
var dir = this.dir; var dir = this.dir;
@@ -146,10 +194,12 @@ class CacheManager {
await file.delete(); await file.delete();
} }
} }
if (res.isNotEmpty) {
_db.execute(''' _db.execute('''
DELETE FROM cache DELETE FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
}
while (_currentSize != null && _currentSize! > _limitSize) { while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select(''' var res = _db.select('''
@@ -157,6 +207,13 @@ class CacheManager {
ORDER BY expires ASC ORDER BY expires ASC
limit 10 limit 10
'''); ''');
if (res.isEmpty) {
// There are many files unmanaged by the cache manager.
// Clear all cache.
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
break;
}
for (var row in res) { for (var row in res) {
var key = row[0] as String; var key = row[0] as String;
var dir = row[1] as String; var dir = row[1] as String;

View File

@@ -184,6 +184,9 @@ class ComicSource {
final HandleClickTagEvent? handleClickTagEvent; final HandleClickTagEvent? handleClickTagEvent;
/// Callback when a tag suggestion is selected in search.
final TagSuggestionSelectFunc? onTagSuggestionSelected;
final LinkHandler? linkHandler; final LinkHandler? linkHandler;
final bool enableTagsSuggestions; final bool enableTagsSuggestions;
@@ -259,6 +262,7 @@ class ComicSource {
this.idMatcher, this.idMatcher,
this.translations, this.translations,
this.handleClickTagEvent, this.handleClickTagEvent,
this.onTagSuggestionSelected,
this.linkHandler, this.linkHandler,
this.enableTagsSuggestions, this.enableTagsSuggestions,
this.enableTagsTranslate, this.enableTagsTranslate,
@@ -397,9 +401,14 @@ class SearchOptions {
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function( typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page); String category, String? param, List<String> options, int page);
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
String category, String? param);
class CategoryComicsData { class CategoryComicsData {
/// options /// options
final List<CategoryComicsOptions> options; final List<CategoryComicsOptions>? options;
final CategoryOptionsLoader? optionsLoader;
/// [category] is the one clicked by the user on the category page. /// [category] is the one clicked by the user on the category page.
/// ///
@@ -410,7 +419,7 @@ class CategoryComicsData {
final RankingData? rankingData; final RankingData? rankingData;
const CategoryComicsData(this.options, this.load, {this.rankingData}); const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
} }
class RankingData { class RankingData {
@@ -425,6 +434,9 @@ class RankingData {
} }
class CategoryComicsOptions { class CategoryComicsOptions {
// The label will not be displayed if it is empty.
final String label;
/// Use a [LinkedHashMap] to describe an option list. /// Use a [LinkedHashMap] to describe an option list.
/// key is for loading comics, value is the name displayed on screen. /// key is for loading comics, value is the name displayed on screen.
/// Default value will be the first of the Map. /// Default value will be the first of the Map.
@@ -435,7 +447,7 @@ class CategoryComicsOptions {
final List<String>? showWhen; final List<String>? showWhen;
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
} }
class LinkHandler { class LinkHandler {

View File

@@ -116,6 +116,26 @@ class Comic {
toString() => "$sourceKey@$id"; toString() => "$sourceKey@$id";
} }
class ComicID {
final ComicType type;
final String id;
const ComicID(this.type, this.id);
@override
bool operator ==(Object other) {
if (other is! ComicID) return false;
return other.type == type && other.id == id;
}
@override
int get hashCode => type.hashCode ^ id.hashCode;
@override
String toString() => "$type@$id";
}
class ComicDetails with HistoryMixin { class ComicDetails with HistoryMixin {
@override @override
final String title; final String title;

View File

@@ -64,8 +64,13 @@ class ComicSourceParser {
if (file.existsSync()) { if (file.existsSync()) {
int i = 0; int i = 0;
while (file.existsSync()) { while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source", file = File(
"${fileName.split('.').first}($i).js")); FilePath.join(
App.dataPath,
"comic_source",
"${fileName.split('.').first}($i).js",
),
);
i++; i++;
} }
} }
@@ -80,8 +85,9 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async { Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n"); js = js.replaceAll("\r\n", "\n");
var line1 = var line1 = js
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class ")); .split('\n')
.firstWhereOrNull((e) => e.trim().startsWith("class "));
if (line1 == null || if (line1 == null ||
!line1.startsWith("class ") || !line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) { !line1.contains("extends ComicSource")) {
@@ -89,24 +95,27 @@ class ComicSourceParser {
} }
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim(); className = className.trim();
JsEngine().runCode(""" JsEngine().runCode("""(() => { $js
(() => { $js
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
""", className); """, className);
_name = JsEngine().runCode("this['temp'].name") ?? _name =
JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required')); (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ?? var key =
JsEngine().runCode("this['temp'].key") ??
(throw ComicSourceParseException('key is required')); (throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") ?? var version =
JsEngine().runCode("this['temp'].version") ??
(throw ComicSourceParseException('version is required')); (throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion"); var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url"); var url = JsEngine().runCode("this['temp'].url");
if (minAppVersion != null) { if (minAppVersion != null) {
if (compareSemVer(minAppVersion, App.version.split('-').first)) { if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException( throw ComicSourceParseException(
"minAppVersion @version is required" "minAppVersion @version is required".tlParams({
.tlParams({"version": minAppVersion}), "version": minAppVersion,
}),
); );
} }
} }
@@ -148,6 +157,7 @@ class ComicSourceParser {
_parseIdMatch(), _parseIdMatch(),
_parseTranslation(), _parseTranslation(),
_parseClickTagEvent(), _parseClickTagEvent(),
_parseTagSuggestionSelectFunc(),
_parseLinkHandler(), _parseLinkHandler(),
_getValue("search.enableTagsSuggestions") ?? false, _getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false, _getValue("comic.enableTagsTranslate") ?? false,
@@ -174,8 +184,10 @@ class ComicSourceParser {
} }
bool _checkExists(String index) { bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null " return JsEngine().runCode(
"&& ComicSource.sources.$_key.$index !== undefined"); "ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined",
);
} }
dynamic _getValue(String index) { dynamic _getValue(String index) {
@@ -276,16 +288,24 @@ class ComicSourceParser {
if (type == "singlePageWithMultiPart") { if (type == "singlePageWithMultiPart") {
loadMultiPart = () async { loadMultiPart = () async {
try { try {
var res = await JsEngine() var res = await JsEngine().runCode(
.runCode("ComicSource.sources.$_key.explore[$i].load()"); "ComicSource.sources.$_key.explore[$i].load()",
return Res(List.from(res.keys );
.map((e) => ExplorePagePart( return Res(
List.from(
res.keys
.map(
(e) => ExplorePagePart(
e, e,
(res[e] as List) (res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!)) .map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(), .toList(),
null)) null,
.toList())); ),
)
.toList(),
),
);
} catch (e, s) { } catch (e, s) {
Log.error("Data Analysis", "$e\n$s"); Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -296,11 +316,15 @@ class ComicSourceParser {
loadPage = (int page) async { loadPage = (int page) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -310,10 +334,13 @@ class ComicSourceParser {
loadNext = (next) async { loadNext = (next) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})"); "ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -325,8 +352,9 @@ class ComicSourceParser {
} else if (type == "multiPartPage") { } else if (type == "multiPartPage") {
loadMultiPart = () async { loadMultiPart = () async {
try { try {
var res = await JsEngine() var res = await JsEngine().runCode(
.runCode("ComicSource.sources.$_key.explore[$i].load()"); "ComicSource.sources.$_key.explore[$i].load()",
);
return Res( return Res(
List.from( List.from(
(res as List).map((e) { (res as List).map((e) {
@@ -349,19 +377,22 @@ class ComicSourceParser {
loadMixed = (index) async { loadMixed = (index) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})"); "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
);
var list = <Object>[]; var list = <Object>[];
for (var data in (res['data'] as List)) { for (var data in (res['data'] as List)) {
if (data is List) { if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList()); list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) { } else if (data is Map) {
list.add(ExplorePagePart( list.add(
ExplorePagePart(
data['title'], data['title'],
(data['comics'] as List).map((e) { (data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!); return Comic.fromJson(e, _key!);
}).toList(), }).toList(),
data['viewMore'], data['viewMore'],
)); ),
);
} }
} }
return Res(list, subData: res['maxPage']); return Res(list, subData: res['maxPage']);
@@ -371,21 +402,25 @@ class ComicSourceParser {
} }
}; };
} }
pages.add(ExplorePageData( pages.add(
ExplorePageData(
title, title,
switch (type) { switch (type) {
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart, "singlePageWithMultiPart" =>
ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart, "multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList, "multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed, "mixed" => ExplorePageType.mixed,
_ => _ => throw ComicSourceParseException(
throw ComicSourceParseException("Unknown explore page type $type") "Unknown explore page type $type",
),
}, },
loadPage, loadPage,
loadNext, loadNext,
loadMultiPart, loadMultiPart,
loadMixed, loadMixed,
)); ),
);
} }
return pages; return pages;
} }
@@ -425,18 +460,17 @@ class ComicSourceParser {
if (type == "fixed") { if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!)); categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") { } else if (type == "random") {
categoryParts categoryParts.add(
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1)); RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
);
} else if (type == "dynamic" && categories == null) { } else if (type == "dynamic" && categories == null) {
var loader = c["loader"]; var loader = c["loader"];
if (loader is! JSInvokable) { if (loader is! JSInvokable) {
throw "DynamicCategoryPart loader must be a function"; throw "DynamicCategoryPart loader must be a function";
} }
categoryParts.add(DynamicCategoryPart( categoryParts.add(
name, DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
JSAutoFreeFunction(loader), );
_key!,
));
} }
} else { } else {
// old format // old format
@@ -453,30 +487,16 @@ class ComicSourceParser {
for (int i = 0; i < tags.length; i++) { for (int i = 0; i < tags.length; i++) {
PageJumpTarget target; PageJumpTarget target;
if (itemType == 'category') { if (itemType == 'category') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'category', {
_key!,
'category',
{
"category": tags[i], "category": tags[i],
"param": categoryParams?.elementAtOrNull(i), "param": categoryParams?.elementAtOrNull(i),
}, });
);
} else if (itemType == 'search') { } else if (itemType == 'search') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
_key!,
'search',
{
"keyword": tags[i],
},
);
} else if (itemType == 'search_with_namespace') { } else if (itemType == 'search_with_namespace') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'search', {
_key!,
'search',
{
"keyword": "$name:$tags[i]", "keyword": "$name:$tags[i]",
}, });
);
} else { } else {
target = PageJumpTarget(_key!, itemType, null); target = PageJumpTarget(_key!, itemType, null);
} }
@@ -485,8 +505,9 @@ class ComicSourceParser {
if (type == "fixed") { if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs)); categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") { } else if (type == "random") {
categoryParts categoryParts.add(
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1)); RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
);
} }
} }
} }
@@ -495,12 +516,16 @@ class ComicSourceParser {
title: title, title: title,
categories: categoryParts, categories: categoryParts,
enableRankingPage: enableRankingPage ?? false, enableRankingPage: enableRankingPage ?? false,
key: title); key: title,
);
} }
CategoryComicsData? _loadCategoryComicsData() { CategoryComicsData? _loadCategoryComicsData() {
if (!_checkExists("categoryComics")) return null; if (!_checkExists("categoryComics")) return null;
var options = <CategoryComicsOptions>[];
List<CategoryComicsOptions>? options;
if (_checkExists("categoryComics.optionList")) {
options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) { for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>(); LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) { for (var option in element["options"]) {
@@ -512,11 +537,64 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; map[key] = value;
} }
options.add(CategoryComicsOptions( options.add(
CategoryComicsOptions(
element["label"] ?? "",
map, map,
List.from(element["notShowWhen"] ?? []), List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]))); 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; RankingData? rankingData;
if (_checkExists("categoryComics.ranking")) { if (_checkExists("categoryComics.ranking")) {
var options = <String, String>{}; var options = <String, String>{};
@@ -540,9 +618,12 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(page)}) ${jsonEncode(option)}, ${jsonEncode(page)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -556,8 +637,10 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(next)}) ${jsonEncode(option)}, ${jsonEncode(next)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -568,7 +651,15 @@ class ComicSourceParser {
} }
rankingData = RankingData(options, load, loadWithNext); rankingData = RankingData(options, load, loadWithNext);
} }
return CategoryComicsData(options, (category, param, options, page) async {
if (options == null && optionLoader == null) {
options = [];
}
return CategoryComicsData(
options: options,
optionsLoader: optionLoader,
load: (category, param, options, page) async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load( ComicSource.sources.$_key.categoryComics.load(
@@ -579,14 +670,19 @@ class ComicSourceParser {
) )
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
} }
}, rankingData: rankingData); },
rankingData: rankingData,
);
} }
SearchPageData? _loadSearchData() { SearchPageData? _loadSearchData() {
@@ -603,12 +699,14 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; map[key] = value;
} }
options.add(SearchOptions( options.add(
SearchOptions(
map, map,
element["label"], element["label"],
element['type'] ?? 'select', element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']), element['default'] == null ? null : jsonEncode(element['default']),
)); ),
);
} }
SearchFunction? loadPage; SearchFunction? loadPage;
@@ -623,9 +721,12 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)}) ${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -639,8 +740,10 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)}) ${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -689,8 +792,9 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort"); final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = final bool? singleFolderForSingleComic = _getValue(
_getValue("favorites.singleFolderForSingleComic"); "favorites.singleFolderForSingleComic",
);
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -743,9 +847,12 @@ class ComicSourceParser {
${jsonEncode(page)}, ${jsonEncode(folder)}) ${jsonEncode(page)}, ${jsonEncode(folder)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -765,8 +872,10 @@ class ComicSourceParser {
${jsonEncode(next)}, ${jsonEncode(folder)}) ${jsonEncode(next)}, ${jsonEncode(folder)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -858,7 +967,8 @@ class ComicSourceParser {
"""); """);
return Res( return Res(
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(), (res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"]); subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -1057,6 +1167,19 @@ class ComicSourceParser {
}; };
} }
TagSuggestionSelectFunc? _parseTagSuggestionSelectFunc() {
if (!_checkExists("search.onTagSuggestionSelected")) {
return null;
}
return (namespace, tag) {
var res = JsEngine().runCode("""
ComicSource.sources.$_key.search.onTagSuggestionSelected(
${jsonEncode(namespace)}, ${jsonEncode(tag)})
""");
return res is String ? res : "$namespace:$tag";
};
}
LinkHandler? _parseLinkHandler() { LinkHandler? _parseLinkHandler() {
if (!_checkExists("comic.link")) { if (!_checkExists("comic.link")) {
return null; return null;
@@ -1100,7 +1223,8 @@ class ComicSourceParser {
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)}) ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
"""); """);
return Res( return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList()); (res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());

View File

@@ -44,5 +44,10 @@ typedef VoteCommentFunc = Future<Res<int?>> Function(
typedef HandleClickTagEvent = PageJumpTarget? Function( typedef HandleClickTagEvent = PageJumpTarget? Function(
String namespace, String tag); String namespace, String tag);
/// Handle tag suggestion selection event. Should return the text to insert
/// into the search field.
typedef TagSuggestionSelectFunc = String Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star. /// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating); typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);

View File

@@ -653,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
_db.execute("""
delete from "$sourceFolder"
where id == ? and type == ?;
""", [item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Move Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Copy Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
notifyListeners();
}
/// delete a folder /// delete a folder
void deleteFolder(String name) { void deleteFolder(String name) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
@@ -667,11 +763,6 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) { void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value); LocalFavoriteImageProvider.delete(id, type.value);
@@ -687,6 +778,55 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
if (counts[folder] != null) {
counts[folder] = counts[folder]! - comics.length;
} else {
counts[folder] = count(folder);
}
} catch (e) {
Log.error("Batch Delete Comics", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB();
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
for (var folder in folderNames) {
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
}
} catch (e) {
Log.error("Batch Delete Comics in All Folders", e.toString());
_db.execute("ROLLBACK");
return;
}
initCounts();
_db.execute("COMMIT");
notifyListeners();
}
Future<int> removeInvalid() async { Future<int> removeInvalid() async {
int count = 0; int count = 0;
await Future.microtask(() { await Future.microtask(() {
@@ -714,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); _db.execute("BEGIN TRANSACTION");
createFolder(folder); try {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i); _db.execute("""
update "$folder"
set display_order = ?
where id == ? and type == ?;
""", [
i,
newFolder[i].id,
newFolder[i].type.value
]);
} }
}
catch (e) {
Log.error("Reorder", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners(); notifyListeners();
} }
@@ -743,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ? set folder_name = ?
where folder_name == ?; where folder_name == ?;
""", [after, before]); """, [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners(); notifyListeners();
} }

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

@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -132,6 +133,11 @@ class History implements Comic {
@override @override
String get description { String get description {
var res = ""; var res = "";
if (group != null){
res += "${"Group @group".tlParams({
"group": group!,
})} - ";
}
if (ep >= 1) { if (ep >= 1) {
res += "Chapter @ep".tlParams({ res += "Chapter @ep".tlParams({
"ep": ep, "ep": ep,
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void clearUnfavoritedHistory() {
_db.execute('BEGIN TRANSACTION;');
try {
final idAndTypes = _db.select("""
select id, type from history;
""");
for (var element in idAndTypes) {
final id = element["id"] as String;
final type = ComicType(element["type"] as int);
if (!LocalFavoritesManager().isExist(id, type)) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [id, type.value]);
}
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
void remove(String id, ComicType type) async { void remove(String id, ComicType type) async {
_db.execute(""" _db.execute("""
delete from history delete from history
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false; isInitialized = false;
_db.dispose(); _db.dispose();
} }
void batchDeleteHistories(List<ComicID> histories) {
if (histories.isEmpty) return;
_db.execute('BEGIN TRANSACTION;');
try {
for (var history in histories) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [history.id, history.type.value]);
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
} }

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:enough_convert/enough_convert.dart';
import 'package:flutter/foundation.dart' show protected; import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
@@ -23,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart'; import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.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/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart'; import 'package:venera/network/proxy.dart';
@@ -67,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
} }
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override @override
@protected @protected
Future<void> doInit() async { Future<void> doInit() async {
@@ -74,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return; return;
} }
try { try {
if (App.isInitialized) {
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
}
_dio ??= AppDio(BaseOptions( _dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!;
_closed = false; _closed = false;
_engine = FlutterQjs(); _engine = FlutterQjs();
_engine!.dispatch(); _engine!.dispatch();
@@ -85,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]); setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free(); 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! _engine!
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>"); .evaluate(utf8.decode(jsInit), name: "<init>");
} catch (e, s) { } catch (e, s) {
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s'); Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
} }
@@ -96,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
Object? _messageReceiver(dynamic message) { Object? _messageReceiver(dynamic message) {
try { try {
if (message is Map<dynamic, dynamic>) { if (message is Map<dynamic, dynamic>) {
if (message["method"] == null) return null;
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
@@ -171,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
var res = await Clipboard.getData(Clipboard.kTextPlain); var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text; 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; return null;
@@ -372,6 +403,11 @@ mixin class _JSEngineApi {
switch (type) { switch (type) {
case "utf8": case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value); return isEncode ? utf8.encode(value) : utf8.decode(value);
case "gbk":
final codec = const GbkCodec();
return isEncode
? Uint8List.fromList(codec.encode(value))
: codec.decode(value);
case "base64": case "base64":
return isEncode ? base64Encode(value) : base64Decode(value); return isEncode ? base64Encode(value) : base64Decode(value);
case "md5": case "md5":

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

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
void read() { void read() {
var history = HistoryManager().find(id, comicType); var history = HistoryManager().find(id, comicType);
int? firstDownloadedChapter;
int? firstDownloadedChapterGroup;
if (downloadedChapters.isNotEmpty && chapters != null) {
final chapters = this.chapters!;
if (chapters.isGrouped) {
for (int i=0; i<chapters.groupCount; i++) {
var group = chapters.getGroupByIndex(i);
var keys = group.keys.toList();
for (int j=0; j<keys.length; j++) {
var chapterId = keys[j];
if (downloadedChapters.contains(chapterId)) {
firstDownloadedChapter = j + 1;
firstDownloadedChapterGroup = i + 1;
break;
}
}
}
} else {
var keys = chapters.allChapters.keys;
for (int i = 0; i < keys.length; i++) {
if (downloadedChapters.contains(keys.elementAt(i))) {
firstDownloadedChapter = i + 1;
break;
}
}
}
}
App.rootContext.to( App.rootContext.to(
() => Reader( () => Reader(
type: comicType, type: comicType,
cid: id, cid: id,
name: title, name: title,
chapters: chapters, chapters: chapters,
initialChapter: history?.ep, initialChapter: history?.ep ?? firstDownloadedChapter,
initialPage: history?.page, initialPage: history?.page,
initialChapterGroup: history?.group, initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
history: history ?? history: history ??
History.fromModel( History.fromModel(
model: this, model: this,
@@ -461,7 +490,7 @@ class LocalManager with ChangeNotifier {
if (comic != null) { if (comic != null) {
return Directory(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
const comicDirectoryMaxLength = 128; const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) { if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength); name = name.substring(0, comicDirectoryMaxLength);
} }
@@ -546,6 +575,99 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); notifyListeners();
} }
void deleteComicChapters(LocalComic c, List<String> chapters) {
if (chapters.isEmpty) {
return;
}
var newDownloadedChapters = c.downloadedChapters
.where((e) => !chapters.contains(e))
.toList();
if (newDownloadedChapters.isNotEmpty) {
_db.execute(
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
[
jsonEncode(newDownloadedChapters),
c.id,
c.comicType.value,
],
);
} else {
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) {
var dir = Directory(FilePath.join(c.baseDir, chapter));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
if (shouldRemovedDirs.isNotEmpty) {
_deleteDirectories(shouldRemovedDirs);
}
notifyListeners();
}
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
if (comics.isEmpty) {
return;
}
var shouldRemovedDirs = <Directory>[];
_db.execute('BEGIN TRANSACTION;');
try {
for (var c in comics) {
if (removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
}
catch(e, s) {
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
_db.execute('ROLLBACK;');
return;
}
_db.execute('COMMIT;');
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
if (removeFavoriteAndHistory) {
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
HistoryManager().batchDeleteHistories(comicIDs);
}
notifyListeners();
if (removeFileOnDisk) {
_deleteDirectories(shouldRemovedDirs);
}
}
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
static void _deleteDirectories(List<Directory> directories) {
Isolate.run(() async {
await SAFTaskWorker().init();
for (var dir in directories) {
try {
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} catch (e) {
continue;
}
}
});
}
} }
enum LocalSortType { enum LocalSortType {

View File

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

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:display_mode/display_mode.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
@@ -15,6 +16,7 @@ import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/handle_text_share.dart'; import 'package:venera/utils/handle_text_share.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
@@ -35,6 +37,7 @@ extension _FutureInit<T> on Future<T> {
Future<void> init() async { Future<void> init() async {
await App.init().wait(); await App.init().wait();
await SingleInstanceCookieJar.createInstance(); await SingleInstanceCookieJar.createInstance();
try {
var futures = [ var futures = [
Rhttp.init(), Rhttp.init(),
App.initComponents(), App.initComponents(),
@@ -43,13 +46,22 @@ Future<void> init() async {
TagsTranslation.readData().wait(), TagsTranslation.readData().wait(),
JsEngine().init().wait(), JsEngine().init().wait(),
ComicSourceManager().init().wait(), ComicSourceManager().init().wait(),
OpenCC.init(),
]; ];
await Future.wait(futures); await Future.wait(futures);
} catch (e, s) {
Log.error("init", "$e\n$s");
}
CacheManager().setLimitSize(appdata.settings['cacheSize']); CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs(); _checkOldConfigs();
if (App.isAndroid) { if (App.isAndroid) {
handleLinks(); handleLinks();
handleTextShare(); handleTextShare();
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch(e) {
Log.error("Display Mode", "Failed to set high refresh rate: $e");
}
} }
FlutterError.onError = (details) { FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}"); Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
@@ -95,8 +107,7 @@ Future<void> _checkAppUpdates() async {
appdata.writeImplicitData(); appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate(); ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) { if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300)); await checkUpdateUi(false, true);
await checkUpdateUi(false);
} }
} }

View File

@@ -14,9 +14,14 @@ import 'components/components.dart';
import 'components/window_frame.dart'; import 'components/window_frame.dart';
import 'foundation/app.dart'; import 'foundation/app.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
import 'headless.dart';
import 'init.dart'; import 'init.dart';
void main(List<String> args) { void main(List<String> args) {
if (args.contains('--headless')) {
runHeadlessMode(args);
return;
}
if (runWebViewTitleBarWidget(args)) return; if (runWebViewTitleBarWidget(args)) return;
overrideIO(() { overrideIO(() {
runZonedGuarded(() async { runZonedGuarded(() async {
@@ -237,6 +242,27 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
); );
}; };
if (widget != null) { if (widget != null) {
/// 如果无法检测到状态栏高度设定指定高度
/// https://github.com/flutter/flutter/issues/161086
var isPaddingCheckError =
MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 50;
if (isPaddingCheckError) {
widget = MediaQuery(
data: MediaQuery.of(context).copyWith(
viewPadding: const EdgeInsets.only(
top: 15,
bottom: 15,
),
padding: const EdgeInsets.only(
top: 15,
bottom: 15,
),
),
child: widget);
}
widget = OverlayWidget(widget); widget = OverlayWidget(widget);
if (App.isDesktop) { if (App.isDesktop) {
widget = Shortcuts( widget = Shortcuts(

View File

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

View File

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

View File

@@ -552,7 +552,7 @@ class _ImageDownloadWrapper {
void start() async { void start() async {
int lastBytes = 0; int lastBytes = 0;
try { try {
await for (var p in ImageDownloader.loadComicImage( await for (var p in ImageDownloader.loadComicImageUnwrapped(
image, task.source.key, task.comicId, chapter)) { image, task.source.key, task.comicId, chapter)) {
if (isCancelled) { if (isCancelled) {
return; return;

View File

@@ -71,7 +71,8 @@ abstract class ImageDownloader {
} }
if (configs['onResponse'] is JSInvokable) { 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(); (configs['onResponse'] as JSInvokable).free();
} }
@@ -111,6 +112,11 @@ abstract class ImageDownloader {
return stream.stream; return stream.stream;
} }
static Stream<ImageDownloadProgress> loadComicImageUnwrapped(
String imageKey, String? sourceKey, String cid, String eid) {
return _loadComicImage(imageKey, sourceKey, cid, eid);
}
static Stream<ImageDownloadProgress> _loadComicImage( static Stream<ImageDownloadProgress> _loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* { String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
@@ -175,12 +181,17 @@ abstract class ImageDownloader {
} }
if (configs['onResponse'] is JSInvokable) { if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]); buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
(configs['onResponse'] as JSInvokable).free(); (configs['onResponse'] as JSInvokable).free();
} }
var data = Uint8List.fromList(buffer); Uint8List data;
if (buffer is Uint8List) {
data = buffer;
} else {
data = Uint8List.fromList(buffer);
buffer.clear(); buffer.clear();
}
if (configs['modifyImage'] != null) { if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript( var newData = await modifyImageWithScript(
@@ -234,6 +245,7 @@ class _StreamWrapper<T> {
} }
void _listen() async { void _listen() async {
try {
await for (var data in _stream) { await for (var data in _stream) {
if (isClosed) { if (isClosed) {
break; break;
@@ -244,11 +256,21 @@ class _StreamWrapper<T> {
} }
} }
} }
}
catch (e) {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.addError(e);
}
}
}
finally {
for (var controller in controllers) { for (var controller in controllers) {
if (!controller.isClosed) { if (!controller.isClosed) {
controller.close(); controller.close();
} }
} }
}
controllers.clear(); controllers.clear();
isClosed = true; isClosed = true;
onClosed(this); onClosed(this);

View File

@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
class _CategoryComicsPageState extends State<CategoryComicsPage> { class _CategoryComicsPageState extends State<CategoryComicsPage> {
late final CategoryComicsData data; late final CategoryComicsData data;
late final List<CategoryComicsOptions> options; late List<CategoryComicsOptions>? options;
late final CategoryOptionsLoader? optionsLoader;
late List<String> optionsValue; late List<String> optionsValue;
late String sourceKey; late String sourceKey;
String? error;
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
@@ -38,7 +40,8 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "The comic source ${source.name} does not support category comics"; throw "The comic source ${source.name} does not support category comics";
} }
data = source.categoryComicsData!; data = source.categoryComicsData!;
options = data.options.where((element) { if (data.options != null) {
options = data.options!.where((element) {
if (element.notShowWhen.contains(widget.category)) { if (element.notShowWhen.contains(widget.category)) {
return false; return false;
} else if (element.showWhen != null) { } else if (element.showWhen != null) {
@@ -46,16 +49,14 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
} }
return true; return true;
}).toList(); }).toList();
var defaultOptionsValue = } else {
options.map((e) => e.options.keys.first).toList(); options = null;
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.optionsLoader != null) {
optionsLoader = data.optionsLoader;
loadOptions();
} }
resetOptionsValue();
sourceKey = source.key; sourceKey = source.key;
return; return;
} }
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "${widget.categoryKey} Not found"; 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 @override
void initState() { void initState() {
if (widget.options != null) { if (widget.options != null) {
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var topPadding = context.padding.top + 56.0; 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( return Scaffold(
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: Appbar( appBar: Appbar(title: Text(widget.category)),
title: Text(widget.category), body: body,
),
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,
),
),
); );
} }
Widget buildOptionItem( Widget buildOptionItem(
String text, String value, int group, BuildContext context) { String text,
String value,
int group,
BuildContext context,
) {
return OptionChip( return OptionChip(
text: text.ts(sourceKey), text: text.ts(sourceKey),
isSelected: value == optionsValue[group], isSelected: value == optionsValue[group],
@@ -112,8 +160,26 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
Widget buildOptions() { Widget buildOptions() {
List<Widget> children = []; List<Widget> children = [];
for (var optionList in options) { var group = 0;
children.add(Wrap( 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, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
@@ -121,14 +187,30 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
buildOptionItem( buildOptionItem(
option.value.tl, option.value.tl,
option.key, option.key,
options.indexOf(optionList), group,
context, 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) { }
if (options!.last != optionList) {
children.add(const SizedBox(height: 8)); children.add(const SizedBox(height: 8));
} }
group++;
} }
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
class _NormalComicChaptersState extends State<_NormalComicChapters> { class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
} }
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
@@ -191,6 +192,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
if (history?.group != null) { if (history?.group != null) {
index = history!.group! - 1; index = history!.group! - 1;

View File

@@ -410,20 +410,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
String text; String text;
if (haveChapter) { if (haveChapter) {
var epName = "E$ep"; var epName = "E$ep";
String? groupName;
try { try {
epName = group == null if (group == null){
? comic.chapters!.titles.elementAt( epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1), math.min(ep - 1, comic.chapters!.length - 1),
) );
: comic.chapters! } else {
groupName = comic.chapters!.groups.elementAt(group - 1);
epName = comic.chapters!
.getGroupByIndex(group - 1) .getGroupByIndex(group - 1)
.values .values
.elementAt(ep - 1); .elementAt(ep - 1);
} }
}
catch(e) { catch(e) {
// ignore // ignore
} }
text = "${"Last Reading".tl}: $epName P$page"; text = groupName == null
? "${"Last Reading".tl}: $epName P$page"
: "${"Last Reading".tl}: $groupName $epName P$page";
} else { } else {
text = "${"Last Reading".tl}: P$page"; text = "${"Last Reading".tl}: P$page";
} }

View File

@@ -18,6 +18,57 @@ import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatelessWidget { class ComicSourcePage extends StatelessWidget {
const ComicSourcePage({super.key}); 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 { static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) { if (ComicSource.all().isEmpty) {
return 0; return 0;
@@ -51,9 +102,7 @@ class ComicSourcePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(body: const _Body());
body: const _Body(),
);
} }
} }
@@ -87,10 +136,7 @@ class _BodyState extends State<_Body> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar( SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) for (var source in ComicSource.all())
_SliverComicSource( _SliverComicSource(
@@ -109,9 +155,7 @@ class _BodyState extends State<_Body> {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
title: "Delete".tl, title: "Delete".tl,
content: "Delete comic source '@n' ?".tlParams({ content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
"n": source.name,
}),
btnColor: context.colorScheme.error, btnColor: context.colorScheme.error,
onConfirm: () { onConfirm: () {
var file = File(source.filePath); var file = File(source.filePath);
@@ -134,13 +178,15 @@ class _BodyState extends State<_Body> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("cancel")), child: const Text("cancel"),
),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ComicSourceManager().reload(); await ComicSourceManager().reload();
App.forceRebuild(); App.forceRebuild();
}, },
child: const Text("continue")), child: const Text("continue"),
),
], ],
), ),
); );
@@ -157,49 +203,11 @@ class _BodyState extends State<_Body> {
); );
} }
static Future<void> update(ComicSource source, void update(ComicSource source, [bool showLoading = true]) {
[bool showLoading = true]) async { ComicSourcePage.update(source, showLoading);
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();
} }
Widget buildCard(BuildContext context) { Widget buildCard(BuildContext context) {
Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
}
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
@@ -218,16 +226,21 @@ class _BodyState extends State<_Body> {
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton( suffix: IconButton(
onPressed: () => handleAddSource(url), onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))), icon: const Icon(Icons.check),
),
),
onChanged: (value) { onChanged: (value) {
url = value; url = value;
}, },
onSubmitted: handleAddSource, onSubmitted: handleAddSource,
).paddingHorizontal(16).paddingBottom(8), ).paddingHorizontal(16).paddingBottom(8),
ListTile( Wrap(
title: Text("Comic Source list".tl), spacing: 8,
trailing: buildButton( runSpacing: 8,
child: Text("View".tl), children: [
FilledButton.tonalIcon(
icon: Icon(Icons.article_outlined),
label: Text("Comic Source list".tl),
onPressed: () { onPressed: () {
showPopUpWidget( showPopUpWidget(
App.rootContext, App.rootContext,
@@ -235,25 +248,19 @@ class _BodyState extends State<_Body> {
); );
}, },
), ),
), FilledButton.tonalIcon(
ListTile( icon: Icon(Icons.file_open_outlined),
title: Text("Use a config file".tl), label: Text("Use a config file".tl),
trailing: buildButton(
onPressed: _selectFile, onPressed: _selectFile,
child: Text("Select".tl),
), ),
), FilledButton.tonalIcon(
ListTile( icon: Icon(Icons.help_outline),
title: Text("Help".tl), label: Text("Help".tl),
trailing: buildButton(
onPressed: help, onPressed: help,
child: Text("Open".tl),
),
),
ListTile(
title: Text("Check updates".tl),
trailing: _CheckUpdatesButton(),
), ),
_CheckUpdatesButton(),
],
).paddingHorizontal(12).paddingVertical(8),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -277,7 +284,8 @@ class _BodyState extends State<_Body> {
void help() { void help() {
launchUrlString( launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md"); "https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
} }
Future<void> handleAddSource(String url) async { Future<void> handleAddSource(String url) async {
@@ -288,11 +296,19 @@ class _BodyState extends State<_Body> {
splits.removeWhere((element) => element == ""); splits.removeWhere((element) => element == "");
var fileName = splits.last; var fileName = splits.last;
bool cancel = false; bool cancel = false;
var controller = showLoadingDialog(App.rootContext, var controller = showLoadingDialog(
onCancel: () => cancel = true, barrierDismissible: false); App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try { try {
var res = await AppDio() var res = await AppDio().get<String>(
.get<String>(url, options: Options(responseType: ResponseType.plain)); url,
options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
);
if (cancel) return; if (cancel) return;
controller.close(); controller.close();
await addSource(res.data!, fileName); await addSource(res.data!, fileName);
@@ -332,6 +348,12 @@ class _ComicSourceListState extends State<_ComicSourceList> {
json = null; json = null;
}); });
} }
if (controller.text.isEmpty) {
setState(() {
json = [];
});
return;
}
var dio = AppDio(); var dio = AppDio();
try { try {
var res = await dio.get<String>(controller.text); var res = await dio.get<String>(controller.text);
@@ -343,8 +365,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
json = jsonDecode(res.data!); json = jsonDecode(res.data!);
}); });
} }
} } catch (e) {
catch(e) {
context.showMessage(message: "Network error".tl); context.showMessage(message: "Network error".tl);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -372,10 +393,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
title: "Comic Source".tl,
body: buildBody(),
);
} }
Widget buildBody() { Widget buildBody() {
@@ -399,32 +417,36 @@ class _ComicSourceListState extends State<_ComicSourceList> {
children: [ children: [
ListTile( ListTile(
leading: Icon(Icons.source_outlined), leading: Icon(Icons.source_outlined),
title: Text("Source URL".tl), title: Text("Repo URL".tl),
), ),
TextField( TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "URL", hintText: "URL",
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 12),
const EdgeInsets.symmetric(horizontal: 12),
), ),
onChanged: (value) { onChanged: (value) {
changed = true; changed = true;
}, },
).paddingHorizontal(16).paddingBottom(8), ).paddingHorizontal(16).paddingBottom(8),
Text("The URL should point to a 'index.json' file".tl).paddingLeft(16), Text(
Text("Do not report any issues related to sources to App repo.".tl).paddingLeft(16), "The URL should point to a 'index.json' file".tl,
).paddingLeft(16),
Text(
"Do not report any issues related to sources to App repo.".tl,
).paddingLeft(16),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: () { onPressed: () {
controller.text = defaultComicSourceUrl; launchUrlString(
changed = true; "https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
}, },
child: Text("Reset".tl), child: Text("Help".tl),
), ),
FilledButton.tonal( FilledButton.tonal(
onPressed: load, onPressed: load,
@@ -440,7 +462,11 @@ class _ComicSourceListState extends State<_ComicSourceList> {
} }
if (index == 1 && json == null) { if (index == 1 && json == null) {
return Center(child: CircularProgressIndicator()); return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
);
} }
index--; index--;
@@ -551,8 +577,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) { !networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key); networkFavorites.add(source.favoriteData!.key);
} }
if (source.searchPageData != null && if (source.searchPageData != null && !searchPages.contains(source.key)) {
!searchPages.contains(source.key)) {
searchPages.add(source.key); searchPages.add(source.key);
} }
@@ -594,15 +619,10 @@ class __EditFilePageState extends State<_EditFilePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(title: Text("Edit".tl)),
title: Text("Edit".tl),
),
body: Column( body: Column(
children: [ children: [
Container( Container(height: 0.6, color: context.colorScheme.outlineVariant),
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Expanded( Expanded(
child: CodeEditor( child: CodeEditor(
initialValue: current, initialValue: current,
@@ -643,9 +663,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} }
void showUpdateDialog() async { void showUpdateDialog() async {
var text = ComicSourceManager().availableUpdates.entries.map((e) { var text = ComicSourceManager().availableUpdates.entries
.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}"; return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n"); })
.join("\n");
bool doUpdate = false; bool doUpdate = false;
await showDialog( await showDialog(
context: App.rootContext, context: App.rootContext,
@@ -677,7 +699,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList(); var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
await _BodyState.update(source, false); await ComicSourcePage.update(source, false);
current++; current++;
loadingController.setProgress(current / total); loadingController.setProgress(current / total);
} }
@@ -690,11 +712,17 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Button.normal( return FilledButton.tonalIcon(
icon: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.update),
label: Text("Check updates".tl),
onPressed: check, onPressed: check,
isLoading: isLoading, );
child: Text("Check".tl),
).fixHeight(32);
} }
} }
@@ -783,10 +811,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [
Text( Text(source.name, style: ts.s18),
source.name,
style: ts.s18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -819,7 +844,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
).paddingLeft(4) ).paddingLeft(4),
], ],
), ),
trailing: Row( trailing: Row(
@@ -864,15 +889,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(children: buildSourceSettings().toList()),
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
), ),
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
], ],
); );
} }
@@ -898,8 +917,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
} }
} }
} else { } else {
current = item.value['options'] current =
.firstWhere((e) => e['value'] == current)['text'] ?? item.value['options'].firstWhere(
(e) => e['value'] == current,
)['text'] ??
current; current;
} }
yield ListTile( yield ListTile(
@@ -907,8 +928,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: Select( trailing: Select(
current: (current as String).ts(source.key), current: (current as String).ts(source.key),
values: (item.value['options'] as List) values: (item.value['options'] as List)
.map<String>((e) => .map<String>(
((e['text'] ?? e['value']) as String).ts(source.key)) (e) => ((e['text'] ?? e['value']) as String).ts(source.key),
)
.toList(), .toList(),
onTap: (i) { onTap: (i) {
source.data['settings'][key] = source.data['settings'][key] =
@@ -936,8 +958,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data['settings'][key] ?? item.value['default'] ?? ''; source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile( yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)), title: Text((item.value['title'] as String).ts(source.key)),
subtitle: subtitle: Text(
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), current,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () { onPressed: () {
@@ -978,10 +1003,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () async { onTap: () async {
await context.to( await context.to(
() => _LoginPage( () => _LoginPage(config: source.account!, source: source),
config: source.account!,
source: source,
),
); );
source.saveData(); source.saveData();
setState(() {}); setState(() {});
@@ -1027,9 +1049,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: loading trailing: loading
? const SizedBox.square( ? const SizedBox.square(
dimension: 24, dimension: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(strokeWidth: 2),
strokeWidth: 2,
),
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
); );
@@ -1070,9 +1090,7 @@ class _LoginPageState extends State<_LoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const Appbar( appBar: const Appbar(title: Text('')),
title: Text(''),
),
body: Center( body: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1200,8 +1218,9 @@ class _LoginPageState extends State<_LoginPage> {
setState(() { setState(() {
loading = true; loading = true;
}); });
var cookies = var cookies = widget.config.cookieFields!
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList(); .map((e) => _cookies[e] ?? '')
.toList();
widget.config.validateCookies!(cookies).then((value) { widget.config.validateCookies!(cookies).then((value) {
if (value) { if (value) {
widget.source.data['account'] = 'ok'; widget.source.data['account'] = 'ok';

View File

@@ -20,6 +20,7 @@ import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -66,6 +67,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
folder = data['name']; folder = data['name'];
isNetwork = data['isNetwork'] ?? false; isNetwork = data['isNetwork'] ?? false;
} }
if (folder != null
&& !isNetwork
&& !LocalFavoritesManager().existsFolder(folder!)) {
folder = null;
}
super.initState(); super.initState();
} }

View File

@@ -52,7 +52,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} else { } else {
searchResults = []; searchResults = [];
for (var comic in comics) { for (var comic in comics) {
if (matchKeyword(keyword, comic)) { if (matchKeyword(keyword, comic) ||
matchKeywordT(keyword, comic) ||
matchKeywordS(keyword, comic)) {
searchResults.add(comic); searchResults.add(comic);
} }
} }
@@ -130,6 +132,24 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return true; return true;
} }
// Convert keyword to traditional Chinese to match comics
bool matchKeywordT(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseSimplified(keyword)) {
return false;
}
keyword = OpenCC.simplifiedToTraditional(keyword);
return matchKeyword(keyword, comic);
}
// Convert keyword to simplified Chinese to match comics
bool matchKeywordS(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseTraditional(keyword)) {
return false;
}
keyword = OpenCC.traditionalToSimplified(keyword);
return matchKeyword(keyword, comic);
}
@override @override
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
@@ -155,16 +175,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() { void selectAll() {
setState(() { setState(() {
if (searchMode) {
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
} else {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
}
}); });
} }
void invertSelection() { void invertSelection() {
setState(() { setState(() {
comics.asMap().forEach((k, v) { if (searchMode) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); for (var c in searchResults) {
}); if (selectedComics.containsKey(c)) {
selectedComics.removeWhere((k, v) => !v); selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
} else {
for (var c in comics) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
}
}); });
} }
@@ -416,10 +453,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})), "Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [ actions: [
MenuButton(entries: [ MenuButton(entries: [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.drive_file_move, icon: Icons.drive_file_move,
text: "Move to folder".tl, text: "Move to folder".tl,
onClick: () => favoriteOption('move')), onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
text: "Copy to folder".tl, text: "Copy to folder".tl,
@@ -756,32 +795,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return; return;
} }
if (option == 'move') { if (option == 'move') {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().moveFavorite( .toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchMoveFavorites(
favPage.folder as String, favPage.folder as String,
s, f,
c.id, comics,
(c as FavoriteItem).type);
}
}
} else {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().addComic(
s,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
author: c.subtitle ?? '',
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
); );
} }
} else {
var comics = selectedComics.keys
.map((e) => e as FavoriteItem)
.toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchCopyFavorites(
favPage.folder as String,
f,
comics,
);
} }
} }
App.rootContext.pop(); App.rootContext.pop();
@@ -817,13 +850,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
void _deleteComicWithId() { void _deleteComicWithId() {
for (var c in selectedComics.keys) { var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().deleteComicWithId( LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
_cancel(); _cancel();
} }
} }
@@ -864,7 +892,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override @override
void dispose() { void dispose() {
if (changed) { if (changed) {
// Delay to ensure navigation is completed
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().reorder(comics, widget.name); LocalFavoritesManager().reorder(comics, widget.name);
});
} }
super.dispose(); super.dispose();
} }
@@ -899,7 +930,9 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar( appBar: Appbar(
title: Text("Reorder".tl), title: Text("Reorder".tl),
actions: [ actions: [
IconButton( Tooltip(
message: "Information".tl,
child: IconButton(
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
onPressed: () { onPressed: () {
showInfoDialog( showInfoDialog(
@@ -909,17 +942,19 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
}, },
), ),
IconButton( ),
Tooltip(
message: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert), icon: const Icon(Icons.swap_vert),
onPressed: () { onPressed: () {
setState(() { setState(() {
comics = comics.reversed.toList(); comics = comics.reversed.toList();
changed = true; changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
}); });
}, },
), ),
)
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( body: ReorderableBuilder<FavoriteItem>(

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
findNetworkFolders(); findNetworkFolders();
appdata.settings.addListener(updateFolders); appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState(); super.initState();
} }
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() { void dispose() {
super.dispose(); super.dispose();
appdata.settings.removeListener(updateFolders); appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
} }
@override @override

View File

@@ -5,10 +5,10 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart'; import '../foundation/global_state.dart';
import 'package:venera/foundation/follow_updates.dart';
class FollowUpdatesWidget extends StatefulWidget { class FollowUpdatesWidget extends StatefulWidget {
const FollowUpdatesWidget({super.key}); const FollowUpdatesWidget({super.key});
@@ -460,7 +460,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
message: "Updating comics...".tl, message: "Updating comics...".tl,
); );
await for (var progress in _updateFolder(folder, true)) { await for (var progress in updateFolder(folder, true)) {
if (isCanceled) { if (isCanceled) {
return; return;
} }
@@ -497,7 +497,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
int updated = 0; int updated = 0;
await for (var progress in _updateFolder(folder!, true)) { await for (var progress in updateFolder(folder!, true)) {
if (isCanceled) { if (isCanceled) {
return; return;
} }
@@ -532,128 +532,6 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
Object? get key => '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 /// Background service for checking updates
abstract class FollowUpdatesService { abstract class FollowUpdatesService {
static bool _isChecking = false; static bool _isChecking = false;
@@ -683,7 +561,7 @@ abstract class FollowUpdatesService {
int updated = 0; int updated = 0;
try { try {
await for (var progress in _updateFolder(folder, false)) { await for (var progress in updateFolder(folder, false)) {
if (isCanceled) { if (isCanceled) {
return; return;
} }

View File

@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
title: 'Clear History'.tl, title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl), content: Text('Are you sure you want to clear your history?'.tl),
actions: [ actions: [
Button.outlined(
onPressed: () {
HistoryManager().clearUnfavoritedHistory();
context.pop();
},
child: Text('Clear Unfavorited'.tl),
),
const SizedBox(width: 4),
Button.filled( Button.filled(
color: context.colorScheme.error, color: context.colorScheme.error,
onPressed: () { onPressed: () {

View File

@@ -14,6 +14,7 @@ import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key}); const LocalComicsPage({super.key});
@@ -143,6 +144,14 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
addFavorite(selectedComics.keys.toList()); addFavorite(selectedComics.keys.toList());
}, },
), ),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.folder_open,
text: "Open Folder".tl,
onClick: () {
openComicFolder(selectedComics.keys.first);
},
),
if (selectedComics.length == 1) if (selectedComics.length == 1)
MenuEntry( MenuEntry(
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
@@ -313,6 +322,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}, },
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
MenuEntry(
icon: Icons.folder_open,
text: "Open Folder".tl,
onClick: () {
openComicFolder(c as LocalComic);
},
),
MenuEntry( MenuEntry(
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
@@ -361,10 +377,22 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
bool removeComicFile = true; bool removeComicFile = true;
bool removeFavoriteAndHistory = true;
return StatefulBuilder(builder: (context, state) { return StatefulBuilder(builder: (context, state) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: CheckboxListTile( content: Column(
children: [
CheckboxListTile(
title: Text("Remove local favorite and history".tl),
value: removeFavoriteAndHistory,
onChanged: (v) {
state(() {
removeFavoriteAndHistory = !removeFavoriteAndHistory;
});
},
),
CheckboxListTile(
title: Text("Also remove files on disk".tl), title: Text("Also remove files on disk".tl),
value: removeComicFile, value: removeComicFile,
onChanged: (v) { onChanged: (v) {
@@ -372,17 +400,26 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
removeComicFile = !removeComicFile; removeComicFile = !removeComicFile;
}); });
}, },
)
],
), ),
actions: [ actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
context.pop(); context.pop();
for (var comic in comics) { LocalManager().batchDeleteComics(
LocalManager().deleteComic( comics,
comic,
removeComicFile, removeComicFile,
removeFavoriteAndHistory,
); );
}
isDeleted = true; isDeleted = true;
}, },
child: Text("Confirm".tl), child: Text("Confirm".tl),
@@ -497,3 +534,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function( typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath); LocalComic comic, String outFilePath);
/// Opens the folder containing the comic in the system file explorer
Future<void> openComicFolder(LocalComic comic) async {
try {
final folderPath = comic.baseDir;
if (App.isWindows) {
await Process.run('explorer', [folderPath]);
} else if (App.isMacOS) {
await Process.run('open', [folderPath]);
} else if (App.isLinux) {
// Try different file managers commonly found on Linux
try {
await Process.run('xdg-open', [folderPath]);
} catch (e) {
// Fallback to other common file managers
try {
await Process.run('nautilus', [folderPath]);
} catch (e) {
try {
await Process.run('dolphin', [folderPath]);
} catch (e) {
try {
await Process.run('thunar', [folderPath]);
} catch (e) {
// Last resort: use the URL launcher with file:// protocol
await launchUrlString('file://$folderPath');
}
}
}
}
} else {
// For mobile platforms, use the URL launcher with file:// protocol
await launchUrlString('file://$folderPath');
}
} catch (e, s) {
Log.error("Open Folder", "Failed to open comic folder: $e", s);
// Show error message to user
if (App.rootContext.mounted) {
App.rootContext.showMessage(message: "Failed to open folder: $e");
}
}
}
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
var chapters = <String>[];
showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Delete Chapters".tl,
body: StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: comic.downloadedChapters.length,
itemBuilder: (context, index) {
var id = comic.downloadedChapters[index];
var chapter = comic.chapters![id] ?? "Unknown Chapter";
return CheckboxListTile(
title: Text(chapter),
value: chapters.contains(id),
onChanged: (v) {
setState(() {
if (v == true) {
chapters.add(id);
} else {
chapters.remove(id);
}
});
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: () {
Future.delayed(const Duration(milliseconds: 200), () {
LocalManager().deleteComicChapters(comic, chapters);
});
App.rootContext.pop();
},
child: Text("Submit".tl),
)
],
),
)
],
);
}),
),
);
}

View File

@@ -152,7 +152,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false; bool _dragInProgress = false;
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"]; bool get _enableDoubleTapToZoom =>
appdata.settings.getReaderSetting(reader.cid, reader.type.sourceKey, 'enableDoubleTapToZoom');
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) { if (_longPressInProgress) {
@@ -190,7 +191,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
} else if (context.readerScaffold.isOpen) { } else if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose(); context.readerScaffold.openOrClose();
} else { } else {
if (appdata.settings['enableTapToTurnPages']) { if (appdata.settings.getReaderSetting(
reader.cid, reader.type.sourceKey, 'enableTapToTurnPages')) {
bool isLeft = false, isRight = false, isTop = false, isBottom = false; bool isLeft = false, isRight = false, isTop = false, isBottom = false;
final width = context.width; final width = context.width;
final height = context.height; final height = context.height;
@@ -207,11 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
isBottom = true; isBottom = true;
} }
bool isCenter = false; bool isCenter = false;
var prev = context.reader.toPrevPage; var prev = () => context.reader.toPrevPage();
var next = context.reader.toNextPage; var next = () => context.reader.toNextPage();
if (appdata.settings['reverseTapToTurnPages']) { if (appdata.settings.getReaderSetting(
prev = context.reader.toNextPage; reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
next = context.reader.toPrevPage; prev = () => context.reader.toNextPage();
next = () => context.reader.toPrevPage();
} }
switch (context.reader.mode) { switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight: case ReaderMode.galleryLeftToRight:

View File

@@ -32,14 +32,24 @@ class _ReaderImagesState extends State<_ReaderImages> {
inProgress = true; inProgress = true;
if (reader.type == ComicType.local || if (reader.type == ComicType.local ||
(LocalManager().isDownloaded( (LocalManager().isDownloaded(
reader.cid, reader.type, reader.chapter, reader.widget.chapters))) { reader.cid,
reader.type,
reader.chapter,
reader.widget.chapters,
))) {
try { try {
var images = await LocalManager() var images = await LocalManager().getImages(
.getImages(reader.cid, reader.type, reader.chapter); reader.cid,
reader.type,
reader.chapter,
);
setState(() { setState(() {
reader.images = images; reader.images = images;
reader.isLoading = false; reader.isLoading = false;
inProgress = false; inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
}); });
} catch (e) { } catch (e) {
setState(() { setState(() {
@@ -65,6 +75,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = res.data; reader.images = res.data;
reader.isLoading = false; reader.isLoading = false;
inProgress = false; inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
}); });
} }
} }
@@ -75,11 +88,14 @@ class _ReaderImagesState extends State<_ReaderImages> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (reader.isLoading) { if (reader.isLoading) {
load(); load();
return const Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(),
);
} else if (error != null) { } else if (error != null) {
return NetworkError( return GestureDetector(
onTap: () {
context.readerScaffold.openOrClose();
},
child: SizedBox.expand(
child: NetworkError(
message: error!, message: error!,
retry: () { retry: () {
setState(() { setState(() {
@@ -87,11 +103,14 @@ class _ReaderImagesState extends State<_ReaderImages> {
error = null; error = null;
}); });
}, },
),
),
); );
} else { } else {
if (reader.mode.isGallery) { if (reader.mode.isGallery) {
return _GalleryMode( return _GalleryMode(
key: Key('${reader.mode.key}_${reader.imagesPerPage}')); key: Key('${reader.mode.key}_${reader.imagesPerPage}'),
);
} else { } else {
return _ContinuousMode(key: Key(reader.mode.key)); return _ContinuousMode(key: Key(reader.mode.key));
} }
@@ -119,11 +138,15 @@ class _GalleryModeState extends State<_GalleryMode>
/// [totalPages] is the total number of pages in the current chapter. /// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page. /// More than one images can be displayed on one page.
int get totalPages { int get totalPages {
if (!reader.showSingleImageOnFirstPage) { if (!reader.showSingleImageOnFirstPage()) {
return (reader.images!.length / reader.imagesPerPage).ceil(); return (reader.images!.length /
reader.imagesPerPage())
.ceil();
} else { } else {
return 1 + return 1 +
((reader.images!.length - 1) / reader.imagesPerPage).ceil(); ((reader.images!.length - 1) /
reader.imagesPerPage())
.ceil();
} }
} }
@@ -146,19 +169,24 @@ class _GalleryModeState extends State<_GalleryMode>
/// Get the range of images for the given page. [page] is 1-based. /// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) { (int start, int end) getPageImagesRange(int page) {
if (reader.showSingleImageOnFirstPage) { var imagesPerPage = reader.imagesPerPage();
if (reader.showSingleImageOnFirstPage()) {
if (page == 1) { if (page == 1) {
return (0, 1); return (0, 1);
} else { } else {
int startIndex = (page - 2) * reader.imagesPerPage + 1; int startIndex = (page - 2) * imagesPerPage + 1;
int endIndex = math.min( int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length); startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex); return (startIndex, endIndex);
} }
} else { } else {
int startIndex = (page - 1) * reader.imagesPerPage; int startIndex = (page - 1) * imagesPerPage;
int endIndex = math.min( int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length); startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex); return (startIndex, endIndex);
} }
} }
@@ -204,16 +232,12 @@ class _GalleryModeState extends State<_GalleryMode>
var controller = photoViewControllers[reader.page]!; var controller = photoViewControllers[reader.page]!;
Offset value = event.delta; Offset value = event.delta;
if (isLongPressing) { if (isLongPressing) {
controller.updateMultiple( controller.updateMultiple(position: controller.position + value);
position: controller.position + value,
);
} }
} }
}, },
child: PhotoViewGallery.builder( child: PhotoViewGallery.builder(
backgroundDecoration: BoxDecoration( backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
color: context.colorScheme.surface,
),
reverse: reader.mode == ReaderMode.galleryRightToLeft, reverse: reader.mode == ReaderMode.galleryRightToLeft,
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
? Axis.vertical ? Axis.vertical
@@ -226,14 +250,17 @@ class _GalleryModeState extends State<_GalleryMode>
); );
} else { } else {
var (startIndex, endIndex) = getPageImagesRange(index); var (startIndex, endIndex) = getPageImagesRange(index);
List<String> pageImages = List<String> pageImages = reader.images!.sublist(
reader.images!.sublist(startIndex, endIndex); startIndex,
endIndex,
);
cache(index); cache(index);
photoViewControllers[index] ??= PhotoViewController(); photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) { if (reader.imagesPerPage() == 1 ||
pageImages.length == 1) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
controller: photoViewControllers[index], controller: photoViewControllers[index],
@@ -343,13 +370,16 @@ class _GalleryModeState extends State<_GalleryMode>
onInit: (state) => imageStates.add(state), onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state), onDispose: (state) => imageStates.remove(state),
), ),
) ),
]; ];
} else { } else {
imageWidgets = images.map((imageKey) { imageWidgets = images.map((imageKey) {
startIndex++; startIndex++;
ImageProvider imageProvider = ImageProvider imageProvider = _createImageProviderFromKey(
_createImageProviderFromKey(imageKey, context, startIndex); imageKey,
context,
startIndex,
);
return Expanded( return Expanded(
child: ComicImage( child: ComicImage(
image: imageProvider, image: imageProvider,
@@ -410,10 +440,7 @@ class _GalleryModeState extends State<_GalleryMode>
} else { } else {
zoomPosition = Offset(0, 0); zoomPosition = Offset(0, 0);
} }
photoViewController.animateScale?.call( photoViewController.animateScale?.call(target, zoomPosition);
target,
zoomPosition,
);
isLongPressing = true; isLongPressing = true;
} }
@@ -465,7 +492,7 @@ class _GalleryModeState extends State<_GalleryMode>
} }
if (event is KeyRepeatEvent && keyRepeatTimer == null) { if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic( keyRepeatTimer = Timer.periodic(
reader.enablePageAnimation reader.enablePageAnimation(reader.cid, reader.type)
? const Duration(milliseconds: 200) ? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50), : const Duration(milliseconds: 50),
(timer) { (timer) {
@@ -499,15 +526,15 @@ class _GalleryModeState extends State<_GalleryMode>
return await File(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
.readAsBytes(); ))!.readAsBytes();
} }
} }
@override @override
String? getImageKeyByOffset(Offset offset) { String? getImageKeyByOffset(Offset offset) {
String? imageKey; String? imageKey;
if (reader.imagesPerPage == 1) { if (reader.imagesPerPage() == 1) {
imageKey = reader.images![reader.page - 1]; imageKey = reader.images![reader.page - 1];
} else { } else {
for (var imageState in imageStates) { for (var imageState in imageStates) {
@@ -525,7 +552,7 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.mouse, PointerDeviceKind.mouse,
PointerDeviceKind.stylus, PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus, PointerDeviceKind.invertedStylus,
PointerDeviceKind.unknown PointerDeviceKind.unknown,
}; };
const double _kChangeChapterOffset = 160; const double _kChangeChapterOffset = 160;
@@ -611,27 +638,52 @@ class _ContinuousModeState extends State<_ContinuousMode>
cacheImages(page); cacheImages(page);
} }
double? futurePosition; double? _futurePosition;
void smoothTo(double offset) { void smoothTo(double offset) {
futurePosition ??= scrollController.offset; if (HardwareKeyboard.instance.isShiftPressed) {
if (futurePosition! > scrollController.position.maxScrollExtent &&
offset > 0) {
return;
} else if (futurePosition! < scrollController.position.minScrollExtent &&
offset < 0) {
return; return;
} }
futurePosition = futurePosition! + offset * 1.2; var currentLocation = scrollController.position.pixels;
futurePosition = futurePosition!.clamp( 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.minScrollExtent,
scrollController.position.maxScrollExtent, scrollController.position.maxScrollExtent,
); );
scrollController.animateTo( var afterOffset = (_futurePosition! - currentLocation).abs();
futurePosition!, if (_futurePosition == old) return;
duration: const Duration(milliseconds: 200), 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, curve: Curves.linear,
); )
.then((_) {
var current = scrollController.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
} }
void onPointerSignal(PointerSignalEvent event) { void onPointerSignal(PointerSignalEvent event) {
@@ -660,10 +712,12 @@ class _ContinuousModeState extends State<_ContinuousMode>
void onScroll() { void onScroll() {
if (prepareToPrevChapter) { if (prepareToPrevChapter) {
jumpToNextChapter = false; jumpToNextChapter = false;
jumpToPrevChapter = scrollController.offset < jumpToPrevChapter =
scrollController.offset <
scrollController.position.minScrollExtent - _kChangeChapterOffset; scrollController.position.minScrollExtent - _kChangeChapterOffset;
} else if (prepareToNextChapter) { } else if (prepareToNextChapter) {
jumpToNextChapter = scrollController.offset > jumpToNextChapter =
scrollController.offset >
scrollController.position.maxScrollExtent + _kChangeChapterOffset; scrollController.position.maxScrollExtent + _kChangeChapterOffset;
jumpToPrevChapter = false; jumpToPrevChapter = false;
} }
@@ -737,8 +791,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
), ),
); );
}, },
scrollBehavior: const MaterialScrollBehavior() scrollBehavior: const MaterialScrollBehavior().copyWith(
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes), scrollbars: false,
dragDevices: _kTouchLikeDeviceTypes,
),
); );
widget = Stack( widget = Stack(
@@ -756,7 +812,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
disableScroll = true; disableScroll = true;
}); });
} }
futurePosition = null; _futurePosition = null;
if (_isMouseScrolling) { if (_isMouseScrolling) {
setState(() { setState(() {
_isMouseScrolling = false; _isMouseScrolling = false;
@@ -882,20 +938,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
return PhotoView.customChild( return PhotoView.customChild(
backgroundDecoration: BoxDecoration( backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
color: context.colorScheme.surface,
),
childSize: Size(width, height), childSize: Size(width, height),
minScale: 1.0, minScale: 1.0,
maxScale: 2.5, maxScale: 2.5,
strictScale: true, strictScale: true,
controller: photoViewController, controller: photoViewController,
onScaleUpdate: onScaleUpdate, onScaleUpdate: onScaleUpdate,
child: SizedBox( child: SizedBox(width: width, height: height, child: widget),
width: width,
height: height,
child: widget,
),
); );
} }
@@ -965,10 +1015,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
} else { } else {
zoomPosition = Offset(0, 0); zoomPosition = Offset(0, 0);
} }
photoViewController.animateScale?.call( photoViewController.animateScale?.call(target, zoomPosition);
target,
zoomPosition,
);
onScaleUpdate(target); onScaleUpdate(target);
isLongPressing = true; isLongPressing = true;
} }
@@ -987,7 +1034,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override @override
void toPage(int page) { void toPage(int page) {
itemScrollController.jumpTo(index: page); itemScrollController.jumpTo(index: page);
futurePosition = null; _futurePosition = null;
} }
@override @override
@@ -1056,8 +1103,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
return await File(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
.readAsBytes(); ))!.readAsBytes();
} }
} }
@@ -1101,10 +1148,7 @@ void _precacheImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) { if (page <= 0 || page > context.reader.images!.length) {
return; return;
} }
precacheImage( precacheImage(_createImageProvider(page, context), context);
_createImageProvider(page, context),
context,
);
} }
/// [_preDownloadImage] is used to download the image for the given page. /// [_preDownloadImage] is used to download the image for the given page.
@@ -1125,10 +1169,7 @@ void _preDownloadImage(int page, BuildContext context) {
} }
class _SwipeChangeChapterProgress extends StatefulWidget { class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({ const _SwipeChangeChapterProgress({this.controller, required this.isPrev});
this.controller,
required this.isPrev,
});
final ScrollController? controller; final ScrollController? controller;
@@ -1245,7 +1286,12 @@ class _ProgressPainter extends CustomPainter {
paint.color = color; paint.color = color;
canvas.drawRRect( canvas.drawRRect(
RRect.fromLTRBR( RRect.fromLTRBR(
0, 0, size.width * value, size.height, Radius.circular(16)), 0,
0,
size.width * value,
size.height,
Radius.circular(16),
),
paint, paint,
); );
} }

View File

@@ -115,15 +115,17 @@ class _ReaderState extends State<Reader>
if (images == null) { if (images == null) {
return 1; return 1;
} }
if (!showSingleImageOnFirstPage) { if (!showSingleImageOnFirstPage()) {
return (images!.length / imagesPerPage).ceil(); return (images!.length / imagesPerPage()).ceil();
} else { } else {
return 1 + ((images!.length - 1) / imagesPerPage).ceil(); return 1 + ((images!.length - 1) / imagesPerPage()).ceil();
} }
} }
@override
ComicType get type => widget.type; ComicType get type => widget.type;
@override
String get cid => widget.cid; String get cid => widget.cid;
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0'; String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
@@ -162,13 +164,13 @@ class _ReaderState extends State<Reader>
if (widget.initialPage != null) { if (widget.initialPage != null) {
page = widget.initialPage!; page = widget.initialPage!;
} }
mode = ReaderMode.fromKey(appdata.settings['readerMode']); // mode = ReaderMode.fromKey(appdata.settings['readerMode']);
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
history = widget.history; history = widget.history;
Future.microtask(() { if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (appdata.settings['enableTurnPageByVolumeKey']) { }
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
handleVolumeEvent(); handleVolumeEvent();
} }
setImageCacheSize(); setImageCacheSize();
@@ -178,10 +180,18 @@ class _ReaderState extends State<Reader>
super.initState(); super.initState();
} }
bool _isInitialized = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_isInitialized) {
initImagesPerPage(widget.initialPage ?? 1); initImagesPerPage(widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange();
}
initReaderWindow(); initReaderWindow();
} }
@@ -267,7 +277,15 @@ class _ReaderState extends State<Reader>
history!.page = images?.length ?? 1; history!.page = images?.length ?? 1;
} else { } else {
/// Record the first image of the page /// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1; if (!showSingleImageOnFirstPage() || imagesPerPage() == 1) {
history!.page = (page - 1) * imagesPerPage() + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage() + 2;
}
}
} }
history!.maxPage = images?.length ?? 1; history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) { if (widget.chapters?.isGrouped ?? false) {
@@ -338,6 +356,8 @@ class _ReaderState extends State<Reader>
abstract mixin class _ImagePerPageHandler { abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage; late int _lastImagesPerPage;
late bool _lastOrientation;
bool get isPortrait; bool get isPortrait;
int get page; int get page;
@@ -346,42 +366,72 @@ abstract mixin class _ImagePerPageHandler {
ReaderMode get mode; ReaderMode get mode;
String get cid;
ComicType get type;
void initImagesPerPage(int initialPage) { void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage; _lastImagesPerPage = imagesPerPage();
if (imagesPerPage != 1) { _lastOrientation = isPortrait;
page = (initialPage / imagesPerPage).ceil(); if (imagesPerPage() != 1) {
if (showSingleImageOnFirstPage()) {
page = ((initialPage - 1) / imagesPerPage()).ceil() + 1;
} else {
page = (initialPage / imagesPerPage()).ceil();
}
} }
} }
bool get showSingleImageOnFirstPage => bool showSingleImageOnFirstPage() =>
appdata.settings["showSingleImageOnFirstPage"]; appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
/// The number of images displayed on one screen /// The number of images displayed on one screen
int get imagesPerPage { int imagesPerPage() {
if (mode.isContinuous) return 1; if (mode.isContinuous) return 1;
if (isPortrait) { if (isPortrait) {
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1; return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
} else { } else {
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1; return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
} }
} }
/// Check if the number of images per page has changed /// Check if the number of images per page has changed
void _checkImagesPerPageChange() { void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage; int currentImagesPerPage = imagesPerPage();
if (_lastImagesPerPage != currentImagesPerPage) { bool currentOrientation = isPortrait;
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage); if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage; _lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
} }
} }
/// Adjust the page number when the number of images per page changes /// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange( void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
int oldImagesPerPage, int newImagesPerPage) { int previousImageIndex = 1;
int previousImageIndex = (page - 1) * oldImagesPerPage; if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1; previousImageIndex = (page - 1) * oldImagesPerPage + 1;
page = newPage; } else {
if (page == 1) {
previousImageIndex = 1;
} else {
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
}
}
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage()) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
}
} else {
newPage = previousImageIndex;
}
page = newPage>0 ? newPage : 1;
} }
} }
@@ -448,9 +498,13 @@ abstract mixin class _ReaderLocation {
bool get isLoading; bool get isLoading;
String get cid;
ComicType get type;
void update(); void update();
bool get enablePageAnimation => appdata.settings['enablePageAnimation']; bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
_ImageViewController? _imageViewController; _ImageViewController? _imageViewController;
@@ -487,7 +541,7 @@ abstract mixin class _ReaderLocation {
} }
this.page = page; this.page = page;
update(); update();
if (enablePageAnimation) { if (enablePageAnimation(cid, type)) {
_animationCount++; _animationCount++;
_imageViewController!.animateToPage(page).then((_) { _imageViewController!.animateToPage(page).then((_) {
_animationCount--; _animationCount--;
@@ -526,12 +580,12 @@ abstract mixin class _ReaderLocation {
Timer? autoPageTurningTimer; Timer? autoPageTurningTimer;
void autoPageTurning() { void autoPageTurning(String cid, ComicType type) {
if (autoPageTurningTimer != null) { if (autoPageTurningTimer != null) {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();
autoPageTurningTimer = null; autoPageTurningTimer = null;
} else { } else {
int interval = appdata.settings['autoPageTurningInterval']; int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) { autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
if (page == maxPage) { if (page == maxPage) {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();

View File

@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (!_isOpen) { if (!_isOpen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else { } else {
if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
} }
setState(() { setState(() {
_isOpen = !_isOpen; _isOpen = !_isOpen;
@@ -124,9 +128,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(child: widget.child),
child: widget.child,
),
if (appdata.settings['showPageNumberInReader'] == true) if (appdata.settings['showPageNumberInReader'] == true)
buildPageInfoText(), buildPageInfoText(),
buildStatusInfo(), buildStatusInfo(),
@@ -164,10 +166,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.92), color: context.colorScheme.surface.toOpacity(0.92),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: Colors.grey.toOpacity(0.5), width: 0.5),
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
), ),
), ),
child: Row( child: Row(
@@ -213,7 +212,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (context.reader.images![0].contains('file://')) { if (context.reader.images![0].contains('file://')) {
showToast( showToast(
message: "Local comic collection is not supported at present".tl, message: "Local comic collection is not supported at present".tl,
context: context); context: context,
);
return; return;
} }
String id = context.reader.cid; String id = context.reader.cid;
@@ -230,8 +230,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
List<String> tags = context.reader.widget.tags; List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author; String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.titles var epName =
.elementAtOrNull(context.reader.chapter - 1) ?? context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}"; "E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList(); var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
@@ -244,7 +246,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return; return;
} }
ImageFavoriteManager().deleteImageFavorite([ ImageFavoriteManager().deleteImageFavorite([
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName) ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName),
]); ]);
showToast( showToast(
message: "Uncollected the image".tl, message: "Uncollected the image".tl,
@@ -252,7 +254,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
seconds: 1, seconds: 1,
); );
} else { } else {
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ?? var imageFavoritesComic =
ImageFavoriteManager().find(id, sourceKey) ??
ImageFavoritesComic( ImageFavoritesComic(
id, id,
[], [],
@@ -266,10 +269,19 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
subTitle, subTitle,
maxPage, maxPage,
); );
ImageFavorite imageFavorite = ImageFavorite imageFavorite = ImageFavorite(
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName); page,
ImageFavoritesEp? imageFavoritesEp = imageKey,
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) { null,
eid,
id,
ep,
sourceKey,
epName,
);
ImageFavoritesEp? imageFavoritesEp = imageFavoritesComic
.imageFavoritesEp
.firstWhereOrNull((e) {
return e.ep == ep; return e.ep == ep;
}); });
if (imageFavoritesEp == null) { if (imageFavoritesEp == null) {
@@ -281,10 +293,20 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
// 不是第一页的话, 自动塞一个封面进去 // 不是第一页的话, 自动塞一个封面进去
imageFavoritesEp = ImageFavoritesEp( imageFavoritesEp = ImageFavoritesEp(
eid, ep, [copy, imageFavorite], epName, maxPage); eid,
ep,
[copy, imageFavorite],
epName,
maxPage,
);
} else { } else {
imageFavoritesEp = imageFavoritesEp = ImageFavoritesEp(
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage); eid,
ep,
[imageFavorite],
epName,
maxPage,
);
} }
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp); imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
} else { } else {
@@ -308,7 +330,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic); ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
showToast( showToast(
message: "Successfully collected".tl, context: context, seconds: 1); message: "Successfully collected".tl,
context: context,
seconds: 1,
);
} }
update(); update();
} catch (e, stackTrace) { } catch (e, stackTrace) {
@@ -323,65 +348,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
text = "P${context.reader.page}"; text = "P${context.reader.page}";
} }
Widget child = SizedBox( final buttons = [
height: kBottomBarHeight,
child: Column(
children: [
const SizedBox(
height: 8,
),
Row(
children: [
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1)
: context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage),
icon: const Icon(Icons.first_page),
),
Expanded(
child: buildSlider(),
),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: 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( Tooltip(
message: "Collect the image".tl, message: "Collect the image".tl,
child: IconButton( child: IconButton(
icon: icon: Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite, onPressed: addImageFavorite,
), ),
), ),
@@ -423,14 +394,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}); });
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight DeviceOrientation.landscapeRight,
]); ]);
} else { } else {
setState(() { setState(() {
rotation = null; rotation = null;
}); });
SystemChrome.setPreferredOrientations( SystemChrome.setPreferredOrientations(DeviceOrientation.values);
DeviceOrientation.values);
} }
}, },
), ),
@@ -442,7 +412,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? const Icon(Icons.timer) ? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp), : const Icon(Icons.timer_sharp),
onPressed: () { onPressed: () {
context.reader.autoPageTurning(); context.reader.autoPageTurning(
context.reader.cid,
context.reader.type,
);
update(); update();
}, },
), ),
@@ -464,14 +437,63 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
), ),
Tooltip( Tooltip(
message: "Share".tl, message: "Share".tl,
child: IconButton( child: IconButton(icon: const Icon(Icons.share), onPressed: share),
icon: const Icon(Icons.share),
onPressed: share,
), ),
];
Widget child = SizedBox(
height: kBottomBarHeight,
child: Column(
children: [
const SizedBox(height: 8),
Row(
children: [
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1)
: context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage),
icon: const Icon(Icons.first_page),
), ),
const SizedBox(width: 4) Expanded(child: buildSlider()),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
icon: const Icon(Icons.last_page),
),
const SizedBox(width: 8),
], ],
) ),
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),
],
);
},
),
], ],
), ),
); );
@@ -502,8 +524,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
focusNode: sliderFocus, focusNode: sliderFocus,
value: context.reader.page.toDouble(), value: context.reader.page.toDouble(),
min: 1, min: 1,
max: max: context.reader.maxPage
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(), .clamp(context.reader.page, 1 << 16)
.toDouble(),
reversed: isReversed, reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16), divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) { onChanged: (i) {
@@ -513,8 +536,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
Widget buildPageInfoText() { Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.titles var epName =
.elementAtOrNull(context.reader.chapter - 1) ?? context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}"; "E${context.reader.chapter}";
if (epName.length > 8) { if (epName.length > 8) {
epName = "${epName.substring(0, 8)}..."; epName = "${epName.substring(0, 8)}...";
@@ -590,23 +615,31 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}"; var filename = "${context.reader.page}${fileType.ext}";
Share.shareFile( Share.shareFile(data: data, filename: filename, mime: fileType.mime);
data: data,
filename: filename,
mime: fileType.mime,
);
} }
void openSetting() { void openSetting() {
showSideBar( showSideBar(
context, context,
ReaderSettings( ReaderSettings(
comicId: context.reader.cid,
comicSource: context.reader.type.sourceKey,
onChanged: (key) { onChanged: (key) {
if (key == "readerMode") { if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); context.reader.mode = ReaderMode.fromKey(
appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
key,
),
);
} }
if (key == "enableTurnPageByVolumeKey") { if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) { if (appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
key,
)) {
context.reader.handleVolumeEvent(); context.reader.handleVolumeEvent();
} else { } else {
context.reader.stopVolumeEvent(); context.reader.stopVolumeEvent();
@@ -712,8 +745,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return await File(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
.readAsBytes(); ))!.readAsBytes();
} }
} }
@@ -729,14 +762,17 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
entry = OverlayEntry( entry = OverlayEntry(
builder: (context) { builder: (context) {
return Positioned.fill( return Positioned.fill(
child: _SelectImageOverlayContent(onTap: (offset) { child: _SelectImageOverlayContent(
onTap: (offset) {
completer.complete(offset); completer.complete(offset);
entry!.remove(); entry!.remove();
}, onDispose: () { },
onDispose: () {
if (!completer.isCompleted) { if (!completer.isCompleted) {
completer.complete(null); completer.complete(null);
} }
}), },
),
); );
}, },
); );
@@ -836,9 +872,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
size: 16, size: 16,
color: batteryColor, color: batteryColor,
// Stroke // Stroke
shadows: List.generate( shadows: List.generate(9, (index) {
9,
(index) {
if (index == 4) { if (index == 4) {
return null; return null;
} }
@@ -848,8 +882,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
color: context.colorScheme.onInverseSurface, color: context.colorScheme.onInverseSurface,
offset: Offset(offsetX, offsetY), offset: Offset(offsetX, offsetY),
); );
}, }).whereType<Shadow>().toList(),
).whereType<Shadow>().toList(),
), ),
Stack( Stack(
children: [ children: [
@@ -936,10 +969,12 @@ class _SelectImageOverlayContent extends StatefulWidget {
final void Function() onDispose; final void Function() onDispose;
@override @override
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState(); State<_SelectImageOverlayContent> createState() =>
_SelectImageOverlayContentState();
} }
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> { class _SelectImageOverlayContentState
extends State<_SelectImageOverlayContent> {
@override @override
void dispose() { void dispose() {
widget.onDispose(); widget.onDispose();
@@ -956,19 +991,14 @@ class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent>
child: Container( child: Container(
color: Colors.black.withAlpha(50), color: Colors.black.withAlpha(50),
child: Align( child: Align(
alignment: Alignment( alignment: Alignment(0, -0.8),
0,
-0.8,
),
child: Container( child: Container(
width: 232, width: 232,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface, color: context.colorScheme.surface,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(color: context.colorScheme.outlineVariant),
color: context.colorScheme.outlineVariant,
),
), ),
child: Row( child: Row(
children: [ children: [

View File

@@ -376,11 +376,16 @@ class _SearchPageState extends State<SearchPage> {
controller.text = controller.text =
controller.text.replaceLast(words[words.length - 1], ""); controller.text.replaceLast(words[words.length - 1], "");
} }
if (type != null) { final source = ComicSource.find(searchTarget);
controller.text += "${type.name}:$text "; String insert;
if (source?.onTagSuggestionSelected != null) {
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
} else { } else {
controller.text += "$text "; var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
} }
controller.text += "$insert ";
suggestions.clear(); suggestions.clear();
update(); update();
focusNode.requestFocus(); focusNode.requestFocus();

View File

@@ -124,7 +124,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
options = widget.options ?? const []; options = widget.options ?? const [];
validateOptions(); validateOptions();
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller); suggestionsController = _SuggestionsController(controller, sourceKey);
super.initState(); super.initState();
} }
@@ -213,6 +213,8 @@ class _SuggestionsController {
final SearchBarController controller; final SearchBarController controller;
final String sourceKey;
OverlayEntry? entry; OverlayEntry? entry;
void updateWidget() { void updateWidget() {
@@ -270,7 +272,7 @@ class _SuggestionsController {
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer); find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
} }
_SuggestionsController(this.controller); _SuggestionsController(this.controller, this.sourceKey);
} }
class _Suggestions extends StatefulWidget { class _Suggestions extends StatefulWidget {
@@ -400,14 +402,16 @@ class _SuggestionsState extends State<_Suggestions> {
controller.text = controller.text =
controller.text.replaceLast(words[words.length - 1], ""); controller.text.replaceLast(words[words.length - 1], "");
} }
if (text.contains(' ')) { final source = ComicSource.find(widget.controller.sourceKey);
text = "'$text'"; String insert;
} if (source?.onTagSuggestionSelected != null) {
if (type != null) { insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
controller.text += "${type.name}:$text ";
} else { } else {
controller.text += "$text "; var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
} }
controller.text += "$insert ";
widget.controller.suggestions.clear(); widget.controller.suggestions.clear();
widget.controller.remove(); widget.controller.remove();
} }

View File

@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
return false; return false;
} }
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async { Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
try { try {
var value = await checkUpdate(); var value = await checkUpdate();
if (value) { if (value) {
if (delay) {
await Future.delayed(const Duration(seconds: 2));
}
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {

View File

@@ -193,12 +193,46 @@ class LogsPage extends StatefulWidget {
} }
class _LogsPageState extends State<LogsPage> { class _LogsPageState extends State<LogsPage> {
String logLevelToShow = "all";
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var logToShow = logLevelToShow == "all"
? Log.logs
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(
title: const Text("Logs"), title: Text("Logs".tl),
actions: [ actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("all"),
onTap: () => setState(() => logLevelToShow = "all")
),
PopupMenuItem(
child: Text("info"),
onTap: () => setState(() => logLevelToShow = "info")
),
PopupMenuItem(
child: Text("warning"),
onTap: () => setState(() => logLevelToShow = "warning")
),
PopupMenuItem(
child: Text("error"),
onTap: () => setState(() => logLevelToShow = "error")
),
]);
}),
icon: const Icon(Icons.filter_list_outlined)
),
IconButton( IconButton(
onPressed: () => setState(() { onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB( final RelativeRect position = RelativeRect.fromLTRB(
@@ -217,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
onTap: () { onTap: () {
Log.ignoreLimitation = true; Log.ignoreLimitation = true;
context.showMessage( context.showMessage(
message: "Only valid for this run"); message: "Only valid for this run".tl);
}, },
), ),
PopupMenuItem( PopupMenuItem(
@@ -232,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
body: ListView.builder( body: ListView.builder(
reverse: true, reverse: true,
controller: ScrollController(), controller: ScrollController(),
itemCount: Log.logs.length, itemCount: logToShow.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
index = Log.logs.length - index - 1; index = logToShow.length - index - 1;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea( child: SelectionArea(
@@ -253,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title), child: Text(logToShow[index].title),
), ),
), ),
const SizedBox( const SizedBox(
@@ -265,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
Theme.of(context).colorScheme.error, Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer, Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index], ][logToShow[index].level.index],
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(16)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text( child: Text(
Log.logs[index].level.name, logToShow[index].level.name,
style: TextStyle( style: TextStyle(
color: Log.logs[index].level.index == 0 color: logToShow[index].level.index == 0
? Colors.white ? Colors.white
: Colors.black), : Colors.black),
), ),
@@ -282,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
), ),
], ],
), ),
Text(Log.logs[index].content), Text(logToShow[index].content),
Text(Log.logs[index].time Text(logToShow[index].time
.toString() .toString()
.replaceAll(RegExp(r"\.\w+"), "")), .replaceAll(RegExp(r"\.\w+"), "")),
TextButton( TextButton(
onPressed: () { onPressed: () {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: Log.logs[index].content)); ClipboardData(text: logToShow[index].content));
}, },
child: Text("Copy".tl), child: Text("Copy".tl),
), ),

View File

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

View File

@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Show history on comic tile".tl, title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile", settingKey: "showHistoryStatusOnTile",
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Reverse default chapter order".tl,
settingKey: "reverseChapterOrder",
).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Keyword blocking".tl, title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(), builder: () => const _ManageBlockingWordView(),

View File

@@ -1,9 +1,16 @@
part of 'settings_page.dart'; part of 'settings_page.dart';
class ReaderSettings extends StatefulWidget { class ReaderSettings extends StatefulWidget {
const ReaderSettings({super.key, this.onChanged}); const ReaderSettings({
super.key,
this.onChanged,
this.comicId,
this.comicSource,
});
final void Function(String key)? onChanged; final void Function(String key)? onChanged;
final String? comicId;
final String? comicSource;
@override @override
State<ReaderSettings> createState() => _ReaderSettingsState(); State<ReaderSettings> createState() => _ReaderSettingsState();
@@ -12,15 +19,57 @@ class ReaderSettings extends StatefulWidget {
class _ReaderSettingsState extends State<ReaderSettings> { class _ReaderSettingsState extends State<ReaderSettings> {
@override @override
Widget build(BuildContext context) { 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( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar(title: Text("Reading".tl)), 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( _SwitchSetting(
title: "Tap to turn Pages".tl, title: "Tap to turn Pages".tl,
settingKey: "enableTapToTurnPages", settingKey: "enableTapToTurnPages",
onChanged: () { onChanged: () {
widget.onChanged?.call("enableTapToTurnPages"); widget.onChanged?.call("enableTapToTurnPages");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Reverse tap to turn Pages".tl, title: "Reverse tap to turn Pages".tl,
@@ -28,6 +77,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages"); widget.onChanged?.call("reverseTapToTurnPages");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Page animation".tl, title: "Page animation".tl,
@@ -35,6 +86,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("enablePageAnimation"); widget.onChanged?.call("enablePageAnimation");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
SelectSetting( SelectSetting(
title: "Reading mode".tl, title: "Reading mode".tl,
@@ -58,6 +111,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
} }
widget.onChanged?.call("readerMode"); widget.onChanged?.call("readerMode");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SliderSetting( _SliderSetting(
title: "Auto page turning interval".tl, title: "Auto page turning interval".tl,
@@ -69,6 +124,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {}); setState(() {});
widget.onChanged?.call("autoPageTurningInterval"); widget.onChanged?.call("autoPageTurningInterval");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
SliverAnimatedVisibility( SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery'), visible: appdata.settings['readerMode']!.startsWith('gallery'),
@@ -84,6 +141,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {}); setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape"); widget.onChanged?.call("readerScreenPicNumberForLandscape");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
), ),
), ),
SliverAnimatedVisibility( SliverAnimatedVisibility(
@@ -99,10 +158,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait"); widget.onChanged?.call("readerScreenPicNumberForPortrait");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
), ),
), ),
SliverAnimatedVisibility( SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery') && visible:
appdata.settings['readerMode']!.startsWith('gallery') &&
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 || (appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
appdata.settings['readerScreenPicNumberForPortrait'] > 1), appdata.settings['readerScreenPicNumberForPortrait'] > 1),
child: _SwitchSetting( child: _SwitchSetting(
@@ -111,6 +173,23 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage"); widget.onChanged?.call("showSingleImageOnFirstPage");
}, },
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( _SwitchSetting(
@@ -120,6 +199,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {}); setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom'); widget.onChanged?.call('enableDoubleTapToZoom');
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
@@ -128,6 +209,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {}); setState(() {});
widget.onChanged?.call('enableLongPressToZoom'); widget.onChanged?.call('enableLongPressToZoom');
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
SliverAnimatedVisibility( SliverAnimatedVisibility(
visible: appdata.settings['enableLongPressToZoom'] == true, visible: appdata.settings['enableLongPressToZoom'] == true,
@@ -138,6 +221,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"press": "Press position".tl, "press": "Press position".tl,
"center": "Screen center".tl, "center": "Screen center".tl,
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
), ),
), ),
_SwitchSetting( _SwitchSetting(
@@ -147,6 +232,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call('limitImageWidth'); widget.onChanged?.call('limitImageWidth');
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
if (App.isAndroid) if (App.isAndroid)
_SwitchSetting( _SwitchSetting(
@@ -155,6 +242,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey'); widget.onChanged?.call('enableTurnPageByVolumeKey');
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Display time & battery info in reader".tl, title: "Display time & battery info in reader".tl,
@@ -162,6 +251,17 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("enableClockAndBatteryInfoInReader"); widget.onChanged?.call("enableClockAndBatteryInfoInReader");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
settingKey: "showSystemStatusBar",
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
SelectSetting( SelectSetting(
title: "Quick collect image".tl, title: "Quick collect image".tl,
@@ -177,6 +277,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
help: help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode" "On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl, .tl,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Custom Image Processing".tl, title: "Custom Image Processing".tl,
@@ -189,6 +291,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
interval: 1, interval: 1,
min: 1, min: 1,
max: 16, max: 16,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Show Page Number".tl, title: "Show Page Number".tl,
@@ -196,6 +300,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("showPageNumberInReader"); widget.onChanged?.call("showPageNumberInReader");
}, },
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).toSliver(),
], ],
); );
@@ -241,7 +347,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
setState(() {}); setState(() {});
}, },
child: Text("Reset".tl), child: Text("Reset".tl),
) ),
], ],
), ),
body: Column( body: Column(
@@ -267,7 +373,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
), ),
), ),
), ),
) ),
], ],
), ),
); );

View File

@@ -6,6 +6,8 @@ class _SwitchSetting extends StatefulWidget {
required this.settingKey, required this.settingKey,
this.onChanged, this.onChanged,
this.subtitle, this.subtitle,
this.comicId,
this.comicSource,
}); });
final String title; final String title;
@@ -16,6 +18,10 @@ class _SwitchSetting extends StatefulWidget {
final String? subtitle; final String? subtitle;
final String? comicId;
final String? comicSource;
@override @override
State<_SwitchSetting> createState() => _SwitchSettingState(); State<_SwitchSetting> createState() => _SwitchSettingState();
} }
@@ -23,16 +29,33 @@ class _SwitchSetting extends StatefulWidget {
class _SwitchSettingState extends State<_SwitchSetting> { class _SwitchSettingState extends State<_SwitchSetting> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(appdata.settings[widget.settingKey] is bool); var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
assert(value is bool);
return ListTile( return ListTile(
title: Text(widget.title), title: Text(widget.title),
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!), subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
trailing: Switch( trailing: Switch(
value: appdata.settings[widget.settingKey], value: value,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value; appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
}); });
appdata.saveData().then((_) { appdata.saveData().then((_) {
widget.onChanged?.call(); widget.onChanged?.call();
@@ -51,6 +74,8 @@ class SelectSetting extends StatelessWidget {
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help, this.help,
this.comicId,
this.comicSource,
}); });
final String title; final String title;
@@ -63,6 +88,10 @@ class SelectSetting extends StatelessWidget {
final String? help; final String? help;
final String? comicId;
final String? comicSource;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
@@ -76,6 +105,8 @@ class SelectSetting extends StatelessWidget {
optionTranslation: optionTranslation, optionTranslation: optionTranslation,
onChanged: onChanged, onChanged: onChanged,
help: help, help: help,
comicId: comicId,
comicSource: comicSource,
); );
} else { } else {
return _EndSelectorSelectSetting( return _EndSelectorSelectSetting(
@@ -84,6 +115,8 @@ class SelectSetting extends StatelessWidget {
optionTranslation: optionTranslation, optionTranslation: optionTranslation,
onChanged: onChanged, onChanged: onChanged,
help: help, help: help,
comicId: comicId,
comicSource: comicSource,
); );
} }
}, },
@@ -99,6 +132,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help, this.help,
this.comicId,
this.comicSource,
}); });
final String title; final String title;
@@ -111,6 +146,10 @@ class _DoubleLineSelectSettings extends StatefulWidget {
final String? help; final String? help;
final String? comicId;
final String? comicSource;
@override @override
State<_DoubleLineSelectSettings> createState() => State<_DoubleLineSelectSettings> createState() =>
_DoubleLineSelectSettingsState(); _DoubleLineSelectSettingsState();
@@ -119,6 +158,14 @@ class _DoubleLineSelectSettings extends StatefulWidget {
class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> { class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
return ListTile( return ListTile(
title: Row( title: Row(
children: [ children: [
@@ -134,9 +181,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
builder: (context) { builder: (context) {
return ContentDialog( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(widget.help!) content: Text(
.paddingHorizontal(16) widget.help!,
.fixWidth(double.infinity), ).paddingHorizontal(16).fixWidth(double.infinity),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: context.pop, onPressed: context.pop,
@@ -150,9 +197,7 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
), ),
], ],
), ),
subtitle: Text( subtitle: Text(widget.optionTranslation[value] ?? "None"),
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
"None"),
trailing: const Icon(Icons.arrow_drop_down), trailing: const Icon(Icons.arrow_drop_down),
onTap: () { onTap: () {
var renderBox = context.findRenderObject() as RenderBox; var renderBox = context.findRenderObject() as RenderBox;
@@ -170,16 +215,27 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
Offset.zero & MediaQuery.of(context).size, Offset.zero & MediaQuery.of(context).size,
), ),
items: widget.optionTranslation.keys items: widget.optionTranslation.keys
.map((key) => PopupMenuItem( .map(
(key) => PopupMenuItem(
value: key, value: key,
height: App.isMobile ? 46 : 40, height: App.isMobile ? 46 : 40,
child: Text(widget.optionTranslation[key]!), child: Text(widget.optionTranslation[key]!),
)) ),
)
.toList(), .toList(),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
setState(() { setState(() {
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value; appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
}); });
appdata.saveData(); appdata.saveData();
widget.onChanged?.call(); widget.onChanged?.call();
@@ -197,6 +253,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help, this.help,
this.comicId,
this.comicSource,
}); });
final String title; final String title;
@@ -209,6 +267,10 @@ class _EndSelectorSelectSetting extends StatefulWidget {
final String? help; final String? help;
final String? comicId;
final String? comicSource;
@override @override
State<_EndSelectorSelectSetting> createState() => State<_EndSelectorSelectSetting> createState() =>
_EndSelectorSelectSettingState(); _EndSelectorSelectSettingState();
@@ -218,6 +280,13 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var options = widget.optionTranslation; var options = widget.optionTranslation;
var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
return ListTile( return ListTile(
title: Row( title: Row(
children: [ children: [
@@ -233,9 +302,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
builder: (context) { builder: (context) {
return ContentDialog( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(widget.help!) content: Text(
.paddingHorizontal(16) widget.help!,
.fixWidth(double.infinity), ).paddingHorizontal(16).fixWidth(double.infinity),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: context.pop, onPressed: context.pop,
@@ -250,12 +319,22 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
], ],
), ),
trailing: Select( trailing: Select(
current: options[appdata.settings[widget.settingKey]], current: options[value],
values: options.values.toList(), values: options.values.toList(),
minWidth: 64, minWidth: 64,
onTap: (index) { onTap: (index) {
setState(() { setState(() {
appdata.settings[widget.settingKey] = options.keys.elementAt(index); var value = options.keys.elementAt(index);
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
}); });
appdata.saveData(); appdata.saveData();
widget.onChanged?.call(); widget.onChanged?.call();
@@ -273,6 +352,8 @@ class _SliderSetting extends StatefulWidget {
required this.min, required this.min,
required this.max, required this.max,
this.onChanged, this.onChanged,
this.comicId,
this.comicSource,
}); });
final String title; final String title;
@@ -287,6 +368,10 @@ class _SliderSetting extends StatefulWidget {
final VoidCallback? onChanged; final VoidCallback? onChanged;
final String? comicId;
final String? comicSource;
@override @override
State<_SliderSetting> createState() => _SliderSettingState(); State<_SliderSetting> createState() => _SliderSettingState();
} }
@@ -294,28 +379,52 @@ class _SliderSetting extends StatefulWidget {
class _SliderSettingState extends State<_SliderSetting> { class _SliderSettingState extends State<_SliderSetting> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var value =
(widget.comicId == null
? appdata.settings[widget.settingsIndex]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
))
.toDouble();
return ListTile( return ListTile(
title: Row( title: Row(
children: [ children: [
Text(widget.title), Text(widget.title),
const Spacer(), const Spacer(),
Text( Text(value.toString(), style: ts.s12),
appdata.settings[widget.settingsIndex].toString(),
style: ts.s12,
),
], ],
), ),
subtitle: Slider( subtitle: Slider(
value: appdata.settings[widget.settingsIndex].toDouble(), value: value,
onChanged: (value) { onChanged: (value) {
if (value.toInt() == value) { if (value.toInt() == value) {
setState(() { setState(() {
if (widget.comicId == null) {
appdata.settings[widget.settingsIndex] = value.toInt(); appdata.settings[widget.settingsIndex] = value.toInt();
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
value.toInt(),
);
}
appdata.saveData(); appdata.saveData();
}); });
} else { } else {
setState(() { setState(() {
if (widget.comicId == null) {
appdata.settings[widget.settingsIndex] = value; appdata.settings[widget.settingsIndex] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
value,
);
}
appdata.saveData(); appdata.saveData();
}); });
} }
@@ -405,7 +514,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
color: Colors.black12, color: Colors.black12,
blurRadius: 5, blurRadius: 5,
offset: Offset(0, 2), offset: Offset(0, 2),
spreadRadius: 2) spreadRadius: 2,
),
], ],
), ),
onReorder: (reorderFunc) { onReorder: (reorderFunc) {
@@ -435,7 +545,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
label: Text("Add".tl), label: Text("Add".tl),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
onPressed: showAddDialog, onPressed: showAddDialog,
) ),
], ],
body: view, body: view,
); );
@@ -450,7 +560,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
keys.remove(key); keys.remove(key);
}); });
}, },
icon: const Icon(Icons.delete_outline)), icon: const Icon(Icons.delete_outline),
),
); );
return ListTile( return ListTile(
@@ -458,10 +569,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
key: Key(key), key: Key(key),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [removeButton, const Icon(Icons.drag_handle)],
removeButton,
const Icon(Icons.drag_handle),
],
), ),
); );
} }
@@ -477,7 +585,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(
builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Add".tl, title: "Add".tl,
content: Column( content: Column(
@@ -534,7 +643,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
), ),
], ],
); );
}); },
);
}, },
); );
} }

View File

@@ -22,11 +22,13 @@ class DataSync with ChangeNotifier {
} }
LocalFavoritesManager().addListener(onDataChanged); LocalFavoritesManager().addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged); ComicSourceManager().addListener(onDataChanged);
if (App.isDesktop) {
Future.delayed(const Duration(seconds: 1), () { Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext); var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose); controller.addCloseListener(_handleWindowClose);
}); });
} }
}
void onDataChanged() { void onDataChanged() {
if (isEnabled) { if (isEnabled) {

View File

@@ -28,6 +28,8 @@ final _resolver = MimeTypeResolver()
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed') ..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
// rar // rar
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar') ..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
// avif
..addMagicNumber([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 'image/avif')
; ;
FileType detectFileType(List<int> data) { FileType detectFileType(List<int> data) {

67
lib/utils/opencc.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter/services.dart';
abstract class OpenCC {
static late final Map<int, int> _s2t;
static late final Map<int, int> _t2s;
static Future<void> init() async {
var data = await rootBundle.load("assets/opencc.txt");
var txt = utf8.decode(data.buffer.asUint8List());
_s2t = <int, int>{};
_t2s = <int, int>{};
for (var line in txt.split('\n')) {
if (line.isEmpty || line.startsWith('#') || line.length != 2) continue;
var s = line.runes.elementAt(0);
var t = line.runes.elementAt(1);
_s2t[s] = t;
_t2s[t] = s;
}
}
static bool hasChineseSimplified(String text) {
if (text != "监禁") {
return false;
}
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
return true;
}
}
return false;
}
static bool hasChineseTraditional(String text) {
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
return true;
}
}
return false;
}
static String simplifiedToTraditional(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
sb.write(String.fromCharCodes([_s2t[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
static String traditionalToSimplified(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
sb.write(String.fromCharCodes([_t2s[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart'; import 'package:venera/utils/image.dart';
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
return Isolate.spawn<SendPort>( return Isolate.spawn<SendPort>(
(sendPort) => overrideIO( (sendPort) => overrideIO(
() async { () async {
if (App.isAndroid) {
await SAFTaskWorker().init();
}
var receivePort = ReceivePort(); var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort); sendPort.send(receivePort.sendPort);

View File

@@ -36,7 +36,9 @@ extension TagsTranslation on String{
static String _translateTags(String tag){ static String _translateTags(String tag){
if (tag.contains('|')) { if (tag.contains('|')) {
var splits = tag.split('|'); var splits = tag.split('|');
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag; return enTagsTranslations[splits[0].trim()]
?? enTagsTranslations[splits[1].trim()]
?? tag;
} else if(tag.contains(':')) { } else if(tag.contains(':')) {
var splits = tag.split(':'); var splits = tag.split(':');
if(_haveNamespace(splits[0])) { if(_haveNamespace(splits[0])) {

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -170,6 +170,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
display_mode:
dependency: "direct main"
description:
name: display_mode
sha256: "8a381f3602a09dc4e96140a0df30808631468d6d0dfff7722f67b1f83757a7cc"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
dynamic_color: dynamic_color:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -178,14 +186,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.7.0" version: "1.7.0"
enough_convert:
dependency: "direct main"
description:
name: enough_convert
sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01
url: "https://pub.dev"
source: hosted
version: "1.6.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -425,10 +441,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_rust_bridge name: flutter_rust_bridge
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611" sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.0" version: "2.10.0"
flutter_saf: flutter_saf:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -516,10 +532,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -540,26 +556,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "11.0.1"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -758,11 +774,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: rhttp path: rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36 ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36 resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
url: "https://github.com/wgh136/rhttp" url: "https://github.com/wgh136/rhttp"
source: git source: git
version: "0.11.0" version: "0.12.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@@ -925,10 +941,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.6"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1021,18 +1037,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1099,5 +1115,5 @@ packages:
source: hosted source: hosted
version: "0.0.12" version: "0.0.12"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.3" flutter: ">=3.35.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.4.2+142 version: 1.5.0+150
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.29.3 flutter: 3.35.2
dependencies: dependencies:
flutter: flutter:
@@ -61,7 +61,7 @@ dependencies:
rhttp: rhttp:
git: git:
url: https://github.com/wgh136/rhttp url: https://github.com/wgh136/rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36 ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
path: rhttp path: rhttp
webdav_client: webdav_client:
git: git:
@@ -85,6 +85,8 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
yaml: ^3.1.3 yaml: ^3.1.3
enough_convert: ^1.6.0
display_mode: ^0.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -101,6 +103,7 @@ flutter:
- assets/app_icon.png - assets/app_icon.png
- assets/tags.json - assets/tags.json
- assets/tags_tw.json - assets/tags_tw.json
- assets/opencc.txt
flutter_to_arch: flutter_to_arch:
name: Venera name: Venera

150
update_alt_store.py Normal file
View File

@@ -0,0 +1,150 @@
import json
import plistlib
import re
import requests
import os
from datetime import datetime
def prepare_description(text):
text = re.sub('<[^<]+?>', '', text) # Remove HTML tags
text = re.sub(r'#{1,6}\s?', '', text) # Remove markdown header tags
text = re.sub(r'\*{2}', '', text) # Remove all occurrences of two consecutive asterisks
text = re.sub(r'(?<=\r|\n)-', '', text) # Only replace - with • if it is preceded by \r or \n
text = re.sub(r'`', '"', text) # Replace ` with "
text = re.sub(r'\r\n\r\n', '\r \n', text) # Replace \r\n\r\n with \r \n (avoid incorrect display of the description regarding paragraphs)
return text
def fetch_latest_release(repo_url):
api_url = f"https://api.github.com/repos/{repo_url}/releases"
headers = {
"Accept": "application/vnd.github+json",
}
try:
response = requests.get(api_url, headers=headers)
response.raise_for_status()
release = response.json()
return release
except requests.RequestException as e:
print(f"Error fetching releases: {e}")
raise
def get_file_size(url):
try:
response = requests.head(url)
response.raise_for_status()
return int(response.headers.get('Content-Length', 0))
except requests.RequestException as e:
print(f"Error getting file size: {e}")
return 194586
def update_json_file_release(json_file, latest_release):
if isinstance(latest_release, list) and latest_release:
latest_release = latest_release[0]
else:
print("Error getting latest release")
return
try:
with open(json_file, "r") as file:
data = json.load(file)
except json.JSONDecodeError as e:
print(f"Error reading JSON file: {e}")
data = {"apps": []}
raise
app = data["apps"][0]
full_version = latest_release["tag_name"]
tag = latest_release["tag_name"]
# Extract version like 1.4.5 from tag, which may be like 'v1.4.5'
version_match = re.search(r"(\d+\.\d+\.\d+)", full_version)
if version_match:
version = version_match.group(1)
else:
print("Error: Could not parse version from tag_name.")
return
version_date = latest_release["published_at"]
date_obj = datetime.strptime(version_date, "%Y-%m-%dT%H:%M:%SZ")
version_date = date_obj.strftime("%Y-%m-%d")
description = latest_release["body"]
description = prepare_description(description)
assets = latest_release.get("assets", [])
download_url = None
size = None
for asset in assets:
# venera-ios-1.4.5+145.ipa
if asset["name"] == f"venera-ios-{version}+{version.replace('.', '')}.ipa":
download_url = asset["browser_download_url"]
size = asset["size"]
break
if download_url is None or size is None:
print("Error: IPA file not found in release assets.")
return
version_entry = {
"version": version,
"date": version_date,
"localizedDescription": description,
"downloadURL": download_url,
"size": size
}
duplicate_entries = [item for item in app["versions"] if item["version"] == version]
if duplicate_entries:
app["versions"].remove(duplicate_entries[0])
app["versions"].insert(0, version_entry)
app.update({
"version": version,
"versionDate": version_date,
"versionDescription": description,
"downloadURL": download_url,
"size": size
})
if "news" not in data:
data["news"] = []
news_identifier = f"release-{full_version}"
date_string = date_obj.strftime("%d/%m/%y")
news_entry = {
"appID": "com.github.wgh136.venera",
"caption": f"Update of Venera just got released!",
"date": latest_release["published_at"],
"identifier": news_identifier,
"notify": True,
"tintColor": "#0784FC",
"title": f"{full_version} - Venera {date_string}",
"url": f"https://github.com/venera-app/venera/releases/tag/{tag}"
}
news_entry_exists = any(item["identifier"] == news_identifier for item in data["news"])
if not news_entry_exists:
data["news"].append(news_entry)
try:
with open(json_file, "w") as file:
json.dump(data, file, indent=2)
print("JSON file updated successfully.")
except IOError as e:
print(f"Error writing to JSON file: {e}")
raise
def main():
repo_url = "venera-app/venera"
is_nightly = "NIGHTLY_LINK" in os.environ
try:
fetched_data_latest = fetch_latest_release(repo_url)
json_file = "alt_store.json"
update_json_file_release(json_file, fetched_data_latest)
except Exception as e:
print(f"An error occurred: {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -98,14 +98,20 @@ bool FlutterWindow::OnCreate() {
else else
result->Success(flutter::EncodableValue("No Proxy")); result->Success(flutter::EncodableValue("No Proxy"));
delete(res); delete(res);
return;
} }
#ifdef NDEBUG
else if (call.method_name() == "heartBeat") { else if (call.method_name() == "heartBeat") {
if (monitorThread == nullptr) { if (monitorThread == nullptr) {
monitorThread = new std::thread{ monitorUIThread }; monitorThread = new std::thread{ monitorUIThread };
} }
lastHeartbeat = std::chrono::steady_clock::now(); lastHeartbeat = std::chrono::steady_clock::now();
result->Success(); result->Success();
return;
} }
#endif
result->Success(); // Default response for unhandled method calls
}); });
flutter::EventChannel<> channel2( flutter::EventChannel<> channel2(