28 Commits

Author SHA1 Message Date
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
6e14942dab Add application category type to Info.plist 2025-04-29 11:29:30 +08:00
146fc70143 Update version code 2025-04-29 11:19:59 +08:00
b37ea01aca Add an option to disable double tap to zoom. 2025-04-29 11:18:59 +08:00
bf7b90313a Fix invalid total page count. Close #348 2025-04-28 20:18:29 +08:00
929c1a9d91 Show comics count of a folder on sidebar. 2025-04-28 19:46:29 +08:00
9ff68d0701 Improve local favorites performance. 2025-04-28 19:40:12 +08:00
dfd15ed34a Fix an issue where folders were not fully displayed on the favorites page. 2025-04-26 10:23:18 +08:00
nyne
dfe2a0db6a Merge pull request #345 from venera-app/v1.4.1-dev
V1.4.1
2025-04-25 09:22:51 +08:00
c6714f79b6 Revert "Add windows arm64"
This reverts commit 6877aa120f.
2025-04-25 09:18:45 +08:00
552a42fb27 Fix the issue where app crashes after exit app. 2025-04-24 20:11:09 +08:00
af456c52f1 Improve the UI of comic source list. 2025-04-24 17:20:16 +08:00
f38129133a Terminate the application when the UI thread is dead. Close #343 2025-04-24 16:44:51 +08:00
17e2696ca4 flutter 3.29.3 2025-04-23 17:50:04 +08:00
9d6999af33 Improve UI 2025-04-23 16:58:38 +08:00
ae5548918c Fix saving, sharing, and collecting images when there are multiple images on the screen. Close #289 2025-04-23 16:51:51 +08:00
92d22c977c Add a Save Image option to the Reader context menu. 2025-04-23 15:51:58 +08:00
8cc3702e1a Add an option to display single image on the first reader page. Close #244 2025-04-23 15:38:10 +08:00
3131ce52a7 Fix file name sanitising to remove trailing dots. Close #322 2025-04-22 20:29:18 +08:00
62e4056f4a Add an 'All' folder to the local favorites page. Close #335 2025-04-22 20:19:22 +08:00
a29a7cbaf3 Adjust the scroll distance when turning pages using the arrow keys. Close #329 2025-04-21 20:12:08 +08:00
7bdab7ade7 Add ComicInfo.xml to cbz file. Close #333 2025-04-21 20:04:06 +08:00
ea99e87afb Fixed issue where http client settings were not synchronised with appdata. Close #337 2025-04-21 19:44:23 +08:00
0d3fde9457 Adjust key repeat timer duration based on page animation setting. 2025-04-21 19:16:43 +08:00
aa9f4dae82 Reset state of photo view controllers on page change. Close #331 2025-04-19 10:54:25 +08:00
6877aa120f Add windows arm64 2025-04-15 17:08:28 +08:00
d25d72a5f7 Improve image cache. Close #326 2025-04-10 17:14:05 +08:00
32 changed files with 1442 additions and 875 deletions

View File

@@ -387,7 +387,11 @@
"Screen center": "屏幕中心",
"Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"Click the setting icon to change the source list url.": "点击设置图标更改源列表URL"
"Show single image on first page": "在首页显示单张图片",
"Click to select an image": "点击选择一张图片",
"Source URL": "源地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放"
},
"zh_TW": {
"Home": "首頁",
@@ -777,6 +781,10 @@
"Screen center": "螢幕中心",
"Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"Click the setting icon to change the source list url.": "點擊設定圖示更改源列表URL"
"Show single image on first page": "在首頁顯示單張圖片",
"Click to select an image": "點擊選擇一張圖片",
"Source URL": "源地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放"
}
}

View File

@@ -46,12 +46,14 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string>
<string>Choose images</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string>
<string>Ensure that the operation is being performed by the user themselves.</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.books</string>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.4.0";
final version = "1.4.2";
bool get isAndroid => Platform.isAndroid;

View File

