diff --git a/assets/translation.json b/assets/translation.json index 9d69000..207364f 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -259,7 +259,9 @@ "Multiple cbz files" : "多个cbz文件", "No valid comics found" : "未找到有效的漫画", "Enable DNS Overrides": "启用DNS覆写", - "DNS Overrides": "DNS覆写" + "DNS Overrides": "DNS覆写", + "Custom Image Processing": "自定义图片处理", + "Enable": "启用" }, "zh_TW": { "Home": "首頁", @@ -521,6 +523,8 @@ "Multiple cbz files" : "多個cbz文件", "No valid comics found" : "未找到有效的漫畫", "Enable DNS Overrides": "啟用DNS覆寫", - "DNS Overrides": "DNS覆寫" + "DNS Overrides": "DNS覆寫", + "Custom Image Processing": "自定義圖片處理", + "Enable": "啟用" } } \ No newline at end of file diff --git a/lib/components/code.dart b/lib/components/code.dart new file mode 100644 index 0000000..5bd1f5c --- /dev/null +++ b/lib/components/code.dart @@ -0,0 +1,96 @@ +part of 'components.dart'; + +class CodeEditor extends StatefulWidget { + const CodeEditor({super.key, this.initialValue, this.onChanged}); + + final String? initialValue; + + final void Function(String value)? onChanged; + + @override + State createState() => _CodeEditorState(); +} + +class _CodeEditorState extends State { + late TextEditingController _controller; + late FocusNode _focusNode; + var horizontalScrollController = ScrollController(); + var verticalScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _focusNode = FocusNode() + ..onKeyEvent = (node, event) { + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (event is KeyDownEvent) { + handleTab(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }; + } + + void handleTab() { + var text = _controller.text; + var start = _controller.selection.start; + var end = _controller.selection.end; + _controller.text = '${text.substring(0, start)} ${text.substring(end)}'; + _controller.selection = TextSelection.collapsed(offset: start + 4); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + thumbVisibility: true, + controller: verticalScrollController, + notificationPredicate: (notif) => notif.metrics.axis == Axis.vertical, + child: Scrollbar( + thumbVisibility: true, + controller: horizontalScrollController, + notificationPredicate: (notif) => notif.metrics.axis == Axis.horizontal, + child: SizedBox.expand( + child: ScrollConfiguration( + behavior: _CustomScrollBehavior(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: horizontalScrollController, + child: IntrinsicWidth( + stepWidth: 100, + child: TextField( + style: TextStyle( + fontFamily: 'consolas', + fontFamilyFallback: ['Courier New', 'monospace'], + ), + controller: _controller, + focusNode: _focusNode, + maxLines: null, + expands: true, + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(8), + ), + onChanged: (value) { + widget.onChanged?.call(value); + }, + scrollController: verticalScrollController, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _CustomScrollBehavior extends MaterialScrollBehavior { + const _CustomScrollBehavior(); + @override + Widget buildScrollbar( + BuildContext context, Widget child, ScrollableDetails details) { + return child; + } +} diff --git a/lib/components/components.dart b/lib/components/components.dart index ac55f9b..55c8d9c 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -44,4 +44,5 @@ part 'select.dart'; part 'side_bar.dart'; part 'comic.dart'; part 'effects.dart'; -part 'gesture.dart'; \ No newline at end of file +part 'gesture.dart'; +part 'code.dart'; \ No newline at end of file diff --git a/lib/components/pop_up_widget.dart b/lib/components/pop_up_widget.dart index 3436af5..bf36812 100644 --- a/lib/components/pop_up_widget.dart +++ b/lib/components/pop_up_widget.dart @@ -155,6 +155,9 @@ class _PopUpWidgetScaffoldState extends State { ), NotificationListener( onNotification: (notifications) { + if (notifications.metrics.axisDirection != AxisDirection.down) { + return false; + } if (notifications.metrics.pixels == notifications.metrics.minScrollExtent && !top) { diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 1628a14..044be3e 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -13,7 +13,7 @@ class _Appdata { bool _isSavingData = false; - Future saveData() async { + Future saveData([bool sync = true]) async { if (_isSavingData) { await Future.doWhile(() async { await Future.delayed(const Duration(milliseconds: 20)); @@ -25,7 +25,9 @@ class _Appdata { var file = File(FilePath.join(App.dataPath, 'appdata.json')); await file.writeAsString(data); _isSavingData = false; - DataSync().uploadData(); + if (sync) { + DataSync().uploadData(); + } } void addSearchHistory(String keyword) { @@ -78,6 +80,25 @@ class _Appdata { }; } + /// Following fields are related to device-specific data and should not be synced. + static const _disableSync = [ + "proxy", + "authorizationRequired", + "customImageProcessing", + ]; + + /// Sync data from another device + void syncData(Map data) { + for (var key in data.keys) { + if (_disableSync.contains(key)) { + continue; + } + settings[key] = data[key]; + } + searchHistory = List.from(data['searchHistory']); + saveData(); + } + var implicitData = {}; void writeImplicitData() { @@ -126,6 +147,8 @@ class _Settings with ChangeNotifier { 'onClickFavorite': 'viewDetail', // viewDetail, read 'enableDnsOverrides': false, 'dnsOverrides': {}, + 'enableCustomImageProcessing': false, + 'customImageProcessing': _defaultCustomImageProcessing, }; operator [](String key) { @@ -142,3 +165,16 @@ class _Settings with ChangeNotifier { return _data.toString(); } } + +const _defaultCustomImageProcessing = ''' +/** + * Process an image + * @param image {ArayBuffer} - The image to process + * @param cid {string} - The comic ID + * @param eid {string} - The episode ID + * @returns {Promise} - The processed image + */ +async function processImage(image, cid, eid) { + return image; +} +'''; \ No newline at end of file diff --git a/lib/foundation/image_provider/reader_image.dart b/lib/foundation/image_provider/reader_image.dart index b92b524..0afc72a 100644 --- a/lib/foundation/image_provider/reader_image.dart +++ b/lib/foundation/image_provider/reader_image.dart @@ -1,10 +1,13 @@ import 'dart:async' show Future, StreamController; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_qjs/flutter_qjs.dart'; +import 'package:venera/foundation/js_engine.dart'; import 'package:venera/network/images.dart'; import 'package:venera/utils/io.dart'; import 'base_image_provider.dart'; import 'reader_image.dart' as image_provider; +import 'package:venera/foundation/appdata.dart'; class ReaderImageProvider extends BaseImageProvider { @@ -21,25 +24,50 @@ class ReaderImageProvider @override Future load(StreamController chunkEvents) async { + Uint8List? imageBytes; if (imageKey.startsWith('file://')) { var file = File(imageKey); if (await file.exists()) { - return file.readAsBytes(); + imageBytes = await file.readAsBytes(); + } else { + throw "Error: File not found."; } - throw "Error: File not found."; - } - - await for (var event + } else { + await for (var event in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { - chunkEvents.add(ImageChunkEvent( - cumulativeBytesLoaded: event.currentBytes, - expectedTotalBytes: event.totalBytes, - )); - if (event.imageBytes != null) { - return event.imageBytes!; + chunkEvents.add(ImageChunkEvent( + cumulativeBytesLoaded: event.currentBytes, + expectedTotalBytes: event.totalBytes, + )); + if (event.imageBytes != null) { + imageBytes = event.imageBytes; + break; + } } } - throw "Error: Empty response body."; + if (imageBytes == null) { + throw "Error: Empty response body."; + } + if (appdata.settings['enableCustomImageProcessing']) { + var script = appdata.settings['customImageProcessing'].toString(); + if (!script.contains('async function processImage')) { + return imageBytes; + } + var func = JsEngine().runCode(''' + (() => { + $script + return processImage; + })() + '''); + if (func is JSInvokable) { + var result = await func.invoke([imageBytes, cid, eid]); + func.free(); + if (result is Uint8List) { + return result; + } + } + } + return imageBytes; } @override diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 810734a..1c6f05d 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -61,9 +61,17 @@ class _ReaderSettingsState extends State { ).toSliver(), SliverToBoxAdapter( child: AbsorbPointer( - absorbing: (appdata.settings['readerMode']?.toLowerCase().startsWith('continuous') ?? false), + absorbing: (appdata.settings['readerMode'] + ?.toLowerCase() + .startsWith('continuous') ?? + false), child: AnimatedOpacity( - opacity: (appdata.settings['readerMode']?.toLowerCase().startsWith('continuous') ?? false) ? 0.5 : 1.0, + opacity: (appdata.settings['readerMode'] + ?.toLowerCase() + .startsWith('continuous') ?? + false) + ? 0.5 + : 1.0, duration: Duration(milliseconds: 300), child: _SliderSetting( title: "The number of pic in screen (Only Gallery Mode)".tl, @@ -93,7 +101,7 @@ class _ReaderSettingsState extends State { widget.onChanged?.call('limitImageWidth'); }, ).toSliver(), - if(App.isAndroid) + if (App.isAndroid) _SwitchSetting( title: 'Turn page by volume keys'.tl, settingKey: 'enableTurnPageByVolumeKey', @@ -108,7 +116,67 @@ class _ReaderSettingsState extends State { widget.onChanged?.call("enableClockAndBatteryInfoInReader"); }, ).toSliver(), + _PopupWindowSetting( + title: "Custom Image Processing".tl, + builder: () => _CustomImageProcessing(), + ).toSliver(), ], ); } } + +class _CustomImageProcessing extends StatefulWidget { + const _CustomImageProcessing(); + + @override + State<_CustomImageProcessing> createState() => __CustomImageProcessingState(); +} + +class __CustomImageProcessingState extends State<_CustomImageProcessing> { + var current = ''; + + @override + void initState() { + super.initState(); + current = appdata.settings['customImageProcessing']; + } + + @override + void dispose() { + appdata.settings['customImageProcessing'] = current; + appdata.saveData(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopUpWidgetScaffold( + title: "Custom Image Processing".tl, + body: Column( + children: [ + _SwitchSetting( + title: "Enable".tl, + settingKey: "enableCustomImageProcessing", + ), + Expanded( + child: Container( + margin: EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: context.colorScheme.outlineVariant), + ), + child: SizedBox.expand( + child: CodeEditor( + initialValue: appdata.settings['customImageProcessing'], + onChanged: (value) { + current = value; + }, + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/utils/data.dart b/lib/utils/data.dart index 788191d..a427af2 100644 --- a/lib/utils/data.dart +++ b/lib/utils/data.dart @@ -80,15 +80,9 @@ Future importAppData(File file, [bool checkVersion = false]) async { LocalFavoritesManager().init(); } if (await appdataFile.exists()) { - // proxy settings & authorization setting should be kept - var proxySettings = appdata.settings["proxy"]; - var authSettings = appdata.settings["authorizationRequired"]; - File(FilePath.join(App.dataPath, "appdata.json")).deleteIfExistsSync(); - appdataFile.renameSync(FilePath.join(App.dataPath, "appdata.json")); - await appdata.init(); - appdata.settings["proxy"] = proxySettings; - appdata.settings["authorizationRequired"] = authSettings; - appdata.saveData(); + var content = await appdataFile.readAsString(); + var data = jsonDecode(content); + appdata.syncData(data); } if (await cookieFile.exists()) { SingleInstanceCookieJar.instance?.dispose(); diff --git a/lib/utils/data_sync.dart b/lib/utils/data_sync.dart index 3dd1474..285dd8d 100644 --- a/lib/utils/data_sync.dart +++ b/lib/utils/data_sync.dart @@ -99,7 +99,7 @@ class DataSync with ChangeNotifier { try { appdata.settings['dataVersion']++; - await appdata.saveData(); + await appdata.saveData(false); var data = await exportAppData(); var time = (DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();