mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
302 lines
8.3 KiB
Dart
302 lines
8.3 KiB
Dart
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<ImageDownloadProgress> 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 = <String, dynamic>{};
|
|
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<String, dynamic>.from(configs['headers']),
|
|
method: configs['method'] ?? 'GET',
|
|
responseType: ResponseType.stream,
|
|
));
|
|
|
|
var req = await dio.request<ResponseBody>(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 = <int>[];
|
|
await for (var data in stream) {
|
|
buffer.addAll(data);
|
|
if (expectedBytes != null) {
|
|
yield ImageDownloadProgress(
|
|
currentBytes: buffer.length,
|
|
totalBytes: expectedBytes,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (configs['onResponse'] is JSInvokable) {
|
|
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
|
|
(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 = <String, _StreamWrapper<ImageDownloadProgress>>{};
|
|
|
|
/// Cancel all loading images.
|
|
static void cancelAllLoadingImages() {
|
|
for (var wrapper in _loadingImages.values) {
|
|
wrapper.cancel();
|
|
}
|
|
_loadingImages.clear();
|
|
}
|
|
|
|
/// Load a comic image from the network or cache.
|
|
/// The function will prevent multiple requests for the same image.
|
|
static Stream<ImageDownloadProgress> loadComicImage(
|
|
String imageKey, String? sourceKey, String cid, String eid) {
|
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
|
if (_loadingImages.containsKey(cacheKey)) {
|
|
return _loadingImages[cacheKey]!.stream;
|
|
}
|
|
final stream = _StreamWrapper<ImageDownloadProgress>(
|
|
_loadComicImage(imageKey, sourceKey, cid, eid),
|
|
(wrapper) {
|
|
_loadingImages.remove(cacheKey);
|
|
},
|
|
);
|
|
_loadingImages[cacheKey] = stream;
|
|
return stream.stream;
|
|
}
|
|
|
|
static Stream<ImageDownloadProgress> _loadComicImage(
|
|
String imageKey, String? sourceKey, String cid, String eid) async* {
|
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
|
final cache = await CacheManager().findCache(cacheKey);
|
|
|
|
if (cache != null) {
|
|
var data = await cache.readAsBytes();
|
|
yield ImageDownloadProgress(
|
|
currentBytes: data.length,
|
|
totalBytes: data.length,
|
|
imageBytes: data,
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> Function()? onLoadFailed;
|
|
|
|
var configs = <String, dynamic>{};
|
|
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<String, dynamic>) return null;
|
|
return result;
|
|
};
|
|
}
|
|
|
|
var dio = AppDio(BaseOptions(
|
|
headers: configs['headers'],
|
|
method: configs['method'] ?? 'GET',
|
|
responseType: ResponseType.stream,
|
|
));
|
|
|
|
var req = await dio.request<ResponseBody>(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 = <int>[];
|
|
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)([buffer]);
|
|
(configs['onResponse'] as JSInvokable).free();
|
|
}
|
|
|
|
var 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<T> {
|
|
final Stream<T> _stream;
|
|
|
|
final List<StreamController> controllers = [];
|
|
|
|
final void Function(_StreamWrapper<T> wrapper) onClosed;
|
|
|
|
bool isClosed = false;
|
|
|
|
_StreamWrapper(this._stream, this.onClosed) {
|
|
_listen();
|
|
}
|
|
|
|
void _listen() async {
|
|
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<T> get stream {
|
|
if (isClosed) {
|
|
throw Exception('Stream is closed');
|
|
}
|
|
var controller = StreamController<T>();
|
|
controllers.add(controller);
|
|
controller.onCancel = () {
|
|
controllers.remove(controller);
|
|
};
|
|
return controller.stream;
|
|
}
|
|
|
|
void cancel() {
|
|
for (var controller in controllers) {
|
|
controller.close();
|
|
}
|
|
controllers.clear();
|
|
isClosed = true;
|
|
}
|
|
}
|
|
|
|
class ImageDownloadProgress {
|
|
final int currentBytes;
|
|
|
|
final int? totalBytes;
|
|
|
|
final Uint8List? imageBytes;
|
|
|
|
const ImageDownloadProgress({
|
|
required this.currentBytes,
|
|
required this.totalBytes,
|
|
this.imageBytes,
|
|
});
|
|
}
|