@@ -178,13 +178,14 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl':
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'comicSourceListUrl': defaultComicSourceUrl,
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
};
operator [](String key) {
@@ -219,3 +220,5 @@ function processImage(image, cid, eid, page, sourceKey) {
return futureImage;
}
''';
const defaultComicSourceUrl = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json";

View File

@@ -21,7 +21,7 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024;
CacheManager._create(){
CacheManager._create() {
Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db');
_db.execute('''
@@ -33,100 +33,102 @@ class CacheManager {
type TEXT
)
''');
compute((path) => Directory(path).size, cachePath)
.then((value) => _currentSize = value);
compute((path) => Directory(path).size, cachePath).then((value) {
_currentSize = value;
checkCache();
});
}
/// Get the singleton instance of CacheManager.
factory CacheManager() => instance ??= CacheManager._create();
/// set cache size limit in MB
void setLimitSize(int size){
void setLimitSize(int size) {
_limitSize = size * 1024 * 1024;
}
void setType(String key, String? type){
_db.execute('''
UPDATE cache
SET type = ?
WHERE key = ?
''', [type, key]);
}
String? getType(String key){
var res = _db.select('''
SELECT type FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
return null;
}
return res.first[0];
}
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
/// Write cache to disk.
Future<void> writeCache(String key, List<int> data,
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
this.dir++;
this.dir %= 100;
var dir = this.dir;
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
var name = md5.convert(key.codeUnits).toString();
var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true);
await file.writeAsBytes(data);
var expires = DateTime.now().millisecondsSinceEpoch + duration;
_db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir.toString(), name, expires]);
if(_currentSize != null) {
if (_currentSize != null) {
_currentSize = _currentSize! + data.length;
}
checkCacheIfRequired();
}
Future<CachingFile> openWrite(String key) async{
this.dir++;
this.dir %= 100;
var dir = this.dir;
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true);
return CachingFile._(key, dir.toString(), name, file);
}
Future<File?> findCache(String key) async{
/// Find cache by key.
/// If cache is expired, it will be deleted and return null.
/// If cache is not found, it will return null.
/// If cache is found, it will return the file, and update the expires time.
Future<File?> findCache(String key) async {
var res = _db.select('''
SELECT * FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
if (res.isEmpty) {
return null;
}
var row = res.first;
var dir = row[1] as String;
var name = row[2] as String;
var expires = row[3] as int;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
var now = DateTime.now().millisecondsSinceEpoch;
if (expires < now) {
// expired
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if (await file.exists()) {
await file.delete();
}
return null;
}
if (await file.exists()) {
// update time
var expires = now + 7 * 24 * 60 * 60 * 1000;
_db.execute('''
UPDATE cache
SET expires = ?
WHERE key = ?
''', [expires, key]);
return file;
} else {
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
}
return null;
}
bool _isChecking = false;
/// Check cache size and delete expired cache.
/// Only check cache if current size is greater than limit size.
void checkCacheIfRequired() {
if(_currentSize != null && _currentSize! > _limitSize){
if (_currentSize != null && _currentSize! > _limitSize) {
checkCache();
}
}
Future<void> checkCache() async{
if(_isChecking){
/// Check cache size and delete expired cache.
/// If current size is greater than limit size,
/// delete cache until current size is less than limit size.
Future<void> checkCache() async {
if (_isChecking) {
return;
}
_isChecking = true;
@@ -134,11 +136,13 @@ class CacheManager {
SELECT * FROM cache
WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]);
for(var row in res){
for (var row in res) {
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
if (await file.exists()) {
var size = await file.length();
_currentSize = _currentSize! - size;
await file.delete();
}
}
@@ -147,26 +151,18 @@ class CacheManager {
WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]);
int count = 0;
var res2 = _db.select('''
SELECT COUNT(*) FROM cache
''');
if(res2.isNotEmpty){
count = res2.first[0] as int;
}
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select('''
SELECT * FROM cache
ORDER BY expires ASC
limit 10
''');
for(var row in res){
for (var row in res) {
var key = row[0] as String;
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
if (await file.exists()) {
var size = await file.length();
await file.delete();
_db.execute('''
@@ -174,7 +170,7 @@ class CacheManager {
WHERE key = ?
''', [key]);
_currentSize = _currentSize! - size;
if(_currentSize! <= _limitSize){
if (_currentSize! <= _limitSize) {
break;
}
} else {
@@ -183,18 +179,18 @@ class CacheManager {
WHERE key = ?
''', [key]);
}
count--;
}
}
_isChecking = false;
}
Future<void> delete(String key) async{
/// Delete cache by key.
Future<void> delete(String key) async {
var res = _db.select('''
SELECT * FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
if (res.isEmpty) {
return;
}
var row = res.first;
@@ -202,7 +198,7 @@ class CacheManager {
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
var fileSize = 0;
if(await file.exists()){
if (await file.exists()) {
fileSize = await file.length();
await file.delete();
}
@@ -210,11 +206,12 @@ class CacheManager {
DELETE FROM cache
WHERE key = ?
''', [key]);
if(_currentSize != null) {
if (_currentSize != null) {
_currentSize = _currentSize! - fileSize;
}
}
/// Delete all cache.
Future<void> clear() async {
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
@@ -223,75 +220,4 @@ class CacheManager {
''');
_currentSize = 0;
}
Future<void> deleteKeyword(String keyword) async{
var res = _db.select('''
SELECT * FROM cache
WHERE key LIKE ?
''', ['%$keyword%']);
for(var row in res){
var key = row[0] as String;
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
var fileSize = 0;
if(await file.exists()){
fileSize = await file.length();
try {
await file.delete();
}
finally {}
}
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if(_currentSize != null) {
_currentSize = _currentSize! - fileSize;
}
}
}
}
class CachingFile{
CachingFile._(this.key, this.dir, this.name, this.file);
final String key;
final String dir;
final String name;
final File file;
final List<int> _buffer = [];
Future<void> writeBytes(List<int> data) async{
_buffer.addAll(data);
if(_buffer.length > 1024 * 1024){
await file.writeAsBytes(_buffer, mode: FileMode.append);
_buffer.clear();
}
}
Future<void> close() async{
if(_buffer.isNotEmpty){
await file.writeAsBytes(_buffer, mode: FileMode.append);
}
CacheManager()._db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
CacheManager().checkCacheIfRequired();
}
Future<void> cancel() async{
await file.deleteIgnoreError();
}
void reset() {
_buffer.clear();
if(file.existsSync()) {
file.deleteSync();
}
}
}

View File

@@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db;
late Map<String, int> counts;
int get totalComics {
int total = 0;
for (var t in counts.values) {
total += t;
}
return total;
}
int folderComics(String folder) {
return counts[folder] ?? 0;
}
Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute("""
create table if not exists folder_order (
@@ -234,7 +251,7 @@ class LocalFavoritesManager with ChangeNotifier {
alter table "$folder"
add column translated_tags TEXT;
""");
var comics = getAllComics(folder);
var comics = getFolderComics(folder);
for (var comic in comics) {
var translatedTags = _translateTags(comic.tags);
_db.execute("""
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
} else {
appdata.settings['followUpdatesFolder'] = null;
}
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
}
List<String> find(String id, ComicType type) {
@@ -349,7 +373,7 @@ class LocalFavoritesManager with ChangeNotifier {
""").firstOrNull?["min_value"] ?? 0;
}
List<FavoriteItem> getAllComics(String folder) {
List<FavoriteItem> getFolderComics(String folder) {
var rows = _db.select("""
select * from "$folder"
ORDER BY display_order;
@@ -357,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
}
static Future<List<FavoriteItem>> _getFolderComicsAsync(
String folder, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var rows = db.select("""
select * from "$folder"
ORDER BY display_order;
""");
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
});
}
/// Start a new isolate to get the comics in the folder
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
return _getFolderComicsAsync(folder, _db.handle);
}
List<FavoriteItem> getAllComics() {
var res = <FavoriteItem>{};
for (final folder in folderNames) {
var comics = _db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
}
static Future<List<FavoriteItem>> _getAllComicsAsync(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var res = <FavoriteItem>{};
for (final folder in folders) {
var comics = db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
});
}
/// Start a new isolate to get all the comics
Future<List<FavoriteItem>> getAllComicsAsync() {
return _getAllComicsAsync(folderNames, _db.handle);
}
void addTagTo(String folder, String id, String tag) {
_db.execute("""
update "$folder"
@@ -422,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
);
""");
notifyListeners();
counts[name] = 0;
return name;
}
@@ -536,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]);
}
}
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners();
return true;
}
@@ -585,6 +663,7 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order
where folder_name == ?;
""", [name]);
counts.remove(name);
notifyListeners();
}
@@ -600,6 +679,11 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder"
where id == ? and type == ?;
""", [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
notifyListeners();
}
@@ -736,10 +820,10 @@ class LocalFavoritesManager with ChangeNotifier {
return comics;
}
List<FavoriteItemWithFolderInfo> search(String keyword) {
List<FavoriteItem> search(String keyword) {
var keywordList = keyword.split(" ");
keyword = keywordList.first;
var comics = <FavoriteItemWithFolderInfo>[];
var comics = <FavoriteItem>{};
for (var table in folderNames) {
keyword = "%$keyword%";
var res = _db.select("""
@@ -747,15 +831,18 @@ class LocalFavoritesManager with ChangeNotifier {
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]);
for (var comic in res) {
comics.add(
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
comics.add(FavoriteItem.fromRow(comic));
}
if (comics.length > 200) {
break;
}
}
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
bool test(FavoriteItem comic, String keyword) {
keyword = keyword.trim();
if (keyword.isEmpty) {
return true;
}
if (comic.name.contains(keyword)) {
return true;
} else if (comic.author.contains(keyword)) {
@@ -766,12 +853,14 @@ class LocalFavoritesManager with ChangeNotifier {
return false;
}
for (var i = 1; i < keywordList.length; i++) {
comics =
comics.where((element) => test(element, keywordList[i])).toList();
}
return comics;
return comics.where((element) {
for (var i = 1; i < keywordList.length; i++) {
if (!test(element, keywordList[i])) {
return false;
}
}
return true;
}).toList();
}
void editTags(String id, String folder, List<String> tags) {

View File

@@ -25,6 +25,7 @@ import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart';
@@ -194,7 +195,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain,
validateStatus: (status) => true,
));
var proxy = await AppDio.getProxy();
var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart';
@@ -51,6 +54,14 @@ Future<void> init() async {
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
if (App.isWindows) {
// Report to the monitor thread that the app is running
// https://github.com/venera-app/venera/issues/343
Timer.periodic(const Duration(seconds: 1), (_) {
const methodChannel = MethodChannel('venera/method_channel');
methodChannel.invokeMethod("heartBeat");
});
}
}
void _checkOldConfigs() {

View File

@@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/network/proxy.dart';
import '../foundation/app.dart';
import 'cloudflare.dart';
@@ -96,9 +96,11 @@ class MyLogInterceptor implements Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15);
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
}
class AppDio with DioMixin {
String? _proxy = proxy;
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
httpClientAdapter = RHttpAdapter();
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
static String? proxy;
static Future<String?> getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}
static final Map<String, bool> _requests = {};
@override
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
_requests[path] = true;
options!.headers!.remove('prevent-parallel');
}
proxy = await getProxy();
if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy");
_proxy = proxy;
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
}
try {
return super.request<T>(
path,
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
}
class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings;
Future<rhttp.ClientSettings> get settings async {
var proxy = await getProxy();
return rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy),
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) {
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
return result;
}
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
@override
void close({bool force = false}) {}
@@ -256,10 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (options.headers['User-Agent'] == null &&
options.headers['user-agent'] == null) {
options.headers['User-Agent'] = "venera/v${App.version}";
}
var res = await rhttp.Rhttp.request(
method: rhttp.HttpMethod(options.method),
url: options.uri.toString(),
settings: settings,
settings: await settings,
expectBody: rhttp.HttpExpectBody.stream,
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
headers: rhttp.HttpHeaders.rawMap(
@@ -289,7 +240,7 @@ class RHttpAdapter implements HttpClientAdapter {
}
static String _getStatusMessage(int statusCode) {
return switch(statusCode) {
return switch (statusCode) {
200 => "OK",
201 => "Created",
202 => "Accepted",
@@ -299,9 +250,11 @@ class RHttpAdapter implements HttpClientAdapter {
302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.",
401 => "Invalid Status Code 401: The Request is unauthorized.",
403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.",
403 =>
"Invalid Status Code 403: No permission to access the resource. Check your account or network.",
404 => "Invalid Status Code 404: Not found.",
429 => "Invalid Status Code 429: Too many requests. Please try again later.",
429 =>
"Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode",
};
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:dio/io.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart';
class FileDownloader {
@@ -105,7 +106,7 @@ class FileDownloader {
void _download(StreamController<DownloadingStatus> resultStream) async {
try {
var proxy = await AppDio.getProxy();
var proxy = await getProxy();
_dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart';
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
import 'app_dio.dart';
class ImageDownloader {
abstract class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey,
[String? cid]) async* {
@@ -82,7 +83,35 @@ class ImageDownloader {
);
}
static final _loadingImages = <String, _StreamWrapper<ImageDownloadProgress>>{};
/// Cancel all loading images.
static void cancelAllLoadingImages() {
for (var wrapper in _loadingImages.values) {
wrapper.cancel();
}
_loadingImages.clear();
}
/// Load a comic image from the network or cache.
/// The function will prevent multiple requests for the same image.
static Stream<ImageDownloadProgress> loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
if (_loadingImages.containsKey(cacheKey)) {
return _loadingImages[cacheKey]!.stream;
}
final stream = _StreamWrapper<ImageDownloadProgress>(
_loadComicImage(imageKey, sourceKey, cid, eid),
(wrapper) {
_loadingImages.remove(cacheKey);
},
);
_loadingImages[cacheKey] = stream;
return stream.stream;
}
static Stream<ImageDownloadProgress> _loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey);
@@ -189,6 +218,63 @@ class ImageDownloader {
}
}
/// A wrapper class for a stream that
/// allows multiple listeners to listen to the same stream.
class _StreamWrapper<T> {
final Stream<T> _stream;
final List<StreamController> controllers = [];
final void Function(_StreamWrapper<T> wrapper) onClosed;
bool isClosed = false;
_StreamWrapper(this._stream, this.onClosed) {
_listen();
}
void _listen() async {
await for (var data in _stream) {
if (isClosed) {
break;
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.add(data);
}
}
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
}
}
controllers.clear();
isClosed = true;
onClosed(this);
}
Stream<T> get stream {
if (isClosed) {
throw Exception('Stream is closed');
}
var controller = StreamController<T>();
controllers.add(controller);
controller.onCancel = () {
controllers.remove(controller);
};
return controller.stream;
}
void cancel() {
for (var controller in controllers) {
controller.close();
}
controllers.clear();
isClosed = true;
}
}
class ImageDownloadProgress {
final int currentBytes;

60
lib/network/proxy.dart Normal file
View File

@@ -0,0 +1,60 @@
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/utils/ext.dart';
String? _cachedProxy;
DateTime? _cachedProxyTime;
Future<String?> getProxy() async {
if (_cachedProxyTime != null &&
DateTime.now().difference(_cachedProxyTime!).inSeconds < 1) {
return _cachedProxy;
}
String? proxy = await _getProxy();
_cachedProxy = proxy;
_cachedProxyTime = DateTime.now();
return proxy;
}
Future<String?> _getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}

View File

@@ -322,127 +322,168 @@ class _ComicSourceList extends StatefulWidget {
}
class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json;
bool changed = false;
var controller = TextEditingController();
void load() async {
var dio = AppDio();
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
if (res.statusCode != 200) {
context.showMessage(message: "Network error".tl);
return;
if (json != null) {
setState(() {
json = null;
});
}
var dio = AppDio();
try {
var res = await dio.get<String>(controller.text);
if (res.statusCode != 200) {
throw "error";
}
if (mounted) {
setState(() {
json = jsonDecode(res.data!);
});
}
}
catch(e) {
context.showMessage(message: "Network error".tl);
if (mounted) {
setState(() {
json = [];
});
}
}
}
@override
void initState() {
super.initState();
controller.text = appdata.settings['comicSourceListUrl'];
load();
}
@override
void dispose() {
super.dispose();
if (changed) {
appdata.settings['comicSourceListUrl'] = controller.text;
appdata.saveData();
}
setState(() {
json = jsonDecode(res.data!);
loading = false;
});
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Comic Source".tl,
tailing: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () async {
await showInputDialog(
context: context,
title: "Set comic source list url".tl,
initialValue: appdata.settings['comicSourceListUrl'],
onConfirm: (value) {
appdata.settings['comicSourceListUrl'] = value;
appdata.saveData();
setState(() {
loading = true;
json = null;
});
return null;
},
);
},
)
],
body: buildBody(),
);
}
Widget buildBody() {
if (loading) {
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: json!.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: context.colorScheme.primaryContainer,
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: (json?.length ?? 1) + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
child: Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Do not report any issues related to sources to App repo.".tl),
Text("Click the setting icon to change the source list url.".tl),
],
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.source_outlined),
title: Text("Source URL".tl),
),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
),
],
),
);
}
index--;
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
onChanged: (value) {
changed = true;
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(description),
trailing: action,
).paddingHorizontal(16).paddingBottom(8),
Text("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),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
controller.text = defaultComicSourceUrl;
changed = true;
},
child: Text("Reset".tl),
),
FilledButton.tonal(
onPressed: load,
child: Text("Refresh".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
);
},
);
}
}
if (index == 1 && json == null) {
return Center(child: CircularProgressIndicator());
}
index--;
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(description),
trailing: action,
);
},
);
}
}

View File

@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
}
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder);
var comics = LocalFavoritesManager().getFolderComics(folder);
Future<void> updateSingleComic(int index) async {
int retry = 3;

View File

@@ -18,14 +18,15 @@ import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart';
part 'side_bar.dart';
part 'local_favorites_page.dart';
part 'network_favorites_page.dart';
part 'local_search_page.dart';
const _kLeftBarWidth = 256.0;

View File

@@ -1,5 +1,11 @@
part of 'favorites_page.dart';
const _localAllFolderLabel = '^_^[%local_all%]^_^';
/// If the number of comics in a folder exceeds this limit, it will be
/// fetched asynchronously.
const _asyncDataFetchLimit = 500;
class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key});
@@ -31,25 +37,112 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex;
bool get isAllFolder => widget.folder == _localAllFolderLabel;
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() {
if (keyword.trim().isEmpty) {
searchResults = comics;
} else {
searchResults = [];
for (var comic in comics) {
if (matchKeyword(keyword, comic)) {
searchResults.add(comic);
}
}
}
});
}
void updateComics() {
if (keyword.isEmpty) {
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
});
if (isLoading) return;
if (isAllFolder) {
var totalComics = manager.totalComics;
if (totalComics < _asyncDataFetchLimit) {
comics = manager.getAllComics();
} else {
isLoading = true;
manager
.getAllComicsAsync()
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} else {
setState(() {
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
});
var folderComics = manager.folderComics(widget.folder);
if (folderComics < _asyncDataFetchLimit) {
comics = manager.getFolderComics(widget.folder);
} else {
isLoading = true;
manager
.getFolderComicsAsync(widget.folder)
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
}
setState(() {});
}
bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" ");
for (var k in list) {
if (k.isEmpty) continue;
if (comic.title.contains(k)) {
continue;
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
continue;
} else if (comic.tags.any((tag) {
if (tag == k) {
return true;
} else if (tag.contains(':') && tag.split(':')[1] == k) {
return true;
} else if (App.locale.languageCode != 'en' &&
tag.translateTagsToCN == k) {
return true;
}
return false;
})) {
continue;
} else if (comic.author == k) {
continue;
}
return false;
}
return true;
}
@override
void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
} else {
networkSource = null;
networkFolder = null;
}
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics);
super.initState();
}
@@ -113,6 +206,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
@override
Widget build(BuildContext context) {
var title = favPage.folder ?? "Unselected".tl;
if (title == _localAllFolderLabel) {
title = "All".tl;
}
Widget body = SmoothCustomScrollView(
controller: scrollController,
slivers: [
@@ -135,10 +233,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector
: null,
child: Text(favPage.folder ?? "Unselected".tl),
child: Text(title),
),
actions: [
if (networkSource != null)
if (networkSource != null && !isAllFolder)
Tooltip(
message: "Sync".tl,
child: Flyout(
@@ -191,14 +289,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.search),
onPressed: () {
setState(() {
keyword = "";
searchMode = true;
updateSearchResult();
});
},
),
),
MenuButton(
entries: [
MenuEntry(
if (!isAllFolder)
MenuButton(
entries: [
MenuEntry(
icon: Icons.edit_outlined,
text: "Rename".tl,
onClick: () {
@@ -220,8 +321,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null;
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.reorder,
text: "Reorder".tl,
onClick: () {
@@ -241,8 +343,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.upload_file,
text: "Export".tl,
onClick: () {
@@ -253,8 +356,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
data: utf8.encode(json),
filename: "${widget.folder}.json",
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.update,
text: "Update Comics Info".tl,
onClick: () {
@@ -265,8 +369,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
});
}
});
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Folder".tl,
color: context.colorScheme.error,
@@ -284,9 +389,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders();
},
);
}),
],
),
},
),
],
),
],
)
else if (multiSelectMode)
@@ -330,22 +436,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: Icons.flip,
text: "Invert Selection".tl,
onClick: invertSelection),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Comic".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: context,
title: "Delete".tl,
content: "Delete @c comics?"
.tlParams({"c": selectedComics.length}),
btnColor: context.colorScheme.error,
onConfirm: () {
_deleteComicWithId();
},
);
}),
if (!isAllFolder)
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Comic".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: context,
title: "Delete".tl,
content: "Delete @c comics?"
.tlParams({"c": selectedComics.length}),
btnColor: context.colorScheme.error,
onConfirm: () {
_deleteComicWithId();
},
);
}),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
@@ -380,9 +487,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
updateComics();
setState(() {
searchMode = false;
});
});
},
),
@@ -391,131 +498,142 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
border: UnderlineInputBorder(),
),
onChanged: (v) {
keyword = v;
updateComics();
updateSearchResult();
},
),
).paddingBottom(8).paddingRight(8),
),
SliverGridComics(
comics: comics,
selections: selectedComics,
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
if (isLoading)
SliverToBoxAdapter(
child: SizedBox(
height: 200,
child: const Center(
child: CircularProgressIndicator(),
),
MenuEntry(
icon: Icons.check,
text: "Select".tl,
onClick: () {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
if (appdata.settings["onClickFavorite"] == "viewDetail")
),
)
else
SliverGridComics(
comics: searchMode ? searchResults : comics,
selections: selectedComics,
menuBuilder: (c) {
return [
if (!isAllFolder)
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
icon: Icons.check,
text: "Select".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
];
},
onTap: (c) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
if (appdata.settings["onClickFavorite"] == "viewDetail")
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
},
),
];
},
onTap: (c) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
}
for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue;
for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue;
var comic = comics[i];
if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic);
} else {
selectedComics[comic] = true;
var comic = comics[i];
if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic);
} else {
selectedComics[comic] = true;
}
}
}
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
}
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
}
_checkExitSelectMode();
});
},
),
_checkExitSelectMode();
});
},
),
],
);
body = AppScrollBar(
@@ -725,7 +843,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
final _key = GlobalKey();
var reorderWidgetKey = UniqueKey();
final _scrollController = ScrollController();
late var comics = LocalFavoritesManager().getAllComics(widget.name);
late var comics = LocalFavoritesManager().getFolderComics(widget.name);
bool changed = false;
static int _floatToInt8(double x) {

View File

@@ -1,41 +0,0 @@
part of 'favorites_page.dart';
class LocalSearchPage extends StatefulWidget {
const LocalSearchPage({super.key});
@override
State<LocalSearchPage> createState() => _LocalSearchPageState();
}
class _LocalSearchPageState extends State<LocalSearchPage> {
String keyword = '';
var comics = <FavoriteItemWithFolderInfo>[];
late final SearchBarController controller;
@override
void initState() {
super.initState();
controller = SearchBarController(onSearch: (text) {
keyword = text;
comics = LocalFavoritesManager().search(keyword);
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(slivers: [
SliverSearchBar(controller: controller),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return (c as FavoriteItemWithFolderInfo).folder;
},
),
]),
);
}
}

View File

@@ -86,58 +86,14 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar
? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2,
itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
return buildLocalTitle();
}
index--;
if (index == 0) {
return buildLocalFolder(_localAllFolderLabel);
}
index--;
if (index < folders.length) {
@@ -145,38 +101,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
}
index -= folders.length;
if (index == 0) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
return buildNetworkTitle();
}
index--;
return buildNetworkFolder(networkFolders[index]);
@@ -188,8 +113,95 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
);
}
Widget buildLocalTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
}
Widget buildNetworkTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
}
Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork;
int count = 0;
if (name == _localAllFolderLabel) {
count = LocalFavoritesManager().totalComics;
} else {
count = LocalFavoritesManager().folderComics(name);
}
var folderName = name == _localAllFolderLabel
? "All".tl
: getFavoriteDataOrNull(name)?.title ?? name;
return InkWell(
onTap: () {
if (isSelected) {
@@ -214,7 +226,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
),
),
padding: const EdgeInsets.only(left: 16),
child: Text(name),
child: Row(
children: [
Expanded(
child: Text(folderName),
),
Container(
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(count.toString()),
),
],
),
),
);
}

View File

@@ -306,7 +306,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
});
} else {
// prevent dirty data
var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
var comic =
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
comic.read();
}
},
@@ -444,7 +445,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
var fileName = "";
// For each comic, export it to a file
for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
fileName = FilePath.join(
cacheDir,
sanitizeFileName(comic.title, maxLength: 100) + ext,
);
await export(comic, fileName);
current++;
if (comics.length > 1) {

View File

@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false;
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
void onTapUp(TapUpDetails event) {
if (_longPressInProgress) {
_longPressInProgress = false;
return;
}
final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) {
if ((location - previousLocation).distanceSquared <
@@ -287,6 +293,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
text: "Copy Image".tl,
onClick: () => copyImage(location),
),
if (!reader.isLoading)
MenuEntry(
icon: Icons.download_outlined,
text: "Save Image".tl,
onClick: () => saveImage(location),
),
],
);
}
@@ -319,6 +331,17 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
context.showMessage(message: "No Image");
}
}
void saveImage(Offset location) async {
var controller = reader._imageViewController;
var image = await controller!.getImageByOffset(location);
if (image != null) {
var filetype = detectFileType(image);
saveFile(filename: "image${filetype.ext}", data: image);
} else {
context.showMessage(message: "No Image");
}
}
}
class _DragListener {

View File

@@ -21,6 +21,12 @@ class _ReaderImagesState extends State<_ReaderImages> {
super.initState();
}
@override
void dispose() {
super.dispose();
ImageDownloader.cancelAllLoadingImages();
}
void load() async {
if (inProgress) return;
inProgress = true;
@@ -104,15 +110,22 @@ class _GalleryModeState extends State<_GalleryMode>
implements _ImageViewController {
late PageController controller;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader;
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
/// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page.
int get totalPages {
if (!reader.showSingleImageOnFirstPage) {
return (reader.images!.length / reader.imagesPerPage).ceil();
} else {
return 1 +
((reader.images!.length - 1) / reader.imagesPerPage).ceil();
}
}
var imageStates = <State<ComicImage>>{};
@@ -125,24 +138,51 @@ class _GalleryModeState extends State<_GalleryMode>
reader = context.reader;
controller = PageController(initialPage: reader.page);
reader._imageViewController = this;
cached = List.filled(reader.maxPage + 2, false);
Future.microtask(() {
context.readerScaffold.setFloatingButton(0);
});
super.initState();
}
void cache(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= totalPages && !cached[i]) {
int startIndex = (i - 1) * reader.imagesPerPage;
int endIndex =
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
for (int i = startIndex; i < endIndex; i++) {
precacheImage(
_createImageProviderFromKey(reader.images![i], context), context);
}
cached[i] = true;
/// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) {
if (reader.showSingleImageOnFirstPage) {
if (page == 1) {
return (0, 1);
} else {
int startIndex = (page - 2) * reader.imagesPerPage + 1;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
return (startIndex, endIndex);
}
} else {
int startIndex = (page - 1) * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
return (startIndex, endIndex);
}
}
/// [cache] is used to cache the images.
/// The count of images to cache is determined by the [preCacheCount] setting.
/// For previous page and next page, it will do a memory cache.
/// For current page, it will do nothing because it is already on the screen.
/// For other pages, it will do a pre-download cache.
void cache(int startPage) {
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
if (i == startPage || i <= 0 || i > totalPages) continue;
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
_cachePage(i, shouldPreCache);
}
}
void _cachePage(int page, bool shouldPreCache) {
var (startIndex, endIndex) = getPageImagesRange(page);
for (int i = startIndex; i < endIndex; i++) {
if (shouldPreCache) {
_precacheImage(i+1, context);
} else {
_preDownloadImage(i+1, context);
}
}
}
@@ -185,14 +225,10 @@ class _GalleryModeState extends State<_GalleryMode>
child: const SizedBox(),
);
} else {
int pageIndex = index - 1;
int startIndex = pageIndex * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
var (startIndex, endIndex) = getPageImagesRange(index);
List<String> pageImages =
reader.images!.sublist(startIndex, endIndex);
cached[index] = true;
cache(index);
photoViewControllers[index] ??= PhotoViewController();
@@ -201,8 +237,11 @@ class _GalleryModeState extends State<_GalleryMode>
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider:
_createImageProviderFromKey(pageImages[0], context),
imageProvider: _createImageProviderFromKey(
pageImages[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
@@ -211,10 +250,11 @@ class _GalleryModeState extends State<_GalleryMode>
}
return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2,
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages),
child: buildPageImages(pageImages, startIndex),
);
}
},
@@ -244,12 +284,19 @@ class _GalleryModeState extends State<_GalleryMode>
reader.setPage(i);
context.readerScaffold.update();
}
// Remove other pages' controllers to reset their state.
var keys = photoViewControllers.keys.toList();
for (var key in keys) {
if (key != i) {
photoViewControllers.remove(key);
}
}
},
),
);
}
Widget buildPageImages(List<String> images) {
Widget buildPageImages(List<String> images, int startIndex) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical
: Axis.horizontal;
@@ -267,7 +314,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(images[0], context),
image: _createImageProviderFromKey(
images[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.bottomCenter
@@ -280,7 +331,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(images[1], context),
image: _createImageProviderFromKey(
images[1],
context,
startIndex + 2,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.topCenter
@@ -292,8 +347,9 @@ class _GalleryModeState extends State<_GalleryMode>
];
} else {
imageWidgets = images.map((imageKey) {
startIndex++;
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
_createImageProviderFromKey(imageKey, context, startIndex);
return Expanded(
child: ComicImage(
image: imageProvider,
@@ -402,34 +458,24 @@ class _GalleryModeState extends State<_GalleryMode>
keyRepeatTimer = null;
}
if (forward == true) {
controller.nextPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
reader.toPage(reader.page+1);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
reader.toPage(reader.page-1);
}
}
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic(
const Duration(milliseconds: 100),
reader.enablePageAnimation
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50),
(timer) {
if (!mounted) {
timer.cancel();
return;
} else if (forward == true) {
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
reader.toPage(reader.page+1);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
reader.toPage(reader.page-1);
}
},
);
@@ -447,6 +493,19 @@ class _GalleryModeState extends State<_GalleryMode>
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
if (reader.imagesPerPage == 1) {
imageKey = reader.images![reader.page - 1];
@@ -457,14 +516,7 @@ class _GalleryModeState extends State<_GalleryMode>
}
}
}
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
return imageKey;
}
}
@@ -599,7 +651,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context);
_preDownloadImage(i, context);
cached[i] = true;
}
}
@@ -975,13 +1027,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
if (forward == true) {
scrollController.animateTo(
scrollController.offset + context.height,
scrollController.offset + context.height * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) {
scrollController.animateTo(
scrollController.offset - context.height,
scrollController.offset - context.height * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
@@ -998,12 +1050,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
String? imageKey;
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
@@ -1013,10 +1060,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
return imageKey;
}
}
ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) {
String imageKey,
BuildContext context,
int page,
) {
var reader = context.reader;
return ReaderImageProvider(
imageKey,
@@ -1030,16 +1091,39 @@ ImageProvider _createImageProviderFromKey(
ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader;
var imageKey = reader.images![page - 1];
return _createImageProviderFromKey(imageKey, context);
return _createImageProviderFromKey(imageKey, context, page);
}
/// [_precacheImage] is used to precache the image for the given page.
/// The image is cached using the flutter's [precacheImage] method.
/// The image will be downloaded and decoded into memory.
void _precacheImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
precacheImage(
_createImageProvider(page, context),
context,
);
}
/// [_preDownloadImage] is used to download the image for the given page.
/// The image is downloaded using the [CacheManager] and saved to the local storage.
void _preDownloadImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
var reader = context.reader;
var imageKey = reader.images![page - 1];
if (imageKey.startsWith("file://")) {
return;
}
var cid = reader.cid;
var eid = reader.eid;
var sourceKey = reader.type.comicSource?.key;
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
}
class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({
this.controller,

View File

@@ -29,6 +29,7 @@ import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/clipboard_image.dart';
import 'package:venera/utils/data_sync.dart';
@@ -110,7 +111,16 @@ class _ReaderState extends State<Reader>
}
@override
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
int get maxPage {
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage) {
return (images!.length / imagesPerPage).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
}
}
ComicType get type => widget.type;
@@ -124,7 +134,8 @@ class _ReaderState extends State<Reader>
late ReaderMode mode;
@override
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
bool get isPortrait =>
MediaQuery.of(context).orientation == Orientation.portrait;
History? history;
@@ -216,10 +227,16 @@ class _ReaderState extends State<Reader>
focusNode: focusNode,
autofocus: true,
onKeyEvent: onKeyEvent,
child: _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
);
})
],
),
);
}
@@ -336,6 +353,9 @@ abstract mixin class _ImagePerPageHandler {
}
}
bool get showSingleImageOnFirstPage =>
appdata.settings["showSingleImageOnFirstPage"];
/// The number of images displayed on one screen
int get imagesPerPage {
if (mode.isContinuous) return 1;
@@ -603,4 +623,6 @@ abstract interface class _ImageViewController {
bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset);
String? getImageKeyByOffset(Offset offset);
}

View File

@@ -208,7 +208,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
void addImageFavorite() {
void addImageFavorite() async {
try {
if (context.reader.images![0].contains('file://')) {
showToast(
@@ -222,7 +222,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length;
int page = context.reader.page;
int? page = await selectImage();
if (page == null) return;
page += 1;
String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags;
@@ -378,11 +380,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
icon:
Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite,
),
),
if (App.isWindows)
if (App.isDesktop)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
@@ -570,94 +573,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
Future<Uint8List?> _getCurrentImageData() async {
var imageKey = context.reader.images![context.reader.page - 1];
var reader = context.reader;
if (context.reader.mode.isContinuous) {
var continuesState =
context.reader._imageViewController as _ContinuousModeState;
var imagesOnScreen =
continuesState.itemPositionsListener.itemPositions.value;
var images = imagesOnScreen
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
.whereType<String>()
.toList();
String? selected;
if (images.length > 1) {
await showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Select an image on screen".tl,
body: GridView.builder(
itemCount: images.length,
itemBuilder: (context, index) {
ImageProvider image;
var imageKey = images[index];
if (imageKey.startsWith('file://')) {
image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else {
image = ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
reader.page,
);
}
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
selected = images[index];
App.rootContext.pop();
},
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity,
height: double.infinity,
child: Image(
width: double.infinity,
height: double.infinity,
image: image,
),
),
).padding(const EdgeInsets.all(8));
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.7,
),
),
),
);
} else {
selected = images.first;
}
if (selected == null) {
return null;
} else {
imageKey = selected!;
}
}
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
void saveCurrentImage() async {
var data = await _getCurrentImageData();
var data = await selectImageToData();
if (data == null) {
return;
}
@@ -667,7 +584,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
void share() async {
var data = await _getCurrentImageData();
var data = await selectImageToData();
if (data == null) {
return;
}
@@ -750,9 +667,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
@@ -761,6 +676,74 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
return const SizedBox();
}
/// If there is only one image on screen, return it.
///
/// If there are multiple images on screen,
/// show an overlay to let the user select an image.
///
/// The return value is the index of the selected image.
Future<int?> selectImage() async {
var reader = context.reader;
var imageViewController = context.reader._imageViewController;
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
return reader.page - 1;
} else {
var location = await _showSelectImageOverlay();
if (location == null) {
return null;
}
var imageKey = imageViewController!.getImageKeyByOffset(location);
if (imageKey == null) {
return null;
}
return reader.images!.indexOf(imageKey);
}
}
/// Same as [selectImage], but return the image data.
Future<Uint8List?> selectImageToData() async {
var i = await selectImage();
if (i == null) {
return null;
}
var imageKey = context.reader.images![i];
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
Future<Offset?> _showSelectImageOverlay() {
if (_isOpen) {
openOrClose();
}
var completer = Completer<Offset?>();
var overlay = Overlay.of(context);
OverlayEntry? entry;
entry = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: _SelectImageOverlayContent(onTap: (offset) {
completer.complete(offset);
entry!.remove();
}, onDispose: () {
if (!completer.isCompleted) {
completer.complete(null);
}
}),
);
},
);
overlay.insert(entry);
return completer.future;
}
}
class _BatteryWidget extends StatefulWidget {
@@ -941,3 +924,69 @@ class _ClockWidgetState extends State<_ClockWidget> {
);
}
}
class _SelectImageOverlayContent extends StatefulWidget {
const _SelectImageOverlayContent({
required this.onTap,
required this.onDispose,
});
final void Function(Offset) onTap;
final void Function() onDispose;
@override
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState();
}
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> {
@override
void dispose() {
widget.onDispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
widget.onTap(details.globalPosition);
},
child: Container(
color: Colors.black.withAlpha(50),
child: Align(
alignment: Alignment(
0,
-0.8,
),
child: Container(
width: 232,
height: 42,
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: context.colorScheme.outlineVariant,
),
),
child: Row(
children: [
const SizedBox(width: 8),
const Icon(Icons.info_outline),
const SizedBox(width: 16),
Text(
"Click to select an image".tl,
style: TextStyle(
fontSize: 16,
color: context.colorScheme.onSurface,
),
),
],
),
),
),
),
);
}
}

View File

@@ -66,6 +66,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1,
max: 20,
onChanged: () {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval");
},
).toSliver(),
@@ -80,6 +81,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1,
max: 5,
onChanged: () {
setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
),
@@ -99,6 +101,26 @@ class _ReaderSettingsState extends State<ReaderSettings> {
},
),
),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery') &&
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
child: _SwitchSetting(
title: "Show single image on first page".tl,
settingKey: "showSingleImageOnFirstPage",
onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage");
},
),
),
_SwitchSetting(
title: 'Double tap to zoom'.tl,
settingKey: 'enableDoubleTapToZoom',
onChanged: () {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
).toSliver(),
_SwitchSetting(
title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom',

View File

@@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'dart:io' as io;
@@ -308,7 +308,7 @@ class DesktopWebview {
useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview",
proxy: AppDio.proxy,
proxy: await getProxy(),
));
_webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) {

View File

@@ -112,7 +112,7 @@ abstract class CBZ {
var ext = e.path.split('.').last;
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
});
if(files.isEmpty) {
if (files.isEmpty) {
cache.deleteSync(recursive: true);
throw Exception('No images found in the archive');
}
@@ -141,8 +141,7 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
);
dest.createSync();
coverFile.copyMem(
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) {
var src = files[i];
@@ -233,17 +232,19 @@ abstract class CBZ {
}
}
var cover = comic.coverFile;
await cover
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
await cover.copyMem(
FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
final metaData = ComicMetaData(
title: comic.title,
author: comic.subtitle,
tags: comic.tags,
chapters: chapters,
);
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode(
ComicMetaData(
title: comic.title,
author: comic.subtitle,
tags: comic.tags,
chapters: chapters,
).toJson(),
),
jsonEncode(metaData),
);
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
_buildComicInfoXml(metaData),
);
var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync();
@@ -252,7 +253,54 @@ abstract class CBZ {
return cbz;
}
static String _buildComicInfoXml(ComicMetaData data) {
final buffer = StringBuffer();
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
if (data.author.isNotEmpty) {
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
}
if (data.tags.isNotEmpty) {
var tags = data.tags;
if (tags.length > 5) {
tags = tags.sublist(0, 5);
}
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
}
if (data.chapters != null && data.chapters!.isNotEmpty) {
final chaptersInfo = data.chapters!.map((chapter) =>
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
).join('; ');
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
}
buffer.writeln(' <Manga>Unknown</Manga>');
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
final now = DateTime.now();
buffer.writeln(' <Year>${now.year}</Year>');
buffer.writeln('</ComicInfo>');
return buffer.toString();
}
static String _escapeXml(String text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
static _compress(String src, String dst) async {
await ZipFile.compressFolderAsync(src, dst, 4);
}
}

View File

@@ -11,7 +11,6 @@ import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/utils/translations.dart';
import 'io.dart';
@@ -119,19 +118,11 @@ class DataSync with ChangeNotifier {
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
adapter: RHttpAdapter(),
);
try {
@@ -192,19 +183,11 @@ class DataSync with ChangeNotifier {
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
adapter: RHttpAdapter(),
);
try {

View File

@@ -107,4 +107,15 @@ abstract class MapOrNull{
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
return i == null ? null : Map<K, V>.from(i);
}
}
extension FutureExt<T> on Future<T>{
/// Wrap the future to make sure it will return at least the duration.
Future<T> minTime(Duration duration) async {
var res = await Future.wait([
this,
Future.delayed(duration),
]);
return res[0];
}
}

View File

@@ -132,15 +132,15 @@ extension DirectoryExtension on Directory {
/// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (fileName.endsWith('.')) {
while (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1);
}
var maxLength = 255;
var length = maxLength ?? 255;
if (dir != null) {
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
dir = "$dir/";
}
maxLength -= dir.length;
length -= dir.length;
}
final invalidChars = RegExp(r'[<>:"/\\|?*]');
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
@@ -148,11 +148,11 @@ String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (trimmedFileName.isEmpty) {
throw Exception('Invalid File Name: Empty length.');
}
if (maxLength <= 0) {
if (length <= 0) {
throw Exception('Invalid File Name: Max length is less than 0.');
}
if (trimmedFileName.length > maxLength) {
trimmedFileName = trimmedFileName.substring(0, maxLength);
if (trimmedFileName.length > length) {
trimmedFileName = trimmedFileName.substring(0, length);
}
return trimmedFileName;
}

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.12.0"
battery_plus:
dependency: "direct main"
description:
@@ -182,10 +182,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "1.3.2"
ffi:
dependency: transitive
description:
@@ -308,18 +308,18 @@ packages:
dependency: "direct main"
description:
path: flutter_inappwebview
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "6.2.0-beta.3"
flutter_inappwebview_android:
dependency: transitive
description:
path: flutter_inappwebview_android
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations:
@@ -334,45 +334,45 @@ packages:
dependency: transitive
description:
path: flutter_inappwebview_ios
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_macos:
dependency: transitive
description:
path: flutter_inappwebview_macos
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
path: flutter_inappwebview_platform_interface
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "1.4.0-beta.3"
flutter_inappwebview_web:
dependency: transitive
description:
path: flutter_inappwebview_web
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_windows:
dependency: transitive
description:
path: flutter_inappwebview_windows
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/venera-app/flutter_inappwebview"
source: git
version: "0.7.0-beta.3"
flutter_lints:
@@ -516,10 +516,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.20.2"
version: "0.19.0"
io:
dependency: transitive
description:
@@ -540,10 +540,10 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
@@ -1029,10 +1029,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "15.0.0"
version: "14.3.1"
web:
dependency: transitive
description:
@@ -1100,4 +1100,4 @@ packages:
version: "0.0.12"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.2"
flutter: ">=3.29.3"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.4.0+140
version: 1.4.2+142
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.29.2
flutter: 3.29.3
dependencies:
flutter:
@@ -46,9 +46,9 @@ dependencies:
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
flutter_inappwebview:
git:
url: https://github.com/pichillilorenzo/flutter_inappwebview
url: https://github.com/venera-app/flutter_inappwebview
path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
ref: 3ef899b3db57c911b080979f1392253b835f98ab
app_links: ^6.4.0
sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2

View File

@@ -10,11 +10,16 @@
#include <flutter/event_stream_handler_functions.h>
#include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h"
#include <thread>
#define _CRT_SECURE_NO_WARNINGS
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr;
std::atomic<bool> mainThreadAlive(true);
std::atomic<std::chrono::steady_clock::time_point> lastHeartbeat(std::chrono::steady_clock::now());
std::thread* monitorThread = nullptr;
char* wideCharToMultiByte(wchar_t* pWCStrKey)
{
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL);
@@ -45,6 +50,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project)
FlutterWindow::~FlutterWindow() {}
void monitorUIThread() {
const auto timeout = std::chrono::seconds(5);
while (mainThreadAlive.load()) {
auto now = std::chrono::steady_clock::now();
auto duration = now - lastHeartbeat.load();
if (duration > timeout) {
std::cerr << "The UI thread is dead. Terminate the application.";
std::exit(0);
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
@@ -78,6 +99,13 @@ bool FlutterWindow::OnCreate() {
result->Success(flutter::EncodableValue("No Proxy"));
delete(res);
}
else if (call.method_name() == "heartBeat") {
if (monitorThread == nullptr) {
monitorThread = new std::thread{ monitorUIThread };
}
lastHeartbeat = std::chrono::steady_clock::now();
result->Success();
}
});
flutter::EventChannel<> channel2(
@@ -163,6 +191,10 @@ void FlutterWindow::OnDestroy() {
}
Win32Window::OnDestroy();
if (monitorThread != nullptr) {
mainThreadAlive = false;
monitorThread->join();
}
}
void mouse_side_button_listener(unsigned int input)