import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/utils/image.dart'; import 'app_dio.dart'; abstract class ImageDownloader { static Stream loadThumbnail( String url, String? sourceKey, [String? cid]) async* { final cacheKey = "$url@$sourceKey${cid != null ? '@$cid' : ''}"; final cache = await CacheManager().findCache(cacheKey); if (cache != null) { var data = await cache.readAsBytes(); yield ImageDownloadProgress( currentBytes: data.length, totalBytes: data.length, imageBytes: data, ); } var configs = {}; if (sourceKey != null) { var comicSource = ComicSource.find(sourceKey); configs = comicSource?.getThumbnailLoadingConfig?.call(url) ?? {}; } configs['headers'] ??= {}; if (configs['headers']['user-agent'] == null && configs['headers']['User-Agent'] == null) { configs['headers']['user-agent'] = webUA; } if (((configs['url'] as String?) ?? url).startsWith('cover.') && sourceKey != null) { var comicSource = ComicSource.find(sourceKey); if(comicSource != null) { var comicInfo = await comicSource.loadComicInfo!(cid!); yield* loadThumbnail(comicInfo.data.cover, sourceKey); return; } } var dio = AppDio(BaseOptions( headers: Map.from(configs['headers']), method: configs['method'] ?? 'GET', responseType: ResponseType.stream, )); var req = await dio.request(configs['url'] ?? url, data: configs['data']); var stream = req.data?.stream ?? (throw "Error: Empty response body."); int? expectedBytes = req.data!.contentLength; if (expectedBytes == -1) { expectedBytes = null; } var buffer = []; await for (var data in stream) { buffer.addAll(data); if (expectedBytes != null) { yield ImageDownloadProgress( currentBytes: buffer.length, totalBytes: expectedBytes, ); } } if (configs['onResponse'] is JSInvokable) { final uint8List = Uint8List.fromList(buffer); buffer = (configs['onResponse'] as JSInvokable)([uint8List]); (configs['onResponse'] as JSInvokable).free(); } await CacheManager().writeCache(cacheKey, buffer); yield ImageDownloadProgress( currentBytes: buffer.length, totalBytes: buffer.length, imageBytes: Uint8List.fromList(buffer), ); } 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 loadComicImageUnwrapped( String imageKey, String? sourceKey, String cid, String eid) { return _loadComicImage(imageKey, sourceKey, cid, eid); } static Stream _loadComicImage( String imageKey, String? sourceKey, String cid, String eid) async* { final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cache = await CacheManager().findCache(cacheKey); if (cache != null) { var data = await cache.readAsBytes(); yield ImageDownloadProgress( currentBytes: data.length, totalBytes: data.length, imageBytes: data, ); } Future?> Function()? onLoadFailed; var configs = {}; if (sourceKey != null) { var comicSource = ComicSource.find(sourceKey); configs = (await comicSource!.getImageLoadingConfig ?.call(imageKey, cid, eid)) ?? {}; } var retryLimit = 5; while (true) { try { configs['headers'] ??= { 'user-agent': webUA, }; if (configs['onLoadFailed'] is JSInvokable) { onLoadFailed = () async { dynamic result = (configs['onLoadFailed'] as JSInvokable)([]); if (result is Future) { result = await result; } if (result is! Map) return null; return result; }; } var dio = AppDio(BaseOptions( headers: configs['headers'], method: configs['method'] ?? 'GET', responseType: ResponseType.stream, )); var req = await dio.request(configs['url'] ?? imageKey, data: configs['data']); var stream = req.data?.stream ?? (throw "Error: Empty response body."); int? expectedBytes = req.data!.contentLength; if (expectedBytes == -1) { expectedBytes = null; } var buffer = []; await for (var data in stream) { buffer.addAll(data); yield ImageDownloadProgress( currentBytes: buffer.length, totalBytes: expectedBytes, ); } if (configs['onResponse'] is JSInvokable) { buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]); (configs['onResponse'] as JSInvokable).free(); } Uint8List data; if (buffer is Uint8List) { data = buffer; } else { data = Uint8List.fromList(buffer); buffer.clear(); } if (configs['modifyImage'] != null) { var newData = await modifyImageWithScript( data, configs['modifyImage'], ); data = newData; } await CacheManager().writeCache(cacheKey, data); yield ImageDownloadProgress( currentBytes: data.length, totalBytes: data.length, imageBytes: data, ); return; } catch (e) { if (retryLimit < 0 || onLoadFailed == null) { rethrow; } var newConfig = await onLoadFailed(); (configs['onLoadFailed'] as JSInvokable).free(); onLoadFailed = null; if (newConfig == null) { rethrow; } configs = newConfig; retryLimit--; } finally { if (onLoadFailed != null) { (configs['onLoadFailed'] as JSInvokable).free(); } } } } } /// 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 { try { await for (var data in _stream) { if (isClosed) { break; } for (var controller in controllers) { if (!controller.isClosed) { controller.add(data); } } } } catch (e) { for (var controller in controllers) { if (!controller.isClosed) { controller.addError(e); } } } finally { 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; final int? totalBytes; final Uint8List? imageBytes; const ImageDownloadProgress({ required this.currentBytes, required this.totalBytes, this.imageBytes, }); }