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": "屏幕中心", "Screen center": "屏幕中心",
"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仓库报告任何与源相关的问题",
"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": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -777,6 +781,10 @@
"Screen center": "螢幕中心", "Screen center": "螢幕中心",
"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倉庫報告任何與源相關的問題",
"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

@@ -53,5 +53,7 @@
<true/> <true/>
<key>NSFaceIDUsageDescription</key> <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> </dict>
</plist> </plist>

View File

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

View File

@@ -178,13 +178,14 @@ 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': 'comicSourceListUrl': defaultComicSourceUrl,
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4, 'preloadImageCount': 4,
'followUpdatesFolder': null, 'followUpdatesFolder': null,
'initialPage': '0', 'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous 'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true, 'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
}; };
operator [](String key) { operator [](String key) {
@@ -219,3 +220,5 @@ function processImage(image, cid, eid, page, sourceKey) {
return futureImage; 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; int _limitSize = 2 * 1024 * 1024 * 1024;
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');
_db.execute(''' _db.execute('''
@@ -33,100 +33,102 @@ class CacheManager {
type TEXT type TEXT
) )
'''); ''');
compute((path) => Directory(path).size, cachePath) compute((path) => Directory(path).size, cachePath).then((value) {
.then((value) => _currentSize = value); _currentSize = value;
checkCache();
});
} }
/// Get the singleton instance of CacheManager.
factory CacheManager() => instance ??= CacheManager._create(); factory CacheManager() => instance ??= CacheManager._create();
/// set cache size limit in MB /// set cache size limit in MB
void setLimitSize(int size){ void setLimitSize(int size) {
_limitSize = size * 1024 * 1024; _limitSize = size * 1024 * 1024;
} }
void setType(String key, String? type){ /// Write cache to disk.
_db.execute(''' Future<void> writeCache(String key, List<int> data,
UPDATE cache [int duration = 7 * 24 * 60 * 60 * 1000]) async {
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{
this.dir++; this.dir++;
this.dir %= 100; this.dir %= 100;
var dir = this.dir; 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'); 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.create(recursive: true);
await file.writeAsBytes(data); await file.writeAsBytes(data);
var expires = DateTime.now().millisecondsSinceEpoch + duration; var expires = DateTime.now().millisecondsSinceEpoch + duration;
_db.execute(''' _db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?) INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir.toString(), name, expires]); ''', [key, dir.toString(), name, expires]);
if(_currentSize != null) { if (_currentSize != null) {
_currentSize = _currentSize! + data.length; _currentSize = _currentSize! + data.length;
} }
checkCacheIfRequired(); checkCacheIfRequired();
} }
Future<CachingFile> openWrite(String key) async{ /// Find cache by key.
this.dir++; /// If cache is expired, it will be deleted and return null.
this.dir %= 100; /// If cache is not found, it will return null.
var dir = this.dir; /// If cache is found, it will return the file, and update the expires time.
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString(); Future<File?> findCache(String key) async {
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{
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(res.isEmpty){ if (res.isEmpty) {
return null; return null;
} }
var row = res.first; var row = res.first;
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var expires = row[3] as int;
var file = File('$cachePath/$dir/$name'); 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; return file;
} else {
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
} }
return null; return null;
} }
bool _isChecking = false; bool _isChecking = false;
/// Check cache size and delete expired cache.
/// Only check cache if current size is greater than limit size.
void checkCacheIfRequired() { void checkCacheIfRequired() {
if(_currentSize != null && _currentSize! > _limitSize){ if (_currentSize != null && _currentSize! > _limitSize) {
checkCache(); checkCache();
} }
} }
Future<void> checkCache() async{ /// Check cache size and delete expired cache.
if(_isChecking){ /// 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; return;
} }
_isChecking = true; _isChecking = true;
@@ -134,11 +136,13 @@ class CacheManager {
SELECT * FROM cache SELECT * FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
for(var row in res){ for (var row in res) {
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); 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(); await file.delete();
} }
} }
@@ -147,26 +151,18 @@ class CacheManager {
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
int count = 0; while (_currentSize != null && _currentSize! > _limitSize) {
var res2 = _db.select('''
SELECT COUNT(*) FROM cache
''');
if(res2.isNotEmpty){
count = res2.first[0] as int;
}
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
ORDER BY expires ASC ORDER BY expires ASC
limit 10 limit 10
'''); ''');
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;
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
if(await file.exists()){ if (await file.exists()) {
var size = await file.length(); var size = await file.length();
await file.delete(); await file.delete();
_db.execute(''' _db.execute('''
@@ -174,7 +170,7 @@ class CacheManager {
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
_currentSize = _currentSize! - size; _currentSize = _currentSize! - size;
if(_currentSize! <= _limitSize){ if (_currentSize! <= _limitSize) {
break; break;
} }
} else { } else {
@@ -183,18 +179,18 @@ class CacheManager {
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
} }
count--;
} }
} }
_isChecking = false; _isChecking = false;
} }
Future<void> delete(String key) async{ /// Delete cache by key.
Future<void> delete(String key) async {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(res.isEmpty){ if (res.isEmpty) {
return; return;
} }
var row = res.first; var row = res.first;
@@ -202,7 +198,7 @@ class CacheManager {
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
var fileSize = 0; var fileSize = 0;
if(await file.exists()){ if (await file.exists()) {
fileSize = await file.length(); fileSize = await file.length();
await file.delete(); await file.delete();
} }
@@ -210,11 +206,12 @@ class CacheManager {
DELETE FROM cache DELETE FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(_currentSize != null) { if (_currentSize != null) {
_currentSize = _currentSize! - fileSize; _currentSize = _currentSize! - fileSize;
} }
} }
/// Delete all cache.
Future<void> clear() async { Future<void> clear() async {
await Directory(cachePath).delete(recursive: true); await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
@@ -223,75 +220,4 @@ class CacheManager {
'''); ''');
_currentSize = 0; _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:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db; 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 { Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db"); _db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute(""" _db.execute("""
create table if not exists folder_order ( create table if not exists folder_order (
@@ -234,7 +251,7 @@ class LocalFavoritesManager with ChangeNotifier {
alter table "$folder" alter table "$folder"
add column translated_tags TEXT; add column translated_tags TEXT;
"""); """);
var comics = getAllComics(folder); var comics = getFolderComics(folder);
for (var comic in comics) { for (var comic in comics) {
var translatedTags = _translateTags(comic.tags); var translatedTags = _translateTags(comic.tags);
_db.execute(""" _db.execute("""
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
} else { } else {
appdata.settings['followUpdatesFolder'] = null; appdata.settings['followUpdatesFolder'] = null;
} }
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -349,7 +373,7 @@ class LocalFavoritesManager with ChangeNotifier {
""").firstOrNull?["min_value"] ?? 0; """).firstOrNull?["min_value"] ?? 0;
} }
List<FavoriteItem> getAllComics(String folder) { List<FavoriteItem> getFolderComics(String folder) {
var rows = _db.select(""" var rows = _db.select("""
select * from "$folder" select * from "$folder"
ORDER BY display_order; ORDER BY display_order;
@@ -357,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList(); 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) { void addTagTo(String folder, String id, String tag) {
_db.execute(""" _db.execute("""
update "$folder" update "$folder"
@@ -422,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
); );
"""); """);
notifyListeners(); notifyListeners();
counts[name] = 0;
return name; return name;
} }
@@ -536,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]); """, [updateTime, comic.id, comic.type.value]);
} }
} }
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners(); notifyListeners();
return true; return true;
} }
@@ -585,6 +663,7 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order delete from folder_order
where folder_name == ?; where folder_name == ?;
""", [name]); """, [name]);
counts.remove(name);
notifyListeners(); notifyListeners();
} }
@@ -600,6 +679,11 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
notifyListeners(); notifyListeners();
} }
@@ -736,10 +820,10 @@ class LocalFavoritesManager with ChangeNotifier {
return comics; return comics;
} }
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItem> search(String keyword) {
var keywordList = keyword.split(" "); var keywordList = keyword.split(" ");
keyword = keywordList.first; keyword = keywordList.first;
var comics = <FavoriteItemWithFolderInfo>[]; var comics = <FavoriteItem>{};
for (var table in folderNames) { for (var table in folderNames) {
keyword = "%$keyword%"; keyword = "%$keyword%";
var res = _db.select(""" 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 ?; WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]); """, [keyword, keyword, keyword, keyword]);
for (var comic in res) { for (var comic in res) {
comics.add( comics.add(FavoriteItem.fromRow(comic));
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
} }
if (comics.length > 200) { if (comics.length > 200) {
break; 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)) { if (comic.name.contains(keyword)) {
return true; return true;
} else if (comic.author.contains(keyword)) { } else if (comic.author.contains(keyword)) {
@@ -766,12 +853,14 @@ class LocalFavoritesManager with ChangeNotifier {
return false; return false;
} }
return comics.where((element) {
for (var i = 1; i < keywordList.length; i++) { for (var i = 1; i < keywordList.length; i++) {
comics = if (!test(element, keywordList[i])) {
comics.where((element) => test(element, keywordList[i])).toList(); return false;
} }
}
return comics; return true;
}).toList();
} }
void editTags(String id, String folder, List<String> tags) { 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/foundation/app.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
@@ -194,7 +195,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, responseType: ResponseType.plain,
validateStatus: (status) => true, validateStatus: (status) => true,
)); ));
var proxy = await AppDio.getProxy(); var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter( dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () { createHttpClient: () {
return HttpClient() return HttpClient()

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart'; import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -51,6 +54,14 @@ Future<void> init() async {
FlutterError.onError = (details) { FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}"); 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() { 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/appdata.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart'; import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/network/proxy.dart';
import '../foundation/app.dart'; import '../foundation/app.dart';
import 'cloudflare.dart'; import 'cloudflare.dart';
@@ -96,7 +96,9 @@ class MyLogInterceptor implements Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n" Log.info(
"Network",
"${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n" "headers:\n${options.headers}\n"
"data:\n${options.data}"); "data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15); options.connectTimeout = const Duration(seconds: 15);
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
} }
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( httpClientAdapter = RHttpAdapter();
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
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 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 = {}; static final Map<String, bool> _requests = {};
@override @override
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
_requests[path] = true; _requests[path] = true;
options!.headers!.remove('prevent-parallel'); 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 { try {
return super.request<T>( return super.request<T>(
path, path,
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
} }
class RHttpAdapter implements HttpClientAdapter { 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() { static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) { if (!appdata.settings['enableDnsOverrides'] == true) {
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
return result; 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 @override
void close({bool force = false}) {} void close({bool force = false}) {}
@@ -256,10 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream, Stream<Uint8List>? requestStream,
Future<void>? cancelFuture, Future<void>? cancelFuture,
) async { ) 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( var res = await rhttp.Rhttp.request(
method: rhttp.HttpMethod(options.method), method: rhttp.HttpMethod(options.method),
url: options.uri.toString(), url: options.uri.toString(),
settings: settings, settings: await settings,
expectBody: rhttp.HttpExpectBody.stream, expectBody: rhttp.HttpExpectBody.stream,
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream), body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
headers: rhttp.HttpHeaders.rawMap( headers: rhttp.HttpHeaders.rawMap(
@@ -289,7 +240,7 @@ class RHttpAdapter implements HttpClientAdapter {
} }
static String _getStatusMessage(int statusCode) { static String _getStatusMessage(int statusCode) {
return switch(statusCode) { return switch (statusCode) {
200 => "OK", 200 => "OK",
201 => "Created", 201 => "Created",
202 => "Accepted", 202 => "Accepted",
@@ -299,9 +250,11 @@ class RHttpAdapter implements HttpClientAdapter {
302 => "Found", 302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.", 400 => "Invalid Status Code 400: The Request is invalid.",
401 => "Invalid Status Code 401: The Request is unauthorized.", 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.", 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", _ => "Invalid Status Code $statusCode",
}; };
} }

View File

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

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_qjs/flutter_qjs.dart';
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
import 'app_dio.dart'; import 'app_dio.dart';
class ImageDownloader { abstract class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail( static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey, String url, String? sourceKey,
[String? cid]) async* { [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( 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* { String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey); 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 { class ImageDownloadProgress {
final int currentBytes; 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,85 +322,127 @@ class _ComicSourceList extends StatefulWidget {
} }
class _ComicSourceListState extends State<_ComicSourceList> { class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json; List? json;
bool changed = false;
var controller = TextEditingController();
void load() async { void load() async {
var dio = AppDio(); if (json != null) {
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']); setState(() {
if (res.statusCode != 200) { json = null;
context.showMessage(message: "Network error".tl); });
return;
} }
var dio = AppDio();
try {
var res = await dio.get<String>(controller.text);
if (res.statusCode != 200) {
throw "error";
}
if (mounted) {
setState(() { setState(() {
json = jsonDecode(res.data!); json = jsonDecode(res.data!);
loading = false;
}); });
} }
}
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();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(
title: "Comic Source".tl, title: "Comic Source".tl,
tailing: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () async {
await showInputDialog(
context: context,
title: "Set comic source list url".tl,
initialValue: appdata.settings['comicSourceListUrl'],
onConfirm: (value) {
appdata.settings['comicSourceListUrl'] = value;
appdata.saveData();
setState(() {
loading = true;
json = null;
});
return null;
},
);
},
)
],
body: buildBody(), body: buildBody(),
); );
} }
Widget buildBody() { Widget buildBody() {
if (loading) {
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList(); var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder( return ListView.builder(
itemCount: json!.length + 1, itemCount: (json?.length ?? 1) + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 12), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), border: Border.all(
color: context.colorScheme.primaryContainer, color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
), ),
child: Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 8),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("Do not report any issues related to sources to App repo.".tl), ListTile(
Text("Click the setting icon to change the source list url.".tl), 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),
),
onChanged: (value) {
changed = true;
},
).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--; index--;
var key = json![index]["key"]; var key = json![index]["key"];
@@ -443,7 +485,6 @@ class _ComicSourceListState extends State<_ComicSourceList> {
}, },
); );
} }
}
} }
void _validatePages() { void _validatePages() {

View File

@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
} }
Future<List<FavoriteItem>> updateComicsInfo(String folder) async { Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder); var comics = LocalFavoritesManager().getFolderComics(folder);
Future<void> updateSingleComic(int index) async { Future<void> updateSingleComic(int index) async {
int retry = 3; 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/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart'; 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/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart'; part 'favorite_actions.dart';
part 'side_bar.dart'; part 'side_bar.dart';
part 'local_favorites_page.dart'; part 'local_favorites_page.dart';
part 'network_favorites_page.dart'; part 'network_favorites_page.dart';
part 'local_search_page.dart';
const _kLeftBarWidth = 256.0; const _kLeftBarWidth = 256.0;

View File

@@ -1,5 +1,11 @@
part of 'favorites_page.dart'; 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 { class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key}); const _LocalFavoritesPage({required this.folder, super.key});
@@ -31,25 +37,112 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex; int? lastSelectedIndex;
void updateComics() { bool get isAllFolder => widget.folder == _localAllFolderLabel;
if (keyword.isEmpty) {
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() { setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder); if (keyword.trim().isEmpty) {
}); searchResults = comics;
} else { } else {
setState(() { searchResults = [];
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword); for (var comic in comics) {
if (matchKeyword(keyword, comic)) {
searchResults.add(comic);
}
}
}
}); });
} }
void updateComics() {
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 {
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 @override
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder); if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
} else {
networkSource = null;
networkFolder = null;
}
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics); LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@@ -113,6 +206,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var title = favPage.folder ?? "Unselected".tl;
if (title == _localAllFolderLabel) {
title = "All".tl;
}
Widget body = SmoothCustomScrollView( Widget body = SmoothCustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
@@ -135,10 +233,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onTap: context.width < _kTwoPanelChangeWidth onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector ? favPage.showFolderSelector
: null, : null,
child: Text(favPage.folder ?? "Unselected".tl), child: Text(title),
), ),
actions: [ actions: [
if (networkSource != null) if (networkSource != null && !isAllFolder)
Tooltip( Tooltip(
message: "Sync".tl, message: "Sync".tl,
child: Flyout( child: Flyout(
@@ -191,11 +289,14 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
setState(() { setState(() {
keyword = "";
searchMode = true; searchMode = true;
updateSearchResult();
}); });
}, },
), ),
), ),
if (!isAllFolder)
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry( MenuEntry(
@@ -220,7 +321,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null; return null;
}, },
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.reorder, icon: Icons.reorder,
text: "Reorder".tl, text: "Reorder".tl,
@@ -241,7 +343,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
}, },
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.upload_file, icon: Icons.upload_file,
text: "Export".tl, text: "Export".tl,
@@ -253,7 +356,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
data: utf8.encode(json), data: utf8.encode(json),
filename: "${widget.folder}.json", filename: "${widget.folder}.json",
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.update, icon: Icons.update,
text: "Update Comics Info".tl, text: "Update Comics Info".tl,
@@ -265,7 +369,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
} }
}); });
}), },
),
MenuEntry( MenuEntry(
icon: Icons.delete_outline, icon: Icons.delete_outline,
text: "Delete Folder".tl, text: "Delete Folder".tl,
@@ -284,7 +389,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders(); favPage.folderList?.updateFolders();
}, },
); );
}), },
),
], ],
), ),
], ],
@@ -330,6 +436,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: Icons.flip, icon: Icons.flip,
text: "Invert Selection".tl, text: "Invert Selection".tl,
onClick: invertSelection), onClick: invertSelection),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.delete_outline, icon: Icons.delete_outline,
text: "Delete Comic".tl, text: "Delete Comic".tl,
@@ -379,10 +486,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
child: IconButton( child: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
setState(() {
setState(() { setState(() {
searchMode = false; searchMode = false;
keyword = ""; });
updateComics();
}); });
}, },
), ),
@@ -391,19 +498,30 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search".tl, hintText: "Search".tl,
border: InputBorder.none, border: UnderlineInputBorder(),
), ),
onChanged: (v) { onChanged: (v) {
keyword = v; keyword = v;
updateComics(); updateSearchResult();
}, },
).paddingBottom(8).paddingRight(8),
),
if (isLoading)
SliverToBoxAdapter(
child: SizedBox(
height: 200,
child: const Center(
child: CircularProgressIndicator(),
), ),
), ),
)
else
SliverGridComics( SliverGridComics(
comics: comics, comics: searchMode ? searchResults : comics,
selections: selectedComics, selections: selectedComics,
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
@@ -725,7 +843,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
final _key = GlobalKey(); final _key = GlobalKey();
var reorderWidgetKey = UniqueKey(); var reorderWidgetKey = UniqueKey();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
late var comics = LocalFavoritesManager().getAllComics(widget.name); late var comics = LocalFavoritesManager().getFolderComics(widget.name);
bool changed = false; bool changed = false;
static int _floatToInt8(double x) { 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,9 +86,34 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar padding: widget.withAppbar
? EdgeInsets.zero ? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top), : EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2, itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return buildLocalTitle();
}
index--;
if (index == 0) {
return buildLocalFolder(_localAllFolderLabel);
}
index--;
if (index < folders.length) {
return buildLocalFolder(folders[index]);
}
index -= folders.length;
if (index == 0) {
return buildNetworkTitle();
}
index--;
return buildNetworkFolder(networkFolders[index]);
},
),
)
],
),
);
}
Widget buildLocalTitle() {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
@@ -102,21 +127,13 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
const Spacer(), const Spacer(),
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry( MenuEntry(
icon: Icons.add, icon: Icons.add,
text: 'Create Folder'.tl, text: 'Create Folder'.tl,
onClick: () { onClick: () {
newFolder().then((value) { newFolder().then((value) {
setState(() { setState(() {
folders = folders = LocalFavoritesManager().folderNames;
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -127,8 +144,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () { onClick: () {
sortFolders().then((value) { sortFolders().then((value) {
setState(() { setState(() {
folders = folders = LocalFavoritesManager().folderNames;
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -139,12 +155,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
).paddingHorizontal(16), ).paddingHorizontal(16),
); );
} }
index--;
if (index < folders.length) { Widget buildNetworkTitle() {
return buildLocalFolder(folders[index]);
}
index -= folders.length;
if (index == 0) {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8), margin: const EdgeInsets.only(top: 8),
@@ -178,18 +190,18 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
).paddingHorizontal(16), ).paddingHorizontal(16),
); );
} }
index--;
return buildNetworkFolder(networkFolders[index]);
},
),
)
],
),
);
}
Widget buildLocalFolder(String name) { Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork; 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( return InkWell(
onTap: () { onTap: () {
if (isSelected) { if (isSelected) {
@@ -214,7 +226,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
), ),
), ),
padding: const EdgeInsets.only(left: 16), 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 { } else {
// prevent dirty data // 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(); comic.read();
} }
}, },
@@ -444,7 +445,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
var fileName = ""; var fileName = "";
// For each comic, export it to a file // For each comic, export it to a file
for (var comic in comics) { 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); await export(comic, fileName);
current++; current++;
if (comics.length > 1) { if (comics.length > 1) {

View File

@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false; bool _dragInProgress = false;
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) { if (_longPressInProgress) {
_longPressInProgress = false; _longPressInProgress = false;
return; return;
} }
final location = event.globalPosition; final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition; final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) { if (previousLocation != null) {
if ((location - previousLocation).distanceSquared < if ((location - previousLocation).distanceSquared <
@@ -287,6 +293,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
text: "Copy Image".tl, text: "Copy Image".tl,
onClick: () => copyImage(location), 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"); 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 { class _DragListener {

View File

@@ -21,6 +21,12 @@ class _ReaderImagesState extends State<_ReaderImages> {
super.initState(); super.initState();
} }
@override
void dispose() {
super.dispose();
ImageDownloader.cancelAllLoadingImages();
}
void load() async { void load() async {
if (inProgress) return; if (inProgress) return;
inProgress = true; inProgress = true;
@@ -104,15 +110,22 @@ class _GalleryModeState extends State<_GalleryMode>
implements _ImageViewController { implements _ImageViewController {
late PageController controller; late PageController controller;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"]; int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{}; var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader; 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>>{}; var imageStates = <State<ComicImage>>{};
@@ -125,24 +138,51 @@ class _GalleryModeState extends State<_GalleryMode>
reader = context.reader; reader = context.reader;
controller = PageController(initialPage: reader.page); controller = PageController(initialPage: reader.page);
reader._imageViewController = this; reader._imageViewController = this;
cached = List.filled(reader.maxPage + 2, false);
Future.microtask(() { Future.microtask(() {
context.readerScaffold.setFloatingButton(0); context.readerScaffold.setFloatingButton(0);
}); });
super.initState(); super.initState();
} }
void cache(int current) { /// Get the range of images for the given page. [page] is 1-based.
for (int i = current + 1; i <= current + preCacheCount; i++) { (int start, int end) getPageImagesRange(int page) {
if (i <= totalPages && !cached[i]) { if (reader.showSingleImageOnFirstPage) {
int startIndex = (i - 1) * reader.imagesPerPage; if (page == 1) {
int endIndex = return (0, 1);
math.min(startIndex + reader.imagesPerPage, reader.images!.length); } else {
for (int i = startIndex; i < endIndex; i++) { int startIndex = (page - 2) * reader.imagesPerPage + 1;
precacheImage( int endIndex = math.min(
_createImageProviderFromKey(reader.images![i], context), context); startIndex + reader.imagesPerPage, reader.images!.length);
return (startIndex, endIndex);
} }
cached[i] = true; } 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(), child: const SizedBox(),
); );
} else { } else {
int pageIndex = index - 1; var (startIndex, endIndex) = getPageImagesRange(index);
int startIndex = pageIndex * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
List<String> pageImages = List<String> pageImages =
reader.images!.sublist(startIndex, endIndex); reader.images!.sublist(startIndex, endIndex);
cached[index] = true;
cache(index); cache(index);
photoViewControllers[index] ??= PhotoViewController(); photoViewControllers[index] ??= PhotoViewController();
@@ -201,8 +237,11 @@ class _GalleryModeState extends State<_GalleryMode>
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
controller: photoViewControllers[index], controller: photoViewControllers[index],
imageProvider: imageProvider: _createImageProviderFromKey(
_createImageProviderFromKey(pageImages[0], context), pageImages[0],
context,
startIndex + 1,
),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) { errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry); return NetworkError(message: error.toString(), retry: retry);
@@ -211,10 +250,11 @@ class _GalleryModeState extends State<_GalleryMode>
} }
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2,
controller: photoViewControllers[index], controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0, minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.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); reader.setPage(i);
context.readerScaffold.update(); 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 axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical ? Axis.vertical
: Axis.horizontal; : Axis.horizontal;
@@ -267,7 +314,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage( child: ComicImage(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
image: _createImageProviderFromKey(images[0], context), image: _createImageProviderFromKey(
images[0],
context,
startIndex + 1,
),
fit: BoxFit.contain, fit: BoxFit.contain,
alignment: axis == Axis.vertical alignment: axis == Axis.vertical
? Alignment.bottomCenter ? Alignment.bottomCenter
@@ -280,7 +331,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage( child: ComicImage(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
image: _createImageProviderFromKey(images[1], context), image: _createImageProviderFromKey(
images[1],
context,
startIndex + 2,
),
fit: BoxFit.contain, fit: BoxFit.contain,
alignment: axis == Axis.vertical alignment: axis == Axis.vertical
? Alignment.topCenter ? Alignment.topCenter
@@ -292,8 +347,9 @@ class _GalleryModeState extends State<_GalleryMode>
]; ];
} else { } else {
imageWidgets = images.map((imageKey) { imageWidgets = images.map((imageKey) {
startIndex++;
ImageProvider imageProvider = ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context); _createImageProviderFromKey(imageKey, context, startIndex);
return Expanded( return Expanded(
child: ComicImage( child: ComicImage(
image: imageProvider, image: imageProvider,
@@ -402,34 +458,24 @@ class _GalleryModeState extends State<_GalleryMode>
keyRepeatTimer = null; keyRepeatTimer = null;
} }
if (forward == true) { if (forward == true) {
controller.nextPage( reader.toPage(reader.page+1);
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) { } else if (forward == false) {
controller.previousPage( reader.toPage(reader.page-1);
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} }
} }
if (event is KeyRepeatEvent && keyRepeatTimer == null) { if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic( keyRepeatTimer = Timer.periodic(
const Duration(milliseconds: 100), reader.enablePageAnimation
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50),
(timer) { (timer) {
if (!mounted) { if (!mounted) {
timer.cancel(); timer.cancel();
return; return;
} else if (forward == true) { } else if (forward == true) {
controller.nextPage( reader.toPage(reader.page+1);
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
} else if (forward == false) { } else if (forward == false) {
controller.previousPage( reader.toPage(reader.page-1);
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
} }
}, },
); );
@@ -447,6 +493,19 @@ class _GalleryModeState extends State<_GalleryMode>
@override @override
Future<Uint8List?> getImageByOffset(Offset offset) async { 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; String? imageKey;
if (reader.imagesPerPage == 1) { if (reader.imagesPerPage == 1) {
imageKey = reader.images![reader.page - 1]; imageKey = reader.images![reader.page - 1];
@@ -457,14 +516,7 @@ class _GalleryModeState extends State<_GalleryMode>
} }
} }
} }
if (imageKey == null) return null; return imageKey;
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();
}
} }
} }
@@ -599,7 +651,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
void cacheImages(int current) { void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) { for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) { if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context); _preDownloadImage(i, context);
cached[i] = true; cached[i] = true;
} }
} }
@@ -975,13 +1027,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
if (forward == true) { if (forward == true) {
scrollController.animateTo( scrollController.animateTo(
scrollController.offset + context.height, scrollController.offset + context.height * 0.25,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.ease, curve: Curves.ease,
); );
} else if (forward == false) { } else if (forward == false) {
scrollController.animateTo( scrollController.animateTo(
scrollController.offset - context.height, scrollController.offset - context.height * 0.25,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.ease, curve: Curves.ease,
); );
@@ -998,12 +1050,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override @override
Future<Uint8List?> getImageByOffset(Offset offset) async { Future<Uint8List?> getImageByOffset(Offset offset) async {
String? imageKey; var imageKey = getImageKeyByOffset(offset);
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
if (imageKey == null) return null; if (imageKey == null) return null;
if (imageKey.startsWith("file://")) { if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
@@ -1013,10 +1060,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
.readAsBytes(); .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( ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) { String imageKey,
BuildContext context,
int page,
) {
var reader = context.reader; var reader = context.reader;
return ReaderImageProvider( return ReaderImageProvider(
imageKey, imageKey,
@@ -1030,16 +1091,39 @@ ImageProvider _createImageProviderFromKey(
ImageProvider _createImageProvider(int page, BuildContext context) { ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader; var reader = context.reader;
var imageKey = reader.images![page - 1]; 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) { void _precacheImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
precacheImage( precacheImage(
_createImageProvider(page, context), _createImageProvider(page, context),
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 { class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({ const _SwipeChangeChapterProgress({
this.controller, 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/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/clipboard_image.dart'; import 'package:venera/utils/clipboard_image.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
@@ -110,7 +111,16 @@ class _ReaderState extends State<Reader>
} }
@override @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; ComicType get type => widget.type;
@@ -124,7 +134,8 @@ class _ReaderState extends State<Reader>
late ReaderMode mode; late ReaderMode mode;
@override @override
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait; bool get isPortrait =>
MediaQuery.of(context).orientation == Orientation.portrait;
History? history; History? history;
@@ -216,10 +227,16 @@ class _ReaderState extends State<Reader>
focusNode: focusNode, focusNode: focusNode,
autofocus: true, autofocus: true,
onKeyEvent: onKeyEvent, onKeyEvent: onKeyEvent,
child: _ReaderScaffold( child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return _ReaderScaffold(
child: _ReaderGestureDetector( child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())), 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 /// The number of images displayed on one screen
int get imagesPerPage { int get imagesPerPage {
if (mode.isContinuous) return 1; if (mode.isContinuous) return 1;
@@ -603,4 +623,6 @@ abstract interface class _ImageViewController {
bool handleOnTap(Offset location); bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset); 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 { try {
if (context.reader.images![0].contains('file://')) { if (context.reader.images![0].contains('file://')) {
showToast( showToast(
@@ -222,7 +222,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
String title = context.reader.history!.title; String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle; String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length; 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 sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1]; String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags; List<String> tags = context.reader.widget.tags;
@@ -378,11 +380,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
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,
), ),
if (App.isWindows) ),
if (App.isDesktop)
Tooltip( Tooltip(
message: "${"Full Screen".tl}(F12)", message: "${"Full Screen".tl}(F12)",
child: IconButton( 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 { void saveCurrentImage() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
@@ -667,7 +584,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
void share() async { void share() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
@@ -750,9 +667,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? Icons.arrow_forward_ios ? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined, : Icons.arrow_back_ios_outlined,
size: 24, size: 24,
color: Theme.of(context) color: Theme.of(context).colorScheme.onPrimaryContainer,
.colorScheme
.onPrimaryContainer,
), ),
), ),
), ),
@@ -761,6 +676,74 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
return const SizedBox(); 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 { 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, min: 1,
max: 20, max: 20,
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval"); widget.onChanged?.call("autoPageTurningInterval");
}, },
).toSliver(), ).toSliver(),
@@ -80,6 +81,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1, min: 1,
max: 5, max: 5,
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape"); 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( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom', 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/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/network/app_dio.dart'; import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'dart:io' as io; import 'dart:io' as io;
@@ -308,7 +308,7 @@ class DesktopWebview {
useWindowPositionAndSize: true, useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview", userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview", title: "webview",
proxy: AppDio.proxy, proxy: await getProxy(),
)); ));
_webview!.addOnWebMessageReceivedCallback(onMessage); _webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) { _webview!.setOnNavigation((s) {

View File

@@ -112,7 +112,7 @@ abstract class CBZ {
var ext = e.path.split('.').last; var ext = e.path.split('.').last;
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
}); });
if(files.isEmpty) { if (files.isEmpty) {
cache.deleteSync(recursive: true); cache.deleteSync(recursive: true);
throw Exception('No images found in the archive'); throw Exception('No images found in the archive');
} }
@@ -141,8 +141,7 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
); );
dest.createSync(); dest.createSync();
coverFile.copyMem( coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) { if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
var src = files[i]; var src = files[i];
@@ -233,17 +232,19 @@ abstract class CBZ {
} }
} }
var cover = comic.coverFile; var cover = comic.coverFile;
await cover await cover.copyMem(
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( final metaData = ComicMetaData(
jsonEncode(
ComicMetaData(
title: comic.title, title: comic.title,
author: comic.subtitle, author: comic.subtitle,
tags: comic.tags, tags: comic.tags,
chapters: chapters, chapters: chapters,
).toJson(), );
), await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode(metaData),
);
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
_buildComicInfoXml(metaData),
); );
var cbz = File(outFilePath); var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync(); if (cbz.existsSync()) cbz.deleteSync();
@@ -252,7 +253,54 @@ abstract class CBZ {
return 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 { static _compress(String src, String dst) async {
await ZipFile.compressFolderAsync(src, dst, 4); 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/data.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File; import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'io.dart'; import 'io.dart';
@@ -119,19 +118,11 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: RHttpAdapter( adapter: RHttpAdapter(),
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
); );
try { try {
@@ -192,19 +183,11 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: RHttpAdapter( adapter: RHttpAdapter(),
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
); );
try { try {

View File

@@ -108,3 +108,14 @@ abstract class MapOrNull{
return i == null ? null : Map<K, V>.from(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. /// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) { String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (fileName.endsWith('.')) { while (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1); fileName = fileName.substring(0, fileName.length - 1);
} }
var maxLength = 255; var length = maxLength ?? 255;
if (dir != null) { if (dir != null) {
if (!dir.endsWith('/') && !dir.endsWith('\\')) { if (!dir.endsWith('/') && !dir.endsWith('\\')) {
dir = "$dir/"; dir = "$dir/";
} }
maxLength -= dir.length; length -= dir.length;
} }
final invalidChars = RegExp(r'[<>:"/\\|?*]'); final invalidChars = RegExp(r'[<>:"/\\|?*]');
final sanitizedFileName = fileName.replaceAll(invalidChars, ' '); final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
@@ -148,11 +148,11 @@ String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (trimmedFileName.isEmpty) { if (trimmedFileName.isEmpty) {
throw Exception('Invalid File Name: Empty length.'); throw Exception('Invalid File Name: Empty length.');
} }
if (maxLength <= 0) { if (length <= 0) {
throw Exception('Invalid File Name: Max length is less than 0.'); throw Exception('Invalid File Name: Max length is less than 0.');
} }
if (trimmedFileName.length > maxLength) { if (trimmedFileName.length > length) {
trimmedFileName = trimmedFileName.substring(0, maxLength); trimmedFileName = trimmedFileName.substring(0, length);
} }
return trimmedFileName; return trimmedFileName;
} }

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.12.0"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -182,10 +182,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.2"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -308,18 +308,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: flutter_inappwebview path: flutter_inappwebview
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "6.2.0-beta.3" version: "6.2.0-beta.3"
flutter_inappwebview_android: flutter_inappwebview_android:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_android path: flutter_inappwebview_android
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations: flutter_inappwebview_internal_annotations:
@@ -334,45 +334,45 @@ packages:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_ios path: flutter_inappwebview_ios
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_macos: flutter_inappwebview_macos:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_macos path: flutter_inappwebview_macos
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface: flutter_inappwebview_platform_interface:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_platform_interface path: flutter_inappwebview_platform_interface
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.4.0-beta.3" version: "1.4.0-beta.3"
flutter_inappwebview_web: flutter_inappwebview_web:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_web path: flutter_inappwebview_web
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_windows: flutter_inappwebview_windows:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_windows path: flutter_inappwebview_windows
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "0.7.0-beta.3" version: "0.7.0-beta.3"
flutter_lints: flutter_lints:
@@ -516,10 +516,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" version: "0.19.0"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -540,10 +540,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
@@ -1029,10 +1029,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "14.3.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1100,4 +1100,4 @@ packages:
version: "0.0.12" version: "0.0.12"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" 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." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.4.0+140 version: 1.4.2+142
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
flutter: 3.29.2 flutter: 3.29.3
dependencies: dependencies:
flutter: flutter:
@@ -46,9 +46,9 @@ dependencies:
ref: 7801fc582ecf5a7351632887891ecf309a7b2583 ref: 7801fc582ecf5a7351632887891ecf309a7b2583
flutter_inappwebview: flutter_inappwebview:
git: git:
url: https://github.com/pichillilorenzo/flutter_inappwebview url: https://github.com/venera-app/flutter_inappwebview
path: flutter_inappwebview path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676 ref: 3ef899b3db57c911b080979f1392253b835f98ab
app_links: ^6.4.0 app_links: ^6.4.0
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2 flutter_file_dialog: ^3.0.2

View File

@@ -10,11 +10,16 @@
#include <flutter/event_stream_handler_functions.h> #include <flutter/event_stream_handler_functions.h>
#include <flutter/standard_method_codec.h> #include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
#include <thread>
#define _CRT_SECURE_NO_WARNINGS #define _CRT_SECURE_NO_WARNINGS
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr; 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) char* wideCharToMultiByte(wchar_t* pWCStrKey)
{ {
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL); 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() {} 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() { bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) { if (!Win32Window::OnCreate()) {
return false; return false;
@@ -78,6 +99,13 @@ bool FlutterWindow::OnCreate() {
result->Success(flutter::EncodableValue("No Proxy")); result->Success(flutter::EncodableValue("No Proxy"));
delete(res); 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( flutter::EventChannel<> channel2(
@@ -163,6 +191,10 @@ void FlutterWindow::OnDestroy() {
} }
Win32Window::OnDestroy(); Win32Window::OnDestroy();
if (monitorThread != nullptr) {
mainThreadAlive = false;
monitorThread->join();
}
} }
void mouse_side_button_listener(unsigned int input) void mouse_side_button_listener(unsigned int input)