diff --git a/assets/translation.json b/assets/translation.json index 9749fc6..dc9b37d 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -387,7 +387,10 @@ "Screen center": "屏幕中心", "Suggestions": "建议", "Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题", - "Click the setting icon to change the source list url.": "点击设置图标更改源列表URL" + "Show single image on first page": "在首页显示单张图片", + "Click to select an image": "点击选择一张图片", + "Source URL": "源地址", + "The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件" }, "zh_TW": { "Home": "首頁", @@ -777,6 +780,9 @@ "Screen center": "螢幕中心", "Suggestions": "建議", "Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題", - "Click the setting icon to change the source list url.": "點擊設定圖示更改源列表URL" + "Show single image on first page": "在首頁顯示單張圖片", + "Click to select an image": "點擊選擇一張圖片", + "Source URL": "源地址", + "The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件" } } diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 384b0ce..67c6ad7 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -13,7 +13,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.4.0"; + final version = "1.4.1"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index ecb64cc..7b1a898 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -178,13 +178,13 @@ class Settings with ChangeNotifier { 'customImageProcessing': defaultCustomImageProcessing, 'sni': true, 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese - 'comicSourceListUrl': - "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json", + 'comicSourceListUrl': defaultComicSourceUrl, 'preloadImageCount': 4, 'followUpdatesFolder': null, 'initialPage': '0', 'comicListDisplayMode': 'paging', // paging, continuous 'showPageNumberInReader': true, + 'showSingleImageOnFirstPage': false, }; operator [](String key) { @@ -219,3 +219,5 @@ function processImage(image, cid, eid, page, sourceKey) { return futureImage; } '''; + +const defaultComicSourceUrl = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json"; diff --git a/lib/foundation/cache_manager.dart b/lib/foundation/cache_manager.dart index 4caeede..d25466d 100644 --- a/lib/foundation/cache_manager.dart +++ b/lib/foundation/cache_manager.dart @@ -21,7 +21,7 @@ class CacheManager { int _limitSize = 2 * 1024 * 1024 * 1024; - CacheManager._create(){ + CacheManager._create() { Directory(cachePath).createSync(recursive: true); _db = sqlite3.open('${App.dataPath}/cache.db'); _db.execute(''' @@ -33,100 +33,102 @@ class CacheManager { type TEXT ) '''); - compute((path) => Directory(path).size, cachePath) - .then((value) => _currentSize = value); + compute((path) => Directory(path).size, cachePath).then((value) { + _currentSize = value; + checkCache(); + }); } + /// Get the singleton instance of CacheManager. factory CacheManager() => instance ??= CacheManager._create(); /// set cache size limit in MB - void setLimitSize(int size){ + void setLimitSize(int size) { _limitSize = size * 1024 * 1024; } - void setType(String key, String? type){ - _db.execute(''' - UPDATE cache - SET type = ? - WHERE key = ? - ''', [type, key]); - } - - String? getType(String key){ - var res = _db.select(''' - SELECT type FROM cache - WHERE key = ? - ''', [key]); - if(res.isEmpty){ - return null; - } - return res.first[0]; - } - - Future writeCache(String key, List data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{ + /// Write cache to disk. + Future writeCache(String key, List data, + [int duration = 7 * 24 * 60 * 60 * 1000]) async { this.dir++; this.dir %= 100; var dir = this.dir; - var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString(); + var name = md5.convert(key.codeUnits).toString(); var file = File('$cachePath/$dir/$name'); - while(await file.exists()){ - name = md5.convert(Uint8List.fromList(name.codeUnits)).toString(); - file = File('$cachePath/$dir/$name'); - } await file.create(recursive: true); await file.writeAsBytes(data); var expires = DateTime.now().millisecondsSinceEpoch + duration; _db.execute(''' INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?) ''', [key, dir.toString(), name, expires]); - if(_currentSize != null) { + if (_currentSize != null) { _currentSize = _currentSize! + data.length; } checkCacheIfRequired(); } - Future openWrite(String key) async{ - this.dir++; - this.dir %= 100; - var dir = this.dir; - var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString(); - var file = File('$cachePath/$dir/$name'); - while(await file.exists()){ - name = md5.convert(Uint8List.fromList(name.codeUnits)).toString(); - file = File('$cachePath/$dir/$name'); - } - await file.create(recursive: true); - return CachingFile._(key, dir.toString(), name, file); - } - - Future findCache(String key) async{ + /// Find cache by key. + /// If cache is expired, it will be deleted and return null. + /// If cache is not found, it will return null. + /// If cache is found, it will return the file, and update the expires time. + Future findCache(String key) async { var res = _db.select(''' SELECT * FROM cache WHERE key = ? ''', [key]); - if(res.isEmpty){ + if (res.isEmpty) { return null; } var row = res.first; var dir = row[1] as String; var name = row[2] as String; + var expires = row[3] as int; var file = File('$cachePath/$dir/$name'); - if(await file.exists()){ + var now = DateTime.now().millisecondsSinceEpoch; + if (expires < now) { + // expired + _db.execute(''' + DELETE FROM cache + WHERE key = ? + ''', [key]); + if (await file.exists()) { + await file.delete(); + } + return null; + } + if (await file.exists()) { + // update time + var expires = now + 7 * 24 * 60 * 60 * 1000; + _db.execute(''' + UPDATE cache + SET expires = ? + WHERE key = ? + ''', [expires, key]); return file; + } else { + _db.execute(''' + DELETE FROM cache + WHERE key = ? + ''', [key]); } return null; } bool _isChecking = false; + /// Check cache size and delete expired cache. + /// Only check cache if current size is greater than limit size. void checkCacheIfRequired() { - if(_currentSize != null && _currentSize! > _limitSize){ + if (_currentSize != null && _currentSize! > _limitSize) { checkCache(); } } - Future checkCache() async{ - if(_isChecking){ + /// Check cache size and delete expired cache. + /// If current size is greater than limit size, + /// delete cache until current size is less than limit size. + Future checkCache() async { + if (_isChecking) { return; } _isChecking = true; @@ -134,11 +136,13 @@ class CacheManager { SELECT * FROM cache WHERE expires < ? ''', [DateTime.now().millisecondsSinceEpoch]); - for(var row in res){ + for (var row in res) { var dir = row[1] as String; var name = row[2] as String; var file = File('$cachePath/$dir/$name'); - if(await file.exists()){ + if (await file.exists()) { + var size = await file.length(); + _currentSize = _currentSize! - size; await file.delete(); } } @@ -147,26 +151,18 @@ class CacheManager { WHERE expires < ? ''', [DateTime.now().millisecondsSinceEpoch]); - int count = 0; - var res2 = _db.select(''' - SELECT COUNT(*) FROM cache - '''); - if(res2.isNotEmpty){ - count = res2.first[0] as int; - } - - while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){ + while (_currentSize != null && _currentSize! > _limitSize) { var res = _db.select(''' SELECT * FROM cache ORDER BY expires ASC limit 10 '''); - for(var row in res){ + for (var row in res) { var key = row[0] as String; var dir = row[1] as String; var name = row[2] as String; var file = File('$cachePath/$dir/$name'); - if(await file.exists()){ + if (await file.exists()) { var size = await file.length(); await file.delete(); _db.execute(''' @@ -174,7 +170,7 @@ class CacheManager { WHERE key = ? ''', [key]); _currentSize = _currentSize! - size; - if(_currentSize! <= _limitSize){ + if (_currentSize! <= _limitSize) { break; } } else { @@ -183,18 +179,18 @@ class CacheManager { WHERE key = ? ''', [key]); } - count--; } } _isChecking = false; } - Future delete(String key) async{ + /// Delete cache by key. + Future delete(String key) async { var res = _db.select(''' SELECT * FROM cache WHERE key = ? ''', [key]); - if(res.isEmpty){ + if (res.isEmpty) { return; } var row = res.first; @@ -202,7 +198,7 @@ class CacheManager { var name = row[2] as String; var file = File('$cachePath/$dir/$name'); var fileSize = 0; - if(await file.exists()){ + if (await file.exists()) { fileSize = await file.length(); await file.delete(); } @@ -210,11 +206,12 @@ class CacheManager { DELETE FROM cache WHERE key = ? ''', [key]); - if(_currentSize != null) { + if (_currentSize != null) { _currentSize = _currentSize! - fileSize; } } + /// Delete all cache. Future clear() async { await Directory(cachePath).delete(recursive: true); Directory(cachePath).createSync(recursive: true); @@ -223,75 +220,4 @@ class CacheManager { '''); _currentSize = 0; } - - Future 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 _buffer = []; - - Future writeBytes(List data) async{ - _buffer.addAll(data); - if(_buffer.length > 1024 * 1024){ - await file.writeAsBytes(_buffer, mode: FileMode.append); - _buffer.clear(); - } - } - - Future 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 cancel() async{ - await file.deleteIgnoreError(); - } - - void reset() { - _buffer.clear(); - if(file.existsSync()) { - file.deleteSync(); - } - } -} \ No newline at end of file diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 0b9f353..ad2baf6 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -234,7 +234,7 @@ class LocalFavoritesManager with ChangeNotifier { alter table "$folder" add column translated_tags TEXT; """); - var comics = getAllComics(folder); + var comics = getFolderComics(folder); for (var comic in comics) { var translatedTags = _translateTags(comic.tags); _db.execute(""" @@ -349,7 +349,7 @@ class LocalFavoritesManager with ChangeNotifier { """).firstOrNull?["min_value"] ?? 0; } - List getAllComics(String folder) { + List getFolderComics(String folder) { var rows = _db.select(""" select * from "$folder" ORDER BY display_order; @@ -357,6 +357,17 @@ class LocalFavoritesManager with ChangeNotifier { return rows.map((element) => FavoriteItem.fromRow(element)).toList(); } + List getAllComics() { + var res = {}; + for (final folder in folderNames) { + var comics = _db.select(""" + select * from "$folder"; + """); + res.addAll(comics.map((element) => FavoriteItem.fromRow(element))); + } + return res.toList(); + } + void addTagTo(String folder, String id, String tag) { _db.execute(""" update "$folder" @@ -736,10 +747,10 @@ class LocalFavoritesManager with ChangeNotifier { return comics; } - List search(String keyword) { + List search(String keyword) { var keywordList = keyword.split(" "); keyword = keywordList.first; - var comics = []; + var comics = {}; for (var table in folderNames) { keyword = "%$keyword%"; var res = _db.select(""" @@ -747,15 +758,18 @@ class LocalFavoritesManager with ChangeNotifier { WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?; """, [keyword, keyword, keyword, keyword]); for (var comic in res) { - comics.add( - FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table)); + comics.add(FavoriteItem.fromRow(comic)); } if (comics.length > 200) { break; } } - bool test(FavoriteItemWithFolderInfo comic, String keyword) { + bool test(FavoriteItem comic, String keyword) { + keyword = keyword.trim(); + if (keyword.isEmpty) { + return true; + } if (comic.name.contains(keyword)) { return true; } else if (comic.author.contains(keyword)) { @@ -766,12 +780,14 @@ class LocalFavoritesManager with ChangeNotifier { return false; } - for (var i = 1; i < keywordList.length; i++) { - comics = - comics.where((element) => test(element, keywordList[i])).toList(); - } - - return comics; + return comics.where((element) { + for (var i = 1; i < keywordList.length; i++) { + if (!test(element, keywordList[i])) { + return false; + } + } + return true; + }).toList(); } void editTags(String id, String folder, List tags) { diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 791215b..40fde70 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -25,6 +25,7 @@ import 'package:venera/components/js_ui.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; +import 'package:venera/network/proxy.dart'; import 'package:venera/utils/init.dart'; import 'comic_source/comic_source.dart'; @@ -194,7 +195,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { responseType: ResponseType.plain, validateStatus: (status) => true, )); - var proxy = await AppDio.getProxy(); + var proxy = await getProxy(); dio.httpClientAdapter = IOHttpClientAdapter( createHttpClient: () { return HttpClient() diff --git a/lib/init.dart b/lib/init.dart index 2841a03..88549d9 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_saf/flutter_saf.dart'; import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/app.dart'; @@ -51,6 +54,14 @@ Future init() async { FlutterError.onError = (details) { Log.error("Unhandled Exception", "${details.exception}\n${details.stack}"); }; + if (App.isWindows) { + // Report to the monitor thread that the app is running + // https://github.com/venera-app/venera/issues/343 + Timer.periodic(const Duration(seconds: 1), (_) { + const methodChannel = MethodChannel('venera/method_channel'); + methodChannel.invokeMethod("heartBeat"); + }); + } } void _checkOldConfigs() { diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 4dc4393..f39b276 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/network/cache.dart'; -import 'package:venera/utils/ext.dart'; +import 'package:venera/network/proxy.dart'; import '../foundation/app.dart'; import 'cloudflare.dart'; @@ -96,9 +96,11 @@ class MyLogInterceptor implements Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - Log.info("Network", "${options.method} ${options.uri}\n" - "headers:\n${options.headers}\n" - "data:\n${options.data}"); + Log.info( + "Network", + "${options.method} ${options.uri}\n" + "headers:\n${options.headers}\n" + "data:\n${options.data}"); options.connectTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15); @@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor { } class AppDio with DioMixin { - String? _proxy = proxy; - AppDio([BaseOptions? options]) { this.options = options ?? BaseOptions(); - httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( - proxySettings: proxy == null - ? const rhttp.ProxySettings.noProxy() - : rhttp.ProxySettings.proxy(proxy!), - )); + httpClientAdapter = RHttpAdapter(); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(NetworkCacheManager()); interceptors.add(CloudflareInterceptor()); interceptors.add(MyLogInterceptor()); } - static String? proxy; - - static Future 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 _requests = {}; @override @@ -184,16 +137,6 @@ class AppDio with DioMixin { _requests[path] = true; options!.headers!.remove('prevent-parallel'); } - proxy = await getProxy(); - if (_proxy != proxy) { - Log.info("Network", "Proxy changed to $proxy"); - _proxy = proxy; - httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( - proxySettings: proxy == null - ? const rhttp.ProxySettings.noProxy() - : rhttp.ProxySettings.proxy(proxy!), - )); - } try { return super.request( path, @@ -213,7 +156,26 @@ class AppDio with DioMixin { } class RHttpAdapter implements HttpClientAdapter { - rhttp.ClientSettings settings; + Future 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> _getOverrides() { if (!appdata.settings['enableDnsOverrides'] == true) { @@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter { return result; } - RHttpAdapter([this.settings = const rhttp.ClientSettings()]) { - settings = settings.copyWith( - redirectSettings: const rhttp.RedirectSettings.limited(5), - timeoutSettings: const rhttp.TimeoutSettings( - connectTimeout: Duration(seconds: 15), - keepAliveTimeout: Duration(seconds: 60), - keepAlivePing: Duration(seconds: 30), - ), - throwOnStatusCode: false, - dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()), - tlsSettings: rhttp.TlsSettings( - sni: appdata.settings['sni'] != false, - ), - ); - } - @override void close({bool force = false}) {} @@ -256,10 +202,15 @@ class RHttpAdapter implements HttpClientAdapter { Stream? requestStream, Future? cancelFuture, ) async { + if (options.headers['User-Agent'] == null && + options.headers['user-agent'] == null) { + options.headers['User-Agent'] = "venera/v${App.version}"; + } + var res = await rhttp.Rhttp.request( method: rhttp.HttpMethod(options.method), url: options.uri.toString(), - settings: settings, + settings: await settings, expectBody: rhttp.HttpExpectBody.stream, body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream), headers: rhttp.HttpHeaders.rawMap( @@ -289,7 +240,7 @@ class RHttpAdapter implements HttpClientAdapter { } static String _getStatusMessage(int statusCode) { - return switch(statusCode) { + return switch (statusCode) { 200 => "OK", 201 => "Created", 202 => "Accepted", @@ -299,9 +250,11 @@ class RHttpAdapter implements HttpClientAdapter { 302 => "Found", 400 => "Invalid Status Code 400: The Request is invalid.", 401 => "Invalid Status Code 401: The Request is unauthorized.", - 403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.", + 403 => + "Invalid Status Code 403: No permission to access the resource. Check your account or network.", 404 => "Invalid Status Code 404: Not found.", - 429 => "Invalid Status Code 429: Too many requests. Please try again later.", + 429 => + "Invalid Status Code 429: Too many requests. Please try again later.", _ => "Invalid Status Code $statusCode", }; } diff --git a/lib/network/file_downloader.dart b/lib/network/file_downloader.dart index 3d91643..77bedbc 100644 --- a/lib/network/file_downloader.dart +++ b/lib/network/file_downloader.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:dio/io.dart'; import 'package:venera/network/app_dio.dart'; +import 'package:venera/network/proxy.dart'; import 'package:venera/utils/ext.dart'; class FileDownloader { @@ -105,7 +106,7 @@ class FileDownloader { void _download(StreamController resultStream) async { try { - var proxy = await AppDio.getProxy(); + var proxy = await getProxy(); _dio.httpClientAdapter = IOHttpClientAdapter( createHttpClient: () { return HttpClient() diff --git a/lib/network/images.dart b/lib/network/images.dart index f16e0ea..449542d 100644 --- a/lib/network/images.dart +++ b/lib/network/images.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_qjs/flutter_qjs.dart'; @@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart'; import 'app_dio.dart'; -class ImageDownloader { +abstract class ImageDownloader { static Stream loadThumbnail( String url, String? sourceKey, [String? cid]) async* { @@ -82,7 +83,35 @@ class ImageDownloader { ); } + static final _loadingImages = >{}; + + /// 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 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( + _loadComicImage(imageKey, sourceKey, cid, eid), + (wrapper) { + _loadingImages.remove(cacheKey); + }, + ); + _loadingImages[cacheKey] = stream; + return stream.stream; + } + + static Stream _loadComicImage( String imageKey, String? sourceKey, String cid, String eid) async* { final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cache = await CacheManager().findCache(cacheKey); @@ -189,6 +218,63 @@ class ImageDownloader { } } +/// A wrapper class for a stream that +/// allows multiple listeners to listen to the same stream. +class _StreamWrapper { + final Stream _stream; + + final List controllers = []; + + final void Function(_StreamWrapper 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 get stream { + if (isClosed) { + throw Exception('Stream is closed'); + } + var controller = StreamController(); + controllers.add(controller); + controller.onCancel = () { + controllers.remove(controller); + }; + return controller.stream; + } + + void cancel() { + for (var controller in controllers) { + controller.close(); + } + controllers.clear(); + isClosed = true; + } +} + class ImageDownloadProgress { final int currentBytes; diff --git a/lib/network/proxy.dart b/lib/network/proxy.dart new file mode 100644 index 0000000..400e6a1 --- /dev/null +++ b/lib/network/proxy.dart @@ -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 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 _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; +} diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 49dd89f..81b9622 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -322,127 +322,168 @@ class _ComicSourceList extends StatefulWidget { } class _ComicSourceListState extends State<_ComicSourceList> { - bool loading = true; List? json; + bool changed = false; + var controller = TextEditingController(); void load() async { - var dio = AppDio(); - var res = await dio.get(appdata.settings['comicSourceListUrl']); - if (res.statusCode != 200) { - context.showMessage(message: "Network error".tl); - return; + if (json != null) { + setState(() { + json = null; + }); + } + var dio = AppDio(); + try { + var res = await dio.get(controller.text); + if (res.statusCode != 200) { + throw "error"; + } + if (mounted) { + setState(() { + json = jsonDecode(res.data!); + }); + } + } + catch(e) { + context.showMessage(message: "Network error".tl); + if (mounted) { + setState(() { + json = []; + }); + } + } + } + + @override + void initState() { + super.initState(); + controller.text = appdata.settings['comicSourceListUrl']; + load(); + } + + @override + void dispose() { + super.dispose(); + if (changed) { + appdata.settings['comicSourceListUrl'] = controller.text; + appdata.saveData(); } - setState(() { - json = jsonDecode(res.data!); - loading = false; - }); } @override Widget build(BuildContext context) { return PopUpWidgetScaffold( title: "Comic Source".tl, - tailing: [ - IconButton( - icon: Icon(Icons.settings), - onPressed: () async { - await showInputDialog( - context: context, - title: "Set comic source list url".tl, - initialValue: appdata.settings['comicSourceListUrl'], - onConfirm: (value) { - appdata.settings['comicSourceListUrl'] = value; - appdata.saveData(); - setState(() { - loading = true; - json = null; - }); - return null; - }, - ); - }, - ) - ], body: buildBody(), ); } Widget buildBody() { - if (loading) { - load(); - return const Center(child: CircularProgressIndicator()); - } else { - var currentKey = ComicSource.all().map((e) => e.key).toList(); - return ListView.builder( - itemCount: json!.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 12), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: context.colorScheme.primaryContainer, + var currentKey = ComicSource.all().map((e) => e.key).toList(); + + return ListView.builder( + itemCount: (json?.length ?? 1) + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, ), - child: Row( - children: [ - const Icon(Icons.info_outline), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Do not report any issues related to sources to App repo.".tl), - Text("Click the setting icon to change the source list url.".tl), - ], - ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: Icon(Icons.source_outlined), + title: Text("Source URL".tl), + ), + TextField( + controller: controller, + decoration: InputDecoration( + hintText: "URL", + border: const UnderlineInputBorder(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), ), - ], - ), - ); - } - index--; - - var key = json![index]["key"]; - var action = currentKey.contains(key) - ? const Icon(Icons.check, size: 20).paddingRight(8) - : Button.filled( - child: Text("Add".tl), - onPressed: () async { - var fileName = json![index]["fileName"]; - var url = json![index]["url"]; - if (url == null || !(url.toString()).isURL) { - var listUrl = - appdata.settings['comicSourceListUrl'] as String; - if (listUrl - .replaceFirst("https://", "") - .replaceFirst("http://", "") - .contains("/")) { - url = - listUrl.substring(0, listUrl.lastIndexOf("/") + 1) + - fileName; - } else { - url = '$listUrl/$fileName'; - } - } - await widget.onAdd(url); - setState(() {}); + onChanged: (value) { + changed = true; }, - ).fixHeight(32); - - var description = json![index]["version"]; - if (json![index]["description"] != null) { - description = "$description\n${json![index]["description"]}"; - } - - return ListTile( - title: Text(json![index]["name"]), - subtitle: Text(description), - trailing: action, + ).paddingHorizontal(16).paddingBottom(8), + Text("The URL should point to a 'index.json' file".tl).paddingLeft(16), + Text("Do not report any issues related to sources to App repo.".tl).paddingLeft(16), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + controller.text = defaultComicSourceUrl; + changed = true; + }, + child: Text("Reset".tl), + ), + FilledButton.tonal( + onPressed: load, + child: Text("Refresh".tl), + ), + const SizedBox(width: 16), + ], + ), + const SizedBox(height: 16), + ], + ), ); - }, - ); - } + } + + if (index == 1 && json == null) { + return Center(child: CircularProgressIndicator()); + } + + index--; + + var key = json![index]["key"]; + var action = currentKey.contains(key) + ? const Icon(Icons.check, size: 20).paddingRight(8) + : Button.filled( + child: Text("Add".tl), + onPressed: () async { + var fileName = json![index]["fileName"]; + var url = json![index]["url"]; + if (url == null || !(url.toString()).isURL) { + var listUrl = + appdata.settings['comicSourceListUrl'] as String; + if (listUrl + .replaceFirst("https://", "") + .replaceFirst("http://", "") + .contains("/")) { + url = + listUrl.substring(0, listUrl.lastIndexOf("/") + 1) + + fileName; + } else { + url = '$listUrl/$fileName'; + } + } + await widget.onAdd(url); + setState(() {}); + }, + ).fixHeight(32); + + var description = json![index]["version"]; + if (json![index]["description"] != null) { + description = "$description\n${json![index]["description"]}"; + } + + return ListTile( + title: Text(json![index]["name"]), + subtitle: Text(description), + trailing: action, + ); + }, + ); } } diff --git a/lib/pages/favorites/favorite_actions.dart b/lib/pages/favorites/favorite_actions.dart index 4d466ea..621f474 100644 --- a/lib/pages/favorites/favorite_actions.dart +++ b/lib/pages/favorites/favorite_actions.dart @@ -133,7 +133,7 @@ void addFavorite(List comics) { } Future> updateComicsInfo(String folder) async { - var comics = LocalFavoritesManager().getAllComics(folder); + var comics = LocalFavoritesManager().getFolderComics(folder); Future updateSingleComic(int index) async { int retry = 3; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 015c4e0..aa14a64 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -25,7 +25,6 @@ part 'favorite_actions.dart'; part 'side_bar.dart'; part 'local_favorites_page.dart'; part 'network_favorites_page.dart'; -part 'local_search_page.dart'; const _kLeftBarWidth = 256.0; diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index eea258a..a807b08 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -1,5 +1,7 @@ part of 'favorites_page.dart'; +const _localAllFolderLabel = '^_^[%local_all%]^_^'; + class _LocalFavoritesPage extends StatefulWidget { const _LocalFavoritesPage({required this.folder, super.key}); @@ -31,14 +33,25 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { int? lastSelectedIndex; + bool get isAllFolder => widget.folder == _localAllFolderLabel; + void updateComics() { if (keyword.isEmpty) { setState(() { - comics = LocalFavoritesManager().getAllComics(widget.folder); + if (isAllFolder) { + comics = LocalFavoritesManager().getAllComics(); + } else { + comics = LocalFavoritesManager().getFolderComics(widget.folder); + } }); } else { setState(() { - comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword); + if (isAllFolder) { + comics = LocalFavoritesManager().search(keyword); + } else { + comics = + LocalFavoritesManager().searchInFolder(widget.folder, keyword); + } }); } } @@ -46,10 +59,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { @override void initState() { favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; - comics = LocalFavoritesManager().getAllComics(widget.folder); - var (a, b) = LocalFavoritesManager().findLinked(widget.folder); - networkSource = a; - networkFolder = b; + if (!isAllFolder) { + comics = LocalFavoritesManager().getFolderComics(widget.folder); + var (a, b) = LocalFavoritesManager().findLinked(widget.folder); + networkSource = a; + networkFolder = b; + } else { + comics = LocalFavoritesManager().getAllComics(); + networkSource = null; + networkFolder = null; + } LocalFavoritesManager().addListener(updateComics); super.initState(); } @@ -113,6 +132,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { @override Widget build(BuildContext context) { + var title = favPage.folder ?? "Unselected".tl; + if (title == _localAllFolderLabel) { + title = "All".tl; + } + Widget body = SmoothCustomScrollView( controller: scrollController, slivers: [ @@ -135,10 +159,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { onTap: context.width < _kTwoPanelChangeWidth ? favPage.showFolderSelector : null, - child: Text(favPage.folder ?? "Unselected".tl), + child: Text(title), ), actions: [ - if (networkSource != null) + if (networkSource != null && !isAllFolder) Tooltip( message: "Sync".tl, child: Flyout( @@ -196,9 +220,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { }, ), ), - MenuButton( - entries: [ - MenuEntry( + if (!isAllFolder) + MenuButton( + entries: [ + MenuEntry( icon: Icons.edit_outlined, text: "Rename".tl, onClick: () { @@ -220,8 +245,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { return null; }, ); - }), - MenuEntry( + }, + ), + MenuEntry( icon: Icons.reorder, text: "Reorder".tl, onClick: () { @@ -241,8 +267,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { } }, ); - }), - MenuEntry( + }, + ), + MenuEntry( icon: Icons.upload_file, text: "Export".tl, onClick: () { @@ -253,8 +280,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { data: utf8.encode(json), filename: "${widget.folder}.json", ); - }), - MenuEntry( + }, + ), + MenuEntry( icon: Icons.update, text: "Update Comics Info".tl, onClick: () { @@ -265,8 +293,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { }); } }); - }), - MenuEntry( + }, + ), + MenuEntry( icon: Icons.delete_outline, text: "Delete Folder".tl, color: context.colorScheme.error, @@ -284,9 +313,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { favPage.folderList?.updateFolders(); }, ); - }), - ], - ), + }, + ), + ], + ), ], ) else if (multiSelectMode) @@ -330,22 +360,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { icon: Icons.flip, text: "Invert Selection".tl, onClick: invertSelection), - MenuEntry( - icon: Icons.delete_outline, - text: "Delete Comic".tl, - color: context.colorScheme.error, - onClick: () { - showConfirmDialog( - context: context, - title: "Delete".tl, - content: "Delete @c comics?" - .tlParams({"c": selectedComics.length}), - btnColor: context.colorScheme.error, - onConfirm: () { - _deleteComicWithId(); - }, - ); - }), + if (!isAllFolder) + MenuEntry( + icon: Icons.delete_outline, + text: "Delete Comic".tl, + color: context.colorScheme.error, + onClick: () { + showConfirmDialog( + context: context, + title: "Delete".tl, + content: "Delete @c comics?" + .tlParams({"c": selectedComics.length}), + btnColor: context.colorScheme.error, + onConfirm: () { + _deleteComicWithId(); + }, + ); + }), MenuEntry( icon: Icons.download, text: "Download".tl, @@ -404,17 +435,18 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { selections: selectedComics, menuBuilder: (c) { return [ - MenuEntry( - icon: Icons.delete, - text: "Delete".tl, - onClick: () { - LocalFavoritesManager().deleteComicWithId( - widget.folder, - c.id, - (c as FavoriteItem).type, - ); - }, - ), + if (!isAllFolder) + MenuEntry( + icon: Icons.delete, + text: "Delete".tl, + onClick: () { + LocalFavoritesManager().deleteComicWithId( + widget.folder, + c.id, + (c as FavoriteItem).type, + ); + }, + ), MenuEntry( icon: Icons.check, text: "Select".tl, @@ -725,7 +757,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> { final _key = GlobalKey(); var reorderWidgetKey = UniqueKey(); final _scrollController = ScrollController(); - late var comics = LocalFavoritesManager().getAllComics(widget.name); + late var comics = LocalFavoritesManager().getFolderComics(widget.name); bool changed = false; static int _floatToInt8(double x) { diff --git a/lib/pages/favorites/local_search_page.dart b/lib/pages/favorites/local_search_page.dart deleted file mode 100644 index f798bc5..0000000 --- a/lib/pages/favorites/local_search_page.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of 'favorites_page.dart'; - -class LocalSearchPage extends StatefulWidget { - const LocalSearchPage({super.key}); - - @override - State createState() => _LocalSearchPageState(); -} - -class _LocalSearchPageState extends State { - String keyword = ''; - - var comics = []; - - 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; - }, - ), - ]), - ); - } -} diff --git a/lib/pages/favorites/side_bar.dart b/lib/pages/favorites/side_bar.dart index f2535bb..71f2c9b 100644 --- a/lib/pages/favorites/side_bar.dart +++ b/lib/pages/favorites/side_bar.dart @@ -102,13 +102,6 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { const Spacer(), MenuButton( entries: [ - MenuEntry( - icon: Icons.search, - text: 'Search'.tl, - onClick: () { - context.to(() => const LocalSearchPage()); - }, - ), MenuEntry( icon: Icons.add, text: 'Create Folder'.tl, @@ -140,6 +133,10 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { ); } index--; + if (index == 0) { + return buildLocalFolder(_localAllFolderLabel); + } + index--; if (index < folders.length) { return buildLocalFolder(folders[index]); } @@ -214,7 +211,9 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { ), ), padding: const EdgeInsets.only(left: 16), - child: Text(name), + child: Text(name == _localAllFolderLabel + ? "All".tl + : getFavoriteDataOrNull(name)?.title ?? name), ), ); } diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index e30c64a..22b4dd9 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -306,7 +306,8 @@ class _LocalComicsPageState extends State { }); } else { // prevent dirty data - var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!; + var comic = + LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!; comic.read(); } }, @@ -444,7 +445,10 @@ class _LocalComicsPageState extends State { var fileName = ""; // For each comic, export it to a file for (var comic in comics) { - fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext); + fileName = FilePath.join( + cacheDir, + sanitizeFileName(comic.title, maxLength: 100) + ext, + ); await export(comic, fileName); current++; if (comics.length > 1) { diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart index 04ef114..751af23 100644 --- a/lib/pages/reader/gesture.dart +++ b/lib/pages/reader/gesture.dart @@ -287,6 +287,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet text: "Copy Image".tl, onClick: () => copyImage(location), ), + if (!reader.isLoading) + MenuEntry( + icon: Icons.download_outlined, + text: "Save Image".tl, + onClick: () => saveImage(location), + ), ], ); } @@ -319,6 +325,17 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet context.showMessage(message: "No Image"); } } + + void saveImage(Offset location) async { + var controller = reader._imageViewController; + var image = await controller!.getImageByOffset(location); + if (image != null) { + var filetype = detectFileType(image); + saveFile(filename: "image${filetype.ext}", data: image); + } else { + context.showMessage(message: "No Image"); + } + } } class _DragListener { diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index a33aeac..752606a 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -21,6 +21,12 @@ class _ReaderImagesState extends State<_ReaderImages> { super.initState(); } + @override + void dispose() { + super.dispose(); + ImageDownloader.cancelAllLoadingImages(); + } + void load() async { if (inProgress) return; inProgress = true; @@ -104,15 +110,22 @@ class _GalleryModeState extends State<_GalleryMode> implements _ImageViewController { late PageController controller; - late List cached; - int get preCacheCount => appdata.settings["preloadImageCount"]; var photoViewControllers = {}; 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 (!showSingleImageOnFirstPage) { + return (reader.images!.length / reader.imagesPerPage).ceil(); + } else { + return 1 + + ((reader.images!.length - 1) / reader.imagesPerPage).ceil(); + } + } var imageStates = >{}; @@ -125,24 +138,53 @@ class _GalleryModeState extends State<_GalleryMode> reader = context.reader; controller = PageController(initialPage: reader.page); reader._imageViewController = this; - cached = List.filled(reader.maxPage + 2, false); Future.microtask(() { context.readerScaffold.setFloatingButton(0); }); super.initState(); } - void cache(int current) { - for (int i = current + 1; i <= current + preCacheCount; i++) { - if (i <= totalPages && !cached[i]) { - int startIndex = (i - 1) * reader.imagesPerPage; - int endIndex = - math.min(startIndex + reader.imagesPerPage, reader.images!.length); - for (int i = startIndex; i < endIndex; i++) { - precacheImage( - _createImageProviderFromKey(reader.images![i], context), context); - } - cached[i] = true; + bool get showSingleImageOnFirstPage => appdata.settings["showSingleImageOnFirstPage"]; + + /// Get the range of images for the given page. [page] is 1-based. + (int start, int end) getPageImagesRange(int page) { + if (showSingleImageOnFirstPage) { + if (page == 1) { + return (0, 1); + } else { + int startIndex = (page - 2) * reader.imagesPerPage + 1; + int endIndex = math.min( + startIndex + reader.imagesPerPage, reader.images!.length); + return (startIndex, endIndex); + } + } else { + int startIndex = (page - 1) * reader.imagesPerPage; + int endIndex = math.min( + startIndex + reader.imagesPerPage, reader.images!.length); + return (startIndex, endIndex); + } + } + + /// [cache] is used to cache the images. + /// The count of images to cache is determined by the [preCacheCount] setting. + /// For previous page and next page, it will do a memory cache. + /// For current page, it will do nothing because it is already on the screen. + /// For other pages, it will do a pre-download cache. + void cache(int startPage) { + for (int i = startPage - 1; i <= startPage + preCacheCount; i++) { + if (i == startPage || i <= 0 || i > totalPages) continue; + bool shouldPreCache = i == startPage + 1 || i == startPage - 1; + _cachePage(i, shouldPreCache); + } + } + + void _cachePage(int page, bool shouldPreCache) { + var (startIndex, endIndex) = getPageImagesRange(page); + for (int i = startIndex; i < endIndex; i++) { + if (shouldPreCache) { + _precacheImage(i+1, context); + } else { + _preDownloadImage(i+1, context); } } } @@ -185,14 +227,10 @@ class _GalleryModeState extends State<_GalleryMode> child: const SizedBox(), ); } else { - int pageIndex = index - 1; - int startIndex = pageIndex * reader.imagesPerPage; - int endIndex = math.min( - startIndex + reader.imagesPerPage, reader.images!.length); + var (startIndex, endIndex) = getPageImagesRange(index); List pageImages = reader.images!.sublist(startIndex, endIndex); - cached[index] = true; cache(index); photoViewControllers[index] ??= PhotoViewController(); @@ -201,8 +239,11 @@ class _GalleryModeState extends State<_GalleryMode> return PhotoViewGalleryPageOptions( filterQuality: FilterQuality.medium, controller: photoViewControllers[index], - imageProvider: - _createImageProviderFromKey(pageImages[0], context), + imageProvider: _createImageProviderFromKey( + pageImages[0], + context, + startIndex + 1, + ), fit: BoxFit.contain, errorBuilder: (_, error, s, retry) { return NetworkError(message: error.toString(), retry: retry); @@ -214,7 +255,7 @@ class _GalleryModeState extends State<_GalleryMode> controller: photoViewControllers[index], minScale: PhotoViewComputedScale.contained * 1.0, maxScale: PhotoViewComputedScale.covered * 10.0, - child: buildPageImages(pageImages), + child: buildPageImages(pageImages, startIndex), ); } }, @@ -244,12 +285,19 @@ class _GalleryModeState extends State<_GalleryMode> reader.setPage(i); context.readerScaffold.update(); } + // Remove other pages' controllers to reset their state. + var keys = photoViewControllers.keys.toList(); + for (var key in keys) { + if (key != i) { + photoViewControllers.remove(key); + } + } }, ), ); } - Widget buildPageImages(List images) { + Widget buildPageImages(List images, int startIndex) { Axis axis = (reader.mode == ReaderMode.galleryTopToBottom) ? Axis.vertical : Axis.horizontal; @@ -267,7 +315,11 @@ class _GalleryModeState extends State<_GalleryMode> child: ComicImage( width: double.infinity, height: double.infinity, - image: _createImageProviderFromKey(images[0], context), + image: _createImageProviderFromKey( + images[0], + context, + startIndex + 1, + ), fit: BoxFit.contain, alignment: axis == Axis.vertical ? Alignment.bottomCenter @@ -280,7 +332,11 @@ class _GalleryModeState extends State<_GalleryMode> child: ComicImage( width: double.infinity, height: double.infinity, - image: _createImageProviderFromKey(images[1], context), + image: _createImageProviderFromKey( + images[1], + context, + startIndex + 2, + ), fit: BoxFit.contain, alignment: axis == Axis.vertical ? Alignment.topCenter @@ -292,8 +348,9 @@ class _GalleryModeState extends State<_GalleryMode> ]; } else { imageWidgets = images.map((imageKey) { + startIndex++; ImageProvider imageProvider = - _createImageProviderFromKey(imageKey, context); + _createImageProviderFromKey(imageKey, context, startIndex); return Expanded( child: ComicImage( image: imageProvider, @@ -402,34 +459,24 @@ class _GalleryModeState extends State<_GalleryMode> keyRepeatTimer = null; } if (forward == true) { - controller.nextPage( - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); + reader.toPage(reader.page+1); } else if (forward == false) { - controller.previousPage( - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); + reader.toPage(reader.page-1); } } if (event is KeyRepeatEvent && keyRepeatTimer == null) { keyRepeatTimer = Timer.periodic( - const Duration(milliseconds: 100), + reader.enablePageAnimation + ? const Duration(milliseconds: 200) + : const Duration(milliseconds: 50), (timer) { if (!mounted) { timer.cancel(); return; } else if (forward == true) { - controller.nextPage( - duration: const Duration(milliseconds: 100), - curve: Curves.ease, - ); + reader.toPage(reader.page+1); } else if (forward == false) { - controller.previousPage( - duration: const Duration(milliseconds: 100), - curve: Curves.ease, - ); + reader.toPage(reader.page-1); } }, ); @@ -447,6 +494,19 @@ class _GalleryModeState extends State<_GalleryMode> @override Future getImageByOffset(Offset offset) async { + var imageKey = getImageKeyByOffset(offset); + if (imageKey == null) return null; + if (imageKey.startsWith("file://")) { + return await File(imageKey.substring(7)).readAsBytes(); + } else { + return (await CacheManager().findCache( + "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! + .readAsBytes(); + } + } + + @override + String? getImageKeyByOffset(Offset offset) { String? imageKey; if (reader.imagesPerPage == 1) { imageKey = reader.images![reader.page - 1]; @@ -457,14 +517,7 @@ class _GalleryModeState extends State<_GalleryMode> } } } - if (imageKey == null) return null; - if (imageKey.startsWith("file://")) { - return await File(imageKey.substring(7)).readAsBytes(); - } else { - return (await CacheManager().findCache( - "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! - .readAsBytes(); - } + return imageKey; } } @@ -599,7 +652,7 @@ class _ContinuousModeState extends State<_ContinuousMode> void cacheImages(int current) { for (int i = current + 1; i <= current + preCacheCount; i++) { if (i <= reader.maxPage && !cached[i]) { - _precacheImage(i, context); + _preDownloadImage(i, context); cached[i] = true; } } @@ -975,13 +1028,13 @@ class _ContinuousModeState extends State<_ContinuousMode> } if (forward == true) { scrollController.animateTo( - scrollController.offset + context.height, + scrollController.offset + context.height * 0.25, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } else if (forward == false) { scrollController.animateTo( - scrollController.offset - context.height, + scrollController.offset - context.height * 0.25, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); @@ -998,12 +1051,7 @@ class _ContinuousModeState extends State<_ContinuousMode> @override Future getImageByOffset(Offset offset) async { - String? imageKey; - for (var imageState in imageStates) { - if ((imageState as _ComicImageState).containsPoint(offset)) { - imageKey = (imageState.widget.image as ReaderImageProvider).imageKey; - } - } + var imageKey = getImageKeyByOffset(offset); if (imageKey == null) return null; if (imageKey.startsWith("file://")) { return await File(imageKey.substring(7)).readAsBytes(); @@ -1013,10 +1061,24 @@ class _ContinuousModeState extends State<_ContinuousMode> .readAsBytes(); } } + + @override + String? getImageKeyByOffset(Offset offset) { + String? imageKey; + for (var imageState in imageStates) { + if ((imageState as _ComicImageState).containsPoint(offset)) { + imageKey = (imageState.widget.image as ReaderImageProvider).imageKey; + } + } + return imageKey; + } } ImageProvider _createImageProviderFromKey( - String imageKey, BuildContext context) { + String imageKey, + BuildContext context, + int page, +) { var reader = context.reader; return ReaderImageProvider( imageKey, @@ -1030,16 +1092,39 @@ ImageProvider _createImageProviderFromKey( ImageProvider _createImageProvider(int page, BuildContext context) { var reader = context.reader; var imageKey = reader.images![page - 1]; - return _createImageProviderFromKey(imageKey, context); + return _createImageProviderFromKey(imageKey, context, page); } +/// [_precacheImage] is used to precache the image for the given page. +/// The image is cached using the flutter's [precacheImage] method. +/// The image will be downloaded and decoded into memory. void _precacheImage(int page, BuildContext context) { + if (page <= 0 || page > context.reader.images!.length) { + return; + } precacheImage( _createImageProvider(page, context), context, ); } +/// [_preDownloadImage] is used to download the image for the given page. +/// The image is downloaded using the [CacheManager] and saved to the local storage. +void _preDownloadImage(int page, BuildContext context) { + if (page <= 0 || page > context.reader.images!.length) { + return; + } + var reader = context.reader; + var imageKey = reader.images![page - 1]; + if (imageKey.startsWith("file://")) { + return; + } + var cid = reader.cid; + var eid = reader.eid; + var sourceKey = reader.type.comicSource?.key; + ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid); +} + class _SwipeChangeChapterProgress extends StatefulWidget { const _SwipeChangeChapterProgress({ this.controller, diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index 9810285..0686bf3 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -29,6 +29,7 @@ import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; +import 'package:venera/network/images.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/clipboard_image.dart'; import 'package:venera/utils/data_sync.dart'; @@ -216,10 +217,16 @@ class _ReaderState extends State focusNode: focusNode, autofocus: true, onKeyEvent: onKeyEvent, - child: _ReaderScaffold( - child: _ReaderGestureDetector( - child: _ReaderImages(key: Key(chapter.toString())), - ), + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) { + return _ReaderScaffold( + child: _ReaderGestureDetector( + child: _ReaderImages(key: Key(chapter.toString())), + ), + ); + }) + ], ), ); } @@ -603,4 +610,6 @@ abstract interface class _ImageViewController { bool handleOnTap(Offset location); Future getImageByOffset(Offset offset); + + String? getImageKeyByOffset(Offset offset); } diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 6f8007a..bfc1a86 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -208,7 +208,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); } - void addImageFavorite() { + void addImageFavorite() async { try { if (context.reader.images![0].contains('file://')) { showToast( @@ -222,7 +222,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { String title = context.reader.history!.title; String subTitle = context.reader.history!.subtitle; int maxPage = context.reader.images!.length; - int page = context.reader.page; + int? page = await selectImage(); + if (page == null) return; + page += 1; String sourceKey = context.reader.type.sourceKey; String imageKey = context.reader.images![page - 1]; List tags = context.reader.widget.tags; @@ -378,11 +380,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { Tooltip( message: "Collect the image".tl, child: IconButton( - icon: Icon( - isLiked() ? Icons.favorite : Icons.favorite_border), - onPressed: addImageFavorite), + icon: + Icon(isLiked() ? Icons.favorite : Icons.favorite_border), + onPressed: addImageFavorite, + ), ), - if (App.isWindows) + if (App.isDesktop) Tooltip( message: "${"Full Screen".tl}(F12)", child: IconButton( @@ -570,94 +573,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); } - Future _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() - .toList(); - String? selected; - if (images.length > 1) { - await showPopUpWidget( - context, - PopUpWidgetScaffold( - title: "Select an image on screen".tl, - body: GridView.builder( - itemCount: images.length, - itemBuilder: (context, index) { - ImageProvider image; - var imageKey = images[index]; - if (imageKey.startsWith('file://')) { - image = FileImage(File(imageKey.replaceFirst("file://", ''))); - } else { - image = ReaderImageProvider( - imageKey, - reader.type.comicSource!.key, - reader.cid, - reader.eid, - reader.page, - ); - } - return InkWell( - borderRadius: const BorderRadius.all(Radius.circular(16)), - onTap: () { - selected = images[index]; - App.rootContext.pop(); - }, - child: Container( - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - ), - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), - width: double.infinity, - height: double.infinity, - child: Image( - width: double.infinity, - height: double.infinity, - image: image, - ), - ), - ).padding(const EdgeInsets.all(8)); - }, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - childAspectRatio: 0.7, - ), - ), - ), - ); - } else { - selected = images.first; - } - if (selected == null) { - return null; - } else { - imageKey = selected!; - } - } - if (imageKey.startsWith("file://")) { - return await File(imageKey.substring(7)).readAsBytes(); - } else { - return (await CacheManager().findCache( - "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! - .readAsBytes(); - } - } - void saveCurrentImage() async { - var data = await _getCurrentImageData(); + var data = await selectImageToData(); if (data == null) { return; } @@ -667,7 +584,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } void share() async { - var data = await _getCurrentImageData(); + var data = await selectImageToData(); if (data == null) { return; } @@ -750,9 +667,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ? Icons.arrow_forward_ios : Icons.arrow_back_ios_outlined, size: 24, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ), @@ -761,6 +676,74 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } return const SizedBox(); } + + /// If there is only one image on screen, return it. + /// + /// If there are multiple images on screen, + /// show an overlay to let the user select an image. + /// + /// The return value is the index of the selected image. + Future 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 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 _showSelectImageOverlay() { + if (_isOpen) { + openOrClose(); + } + + var completer = Completer(); + + var overlay = Overlay.of(context); + OverlayEntry? entry; + entry = OverlayEntry( + builder: (context) { + return Positioned.fill( + child: _SelectImageOverlayContent(onTap: (offset) { + completer.complete(offset); + entry!.remove(); + }, onDispose: () { + if (!completer.isCompleted) { + completer.complete(null); + } + }), + ); + }, + ); + overlay.insert(entry); + + return completer.future; + } } class _BatteryWidget extends StatefulWidget { @@ -941,3 +924,69 @@ class _ClockWidgetState extends State<_ClockWidget> { ); } } + +class _SelectImageOverlayContent extends StatefulWidget { + const _SelectImageOverlayContent({ + required this.onTap, + required this.onDispose, + }); + + final void Function(Offset) onTap; + + final void Function() onDispose; + + @override + State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState(); +} + +class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> { + @override + void dispose() { + widget.onDispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + widget.onTap(details.globalPosition); + }, + child: Container( + color: Colors.black.withAlpha(50), + child: Align( + alignment: Alignment( + 0, + -0.8, + ), + child: Container( + width: 232, + height: 42, + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: context.colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + const SizedBox(width: 8), + const Icon(Icons.info_outline), + const SizedBox(width: 16), + Text( + "Click to select an image".tl, + style: TextStyle( + fontSize: 16, + color: context.colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index ea04a78..cce23e6 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -66,6 +66,7 @@ class _ReaderSettingsState extends State { min: 1, max: 20, onChanged: () { + setState(() {}); widget.onChanged?.call("autoPageTurningInterval"); }, ).toSliver(), @@ -80,6 +81,7 @@ class _ReaderSettingsState extends State { min: 1, max: 5, onChanged: () { + setState(() {}); widget.onChanged?.call("readerScreenPicNumberForLandscape"); }, ), @@ -99,6 +101,18 @@ class _ReaderSettingsState extends State { }, ), ), + 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: 'Long press to zoom'.tl, settingKey: 'enableLongPressToZoom', diff --git a/lib/pages/webview.dart b/lib/pages/webview.dart index c81e140..212daaf 100644 --- a/lib/pages/webview.dart +++ b/lib/pages/webview.dart @@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; -import 'package:venera/network/app_dio.dart'; +import 'package:venera/network/proxy.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; import 'dart:io' as io; @@ -308,7 +308,7 @@ class DesktopWebview { useWindowPositionAndSize: true, userDataFolderWindows: "${App.dataPath}\\webview", title: "webview", - proxy: AppDio.proxy, + proxy: await getProxy(), )); _webview!.addOnWebMessageReceivedCallback(onMessage); _webview!.setOnNavigation((s) { diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index 4710421..3e4b25b 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -112,7 +112,7 @@ abstract class CBZ { var ext = e.path.split('.').last; return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); }); - if(files.isEmpty) { + if (files.isEmpty) { cache.deleteSync(recursive: true); throw Exception('No images found in the archive'); } @@ -141,8 +141,7 @@ abstract class CBZ { FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), ); dest.createSync(); - coverFile.copyMem( - FilePath.join(dest.path, 'cover.${coverFile.extension}')); + coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}')); if (metaData.chapters == null) { for (var i = 0; i < files.length; i++) { var src = files[i]; @@ -233,17 +232,19 @@ abstract class CBZ { } } var cover = comic.coverFile; - await cover - .copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); + await cover.copyMem( + FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); + final metaData = ComicMetaData( + title: comic.title, + author: comic.subtitle, + tags: comic.tags, + chapters: chapters, + ); await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( - jsonEncode( - ComicMetaData( - title: comic.title, - author: comic.subtitle, - tags: comic.tags, - chapters: chapters, - ).toJson(), - ), + jsonEncode(metaData), + ); + await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString( + _buildComicInfoXml(metaData), ); var cbz = File(outFilePath); if (cbz.existsSync()) cbz.deleteSync(); @@ -252,7 +253,54 @@ abstract class CBZ { return cbz; } + static String _buildComicInfoXml(ComicMetaData data) { + final buffer = StringBuffer(); + buffer.writeln(''); + buffer.writeln(''); + + buffer.writeln(' ${_escapeXml(data.title)}'); + buffer.writeln(' ${_escapeXml(data.title)}'); + + if (data.author.isNotEmpty) { + buffer.writeln(' ${_escapeXml(data.author)}'); + } + + if (data.tags.isNotEmpty) { + var tags = data.tags; + if (tags.length > 5) { + tags = tags.sublist(0, 5); + } + buffer.writeln(' ${_escapeXml(tags.join(', '))}'); + } + + if (data.chapters != null && data.chapters!.isNotEmpty) { + final chaptersInfo = data.chapters!.map((chapter) => + '${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}' + ).join('; '); + buffer.writeln(' Chapters: $chaptersInfo'); + } + + buffer.writeln(' Unknown'); + buffer.writeln(' Unknown'); + + final now = DateTime.now(); + buffer.writeln(' ${now.year}'); + + buffer.writeln(''); + return buffer.toString(); + } + + static String _escapeXml(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + static _compress(String src, String dst) async { await ZipFile.compressFolderAsync(src, dst, 4); } } + diff --git a/lib/utils/data_sync.dart b/lib/utils/data_sync.dart index 12bd7cc..52b890d 100644 --- a/lib/utils/data_sync.dart +++ b/lib/utils/data_sync.dart @@ -11,7 +11,6 @@ import 'package:venera/network/app_dio.dart'; import 'package:venera/utils/data.dart'; import 'package:venera/utils/ext.dart'; import 'package:webdav_client/webdav_client.dart' hide File; -import 'package:rhttp/rhttp.dart' as rhttp; import 'package:venera/utils/translations.dart'; import 'io.dart'; @@ -119,19 +118,11 @@ class DataSync with ChangeNotifier { String user = config[1]; String pass = config[2]; - var proxy = await AppDio.getProxy(); - var client = newClient( url, user: user, password: pass, - adapter: RHttpAdapter( - rhttp.ClientSettings( - proxySettings: - proxy == null ? null : rhttp.ProxySettings.proxy(proxy), - userAgent: "venera v${App.version}", - ), - ), + adapter: RHttpAdapter(), ); try { @@ -192,19 +183,11 @@ class DataSync with ChangeNotifier { String user = config[1]; String pass = config[2]; - var proxy = await AppDio.getProxy(); - var client = newClient( url, user: user, password: pass, - adapter: RHttpAdapter( - rhttp.ClientSettings( - proxySettings: - proxy == null ? null : rhttp.ProxySettings.proxy(proxy), - userAgent: "venera v${App.version}", - ), - ), + adapter: RHttpAdapter(), ); try { diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 288b5a1..d789d4b 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -132,15 +132,15 @@ extension DirectoryExtension on Directory { /// Sanitize the file name. Remove invalid characters and trim the file name. String sanitizeFileName(String fileName, {String? dir, int? maxLength}) { - if (fileName.endsWith('.')) { + while (fileName.endsWith('.')) { fileName = fileName.substring(0, fileName.length - 1); } - var maxLength = 255; + var length = maxLength ?? 255; if (dir != null) { if (!dir.endsWith('/') && !dir.endsWith('\\')) { dir = "$dir/"; } - maxLength -= dir.length; + length -= dir.length; } final invalidChars = RegExp(r'[<>:"/\\|?*]'); final sanitizedFileName = fileName.replaceAll(invalidChars, ' '); @@ -148,11 +148,11 @@ String sanitizeFileName(String fileName, {String? dir, int? maxLength}) { if (trimmedFileName.isEmpty) { throw Exception('Invalid File Name: Empty length.'); } - if (maxLength <= 0) { + if (length <= 0) { throw Exception('Invalid File Name: Max length is less than 0.'); } - if (trimmedFileName.length > maxLength) { - trimmedFileName = trimmedFileName.substring(0, maxLength); + if (trimmedFileName.length > length) { + trimmedFileName = trimmedFileName.substring(0, length); } return trimmedFileName; } diff --git a/pubspec.lock b/pubspec.lock index 8fa2170..811ca1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.12.0" battery_plus: dependency: "direct main" description: @@ -182,10 +182,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" ffi: dependency: transitive description: @@ -308,18 +308,18 @@ packages: dependency: "direct main" description: path: flutter_inappwebview - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "6.2.0-beta.3" flutter_inappwebview_android: dependency: transitive description: path: flutter_inappwebview_android - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "1.2.0-beta.3" flutter_inappwebview_internal_annotations: @@ -334,45 +334,45 @@ packages: dependency: transitive description: path: flutter_inappwebview_ios - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "1.2.0-beta.3" flutter_inappwebview_macos: dependency: transitive description: path: flutter_inappwebview_macos - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "1.2.0-beta.3" flutter_inappwebview_platform_interface: dependency: transitive description: path: flutter_inappwebview_platform_interface - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "1.4.0-beta.3" flutter_inappwebview_web: dependency: transitive description: path: flutter_inappwebview_web - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "1.2.0-beta.3" flutter_inappwebview_windows: dependency: transitive description: path: flutter_inappwebview_windows - ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" - url: "https://github.com/pichillilorenzo/flutter_inappwebview" + ref: "3ef899b3db57c911b080979f1392253b835f98ab" + resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab" + url: "https://github.com/venera-app/flutter_inappwebview" source: git version: "0.7.0-beta.3" flutter_lints: @@ -516,10 +516,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.19.0" io: dependency: transitive description: @@ -540,10 +540,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: @@ -1029,10 +1029,10 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "14.3.1" web: dependency: transitive description: @@ -1100,4 +1100,4 @@ packages: version: "0.0.12" sdks: dart: ">=3.7.0 <4.0.0" - flutter: ">=3.29.2" + flutter: ">=3.29.3" diff --git a/pubspec.yaml b/pubspec.yaml index 9d9ff98..953bf25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,11 +2,11 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.4.0+140 +version: 1.4.1+141 environment: sdk: '>=3.6.0 <4.0.0' - flutter: 3.29.2 + flutter: 3.29.3 dependencies: flutter: @@ -46,9 +46,9 @@ dependencies: ref: 7801fc582ecf5a7351632887891ecf309a7b2583 flutter_inappwebview: git: - url: https://github.com/pichillilorenzo/flutter_inappwebview + url: https://github.com/venera-app/flutter_inappwebview path: flutter_inappwebview - ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676 + ref: 3ef899b3db57c911b080979f1392253b835f98ab app_links: ^6.4.0 sliver_tools: ^0.2.12 flutter_file_dialog: ^3.0.2 diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index f379ea8..fd0590a 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -10,11 +10,16 @@ #include #include #include "flutter/generated_plugin_registrant.h" +#include #define _CRT_SECURE_NO_WARNINGS std::unique_ptr>&& mouseEvents = nullptr; +std::atomic mainThreadAlive(true); +std::atomic lastHeartbeat(std::chrono::steady_clock::now()); +std::thread* monitorThread = nullptr; + char* wideCharToMultiByte(wchar_t* pWCStrKey) { size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL); @@ -45,6 +50,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project) FlutterWindow::~FlutterWindow() {} +void monitorUIThread() { + const auto timeout = std::chrono::seconds(5); + + while (mainThreadAlive.load()) { + auto now = std::chrono::steady_clock::now(); + auto duration = now - lastHeartbeat.load(); + + if (duration > timeout) { + std::cerr << "The UI thread is dead. Terminate the application."; + std::exit(0); + } + + std::this_thread::sleep_for(std::chrono::seconds(1)); + } +} + bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; @@ -78,6 +99,13 @@ bool FlutterWindow::OnCreate() { result->Success(flutter::EncodableValue("No Proxy")); delete(res); } + else if (call.method_name() == "heartBeat") { + if (monitorThread == nullptr) { + monitorThread = new std::thread{ monitorUIThread }; + } + lastHeartbeat = std::chrono::steady_clock::now(); + result->Success(); + } }); flutter::EventChannel<> channel2( @@ -163,6 +191,10 @@ void FlutterWindow::OnDestroy() { } Win32Window::OnDestroy(); + if (monitorThread != nullptr) { + mainThreadAlive = false; + monitorThread->join(); + } } void mouse_side_button_listener(unsigned int input)