add archive download

This commit is contained in:
2024-11-21 21:29:45 +08:00
parent b1cdcc2a91
commit ad3f2fab45
11 changed files with 726 additions and 17 deletions

View File

@@ -154,8 +154,6 @@ class ComicTile extends StatelessWidget {
ImageProvider image; ImageProvider image;
if (comic is LocalComic) { if (comic is LocalComic) {
image = FileImage((comic as LocalComic).coverFile); image = FileImage((comic as LocalComic).coverFile);
} else if (comic.cover.startsWith('file://')) {
image = FileImage(File(comic.cover.substring(7)));
} else if (comic.sourceKey == 'local') { } else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local); var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) { if (localComic == null) {

View File

@@ -215,6 +215,8 @@ class ComicSource {
final StarRatingFunc? starRatingFunc; final StarRatingFunc? starRatingFunc;
final ArchiveDownloader? archiveDownloader;
Future<void> loadData() async { Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data"); var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) { if (await file.exists()) {
@@ -284,6 +286,7 @@ class ComicSource {
this.enableTagsSuggestions, this.enableTagsSuggestions,
this.enableTagsTranslate, this.enableTagsTranslate,
this.starRatingFunc, this.starRatingFunc,
this.archiveDownloader,
); );
} }
@@ -465,3 +468,11 @@ class LinkHandler {
const LinkHandler(this.domains, this.linkToId); const LinkHandler(this.domains, this.linkToId);
} }
class ArchiveDownloader {
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
}

View File

@@ -232,3 +232,14 @@ class ComicDetails with HistoryMixin {
ComicType get comicType => ComicType(sourceKey.hashCode); ComicType get comicType => ComicType(sourceKey.hashCode);
} }
class ArchiveInfo {
final String title;
final String description;
final String id;
ArchiveInfo.fromJson(Map<String, dynamic> json)
: title = json["title"],
description = json["description"],
id = json["id"];
}

View File

@@ -153,6 +153,7 @@ class ComicSourceParser {
_getValue("search.enableTagsSuggestions") ?? false, _getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false, _getValue("comic.enableTagsTranslate") ?? false,
_parseStarRatingFunc(), _parseStarRatingFunc(),
_parseArchiveDownloader(),
); );
await source.loadData(); await source.loadData();
@@ -988,4 +989,35 @@ class ComicSourceParser {
} }
}; };
} }
ArchiveDownloader? _parseArchiveDownloader() {
if (!_checkExists("comic.archive")) {
return null;
}
return ArchiveDownloader(
(cid) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
""");
return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
(cid, aid) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)})
""");
return Res(res as String);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
);
}
} }

View File

@@ -1,4 +1,5 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future, StreamController;
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
@@ -8,6 +9,8 @@ import 'cached_image.dart' as image_provider;
class CachedImageProvider class CachedImageProvider
extends BaseImageProvider<image_provider.CachedImageProvider> { extends BaseImageProvider<image_provider.CachedImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid}); const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
final String url; final String url;
@@ -20,6 +23,10 @@ class CachedImageProvider
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
if(url.startsWith("file://")) {
var file = File(url.substring(7));
return file.readAsBytes();
}
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
@@ -184,7 +185,23 @@ class JsEngine with _JSEngineApi {
if (headers["user-agent"] == null && headers["User-Agent"] == null) { if (headers["user-agent"] == null && headers["User-Agent"] == null) {
headers["User-Agent"] = webUA; headers["User-Agent"] = webUA;
} }
response = await _dio!.request(req["url"], var dio = _dio;
if (headers['http_client'] == "dart:io") {
dio = Dio(BaseOptions(
responseType: ResponseType.plain,
validateStatus: (status) => true,
));
var proxy = await AppDio.getProxy();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
);
dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
dio.interceptors.add(LogInterceptor());
}
response = await dio!.request(req["url"],
data: req["data"], data: req["data"],
options: Options( options: Options(
method: req['http_method'], method: req['http_method'],

View File

@@ -97,6 +97,9 @@ class MyLogInterceptor implements Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15); options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15);

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
@@ -11,13 +12,14 @@ import 'package:venera/network/images.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
import 'file_downloader.dart';
abstract class DownloadTask with ChangeNotifier { abstract class DownloadTask with ChangeNotifier {
/// 0-1 /// 0-1
double get progress; double get progress;
bool get isComplete;
bool get isError; bool get isError;
bool get isPaused; bool get isPaused;
@@ -106,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
@override @override
String? get cover => _cover; String? get cover => _cover ?? comic?.cover;
@override
bool get isComplete => _totalCount == _downloadedCount;
@override @override
String get message => _message; String get message => _message;
@@ -159,7 +158,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var tasks = <int, _ImageDownloadWrapper>{}; var tasks = <int, _ImageDownloadWrapper>{};
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); int get _maxConcurrentTasks =>
(appdata.settings["downloadThreads"] as num).toInt();
void _scheduleTasks() { void _scheduleTasks() {
var images = _images![_images!.keys.elementAt(_chapter)]!; var images = _images![_images!.keys.elementAt(_chapter)]!;
@@ -268,7 +268,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var fileType = detectFileType(data); var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}")); var file = File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data); file.writeAsBytesSync(data);
return file.path; return "file://${file.path}";
}); });
if (res.error) { if (res.error) {
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
@@ -448,7 +448,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(), }).toList(),
directory: Directory(path!).name, directory: Directory(path!).name,
chapters: comic!.chapters, chapters: comic!.chapters,
cover: File(_cover!).uri.pathSegments.last, cover: File(_cover!.split("file://").last).uri.pathSegments.last,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -603,3 +603,217 @@ abstract mixin class _TransferSpeedMixin {
_bytesSinceLastSecond = 0; _bytesSinceLastSecond = 0;
} }
} }
class ArchiveDownloadTask extends DownloadTask {
final String archiveUrl;
final ComicDetails comic;
late ComicSource source;
/// Download comic by archive url
///
/// Currently only support zip file and comics without chapters
ArchiveDownloadTask(this.archiveUrl, this.comic) {
source = ComicSource.find(comic.sourceKey)!;
}
FileDownloader? _downloader;
String _message = "Fetching comic info...";
bool _isRunning = false;
bool _isError = false;
void _setError(String message) {
_isRunning = false;
_isError = true;
_message = message;
notifyListeners();
Log.error("Download", message);
}
@override
void cancel() async {
_isRunning = false;
await _downloader?.stop();
if (path != null) {
Directory(path!).deleteIgnoreError(recursive: true);
}
path = null;
LocalManager().removeTask(this);
}
@override
ComicType get comicType => ComicType(source.key.hashCode);
@override
String? get cover => comic.cover;
@override
String get id => comic.id;
@override
bool get isError => _isError;
@override
bool get isPaused => !_isRunning;
@override
String get message => _message;
int _currentBytes = 0;
int _expectedBytes = 0;
int _speed = 0;
@override
void pause() {
_isRunning = false;
_message = "Paused";
_downloader?.stop();
notifyListeners();
}
@override
double get progress =>
_expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes;
@override
void resume() async {
if (_isRunning) {
return;
}
_isError = false;
_isRunning = true;
notifyListeners();
_message = "Downloading...";
if (path == null) {
var dir = await LocalManager().findValidDirectory(
comic.id,
comicType,
comic.title,
);
if (!(await dir.exists())) {
try {
await dir.create();
} catch (e) {
_setError("Error: $e");
return;
}
}
path = dir.path;
}
var resultFile = File(FilePath.join(path!, "archive.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.path);
bool isDownloaded = false;
try {
await for (var status in _downloader!.start()) {
_currentBytes = status.downloadedBytes;
_expectedBytes = status.totalBytes;
_message =
"${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}";
_speed = status.bytesPerSecond;
isDownloaded = status.isFinished;
notifyListeners();
}
}
catch(e) {
_setError("Error: $e");
return;
}
if (!_isRunning) {
return;
}
if (!isDownloaded) {
_setError("Error: Download failed");
return;
}
try {
await extractArchive(path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await resultFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> extractArchive(String path) async {
var resultFile = FilePath.join(path, "archive.zip");
await Isolate.run(() {
ZipFile.openAndExtract(resultFile, path);
});
}
@override
int get speed => _speed;
@override
String get title => comic.title;
@override
Map<String, dynamic> toJson() {
return {
"type": "ArchiveDownloadTask",
"archiveUrl": archiveUrl,
"comic": comic.toJson(),
"path": path,
};
}
static ArchiveDownloadTask? fromJson(Map<String, dynamic> json) {
if (json["type"] != "ArchiveDownloadTask") {
return null;
}
return ArchiveDownloadTask(
json["archiveUrl"],
ComicDetails.fromJson(json["comic"]),
)..path = json["path"];
}
String _findCover() {
var files = Directory(path!).listSync();
for (var f in files) {
if (f.name.startsWith('cover')) {
return f.name;
}
}
files.sort((a, b) {
return a.name.compareTo(b.name);
});
return files.first.name;
}
@override
LocalComic toLocalComic() {
return LocalComic(
id: comic.id,
title: title,
subtitle: comic.subTitle ?? '',
tags: comic.tags.entries.expand((e) {
return e.value.map((v) => "${e.key}:$v");
}).toList(),
directory: Directory(path!).name,
chapters: null,
cover: _findCover(),
comicType: ComicType(source.key.hashCode),
downloadedChapters: [],
createdAt: DateTime.now(),
);
}
}

View File

@@ -0,0 +1,298 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/io.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/ext.dart';
class FileDownloader {
final String url;
final String savePath;
final int maxConcurrent;
FileDownloader(this.url, this.savePath, {this.maxConcurrent = 4});
int _currentBytes = 0;
int _lastBytes = 0;
late int _fileSize;
final _dio = Dio();
RandomAccessFile? _file;
bool _isWriting = false;
int _kChunkSize = 16 * 1024 * 1024;
bool _canceled = false;
late List<_DownloadBlock> _blocks;
Future<void> _writeStatus() async {
var file = File("$savePath.download");
await file.writeAsString(_blocks.map((e) => e.toString()).join("\n"));
}
Future<void> _readStatus() async {
var file = File("$savePath.download");
if (!await file.exists()) {
return;
}
var lines = await file.readAsLines();
_blocks = lines.map((e) => _DownloadBlock.fromString(e)).toList();
}
/// create file and write empty bytes
Future<void> _prepareFile() async {
var file = File(savePath);
if (await file.exists()) {
if (file.lengthSync() == _fileSize &&
File("$savePath.download").existsSync()) {
_file = await file.open(mode: FileMode.append);
return;
} else {
await file.delete();
}
}
await file.create(recursive: true);
_file = await file.open(mode: FileMode.append);
await _file!.truncate(_fileSize);
}
Future<void> _createTasks() async {
var res = await _dio.head(url);
var length = res.headers["content-length"]?.first;
_fileSize = length == null ? 0 : int.parse(length);
await _prepareFile();
if (File("$savePath.download").existsSync()) {
await _readStatus();
_currentBytes = _blocks.fold<int>(0,
(previousValue, element) => previousValue + element.downloadedBytes);
} else {
if (_fileSize > 1024 * 1024 * 1024) {
_kChunkSize = 64 * 1024 * 1024;
} else if (_fileSize > 512 * 1024 * 1024) {
_kChunkSize = 32 * 1024 * 1024;
}
_blocks = [];
for (var i = 0; i < _fileSize; i += _kChunkSize) {
var end = i + _kChunkSize;
if (end > _fileSize) {
_blocks.add(_DownloadBlock(i, _fileSize, 0, false));
} else {
_blocks.add(_DownloadBlock(i, i + _kChunkSize, 0, false));
}
}
}
}
Stream<DownloadingStatus> start() {
var stream = StreamController<DownloadingStatus>();
_download(stream);
return stream.stream;
}
void _reportStatus(StreamController<DownloadingStatus> stream) {
stream.add(DownloadingStatus(_currentBytes, _fileSize, 0));
}
void _download(StreamController<DownloadingStatus> resultStream) async {
try {
var proxy = await AppDio.getProxy();
_dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
);
// get file size
await _createTasks();
if (_canceled) return;
// check if file is downloaded
if (_currentBytes >= _fileSize) {
await _file!.close();
_file = null;
_reportStatus(resultStream);
resultStream.close();
return;
}
_reportStatus(resultStream);
Timer.periodic(const Duration(seconds: 1), (timer) {
if (_canceled || _currentBytes >= _fileSize) {
timer.cancel();
return;
}
resultStream.add(DownloadingStatus(
_currentBytes, _fileSize, _currentBytes - _lastBytes));
_lastBytes = _currentBytes;
});
// start downloading
await _scheduleDownload();
if (_canceled) {
resultStream.close();
return;
}
await _file!.close();
_file = null;
await File("$savePath.download").delete();
// check if download is finished
if (_currentBytes < _fileSize) {
resultStream
.addError(Exception("Download failed: Expected $_fileSize bytes, "
"but only $_currentBytes bytes downloaded."));
resultStream.close();
}
resultStream.add(DownloadingStatus(_currentBytes, _fileSize, 0, true));
resultStream.close();
} catch (e, s) {
await _file?.close();
_file = null;
resultStream.addError(e, s);
resultStream.close();
}
}
Future<void> _scheduleDownload() async {
var tasks = <Future>[];
while (true) {
if (_canceled) return;
if (tasks.length >= maxConcurrent) {
await Future.any(tasks);
}
final block = _blocks.firstWhereOrNull((element) =>
!element.downloading &&
element.end - element.start > element.downloadedBytes);
if (block == null) {
break;
}
block.downloading = true;
var task = _fetchBlock(block);
task.then((value) => tasks.remove(task), onError: (e) {
if(_canceled) return;
throw e;
});
tasks.add(task);
}
await Future.wait(tasks);
}
Future<void> _fetchBlock(_DownloadBlock block) async {
final start = block.start;
final end = block.end;
if (start > _fileSize) {
return;
}
var options = Options(
responseType: ResponseType.stream,
headers: {
"Range": "bytes=${start + block.downloadedBytes}-${end - 1}",
"Accept": "*/*",
"Accept-Encoding": "deflate, gzip",
},
preserveHeaderCase: true,
);
var res = await _dio.get<ResponseBody>(url, options: options);
if (_canceled) return;
if (res.data == null) {
throw Exception("Failed to block $start-$end");
}
var buffer = <int>[];
await for (var data in res.data!.stream) {
if (_canceled) return;
buffer.addAll(data);
if (buffer.length > 16 * 1024) {
if (_isWriting) continue;
_currentBytes += buffer.length;
_isWriting = true;
await _file!.setPosition(start + block.downloadedBytes);
await _file!.writeFrom(buffer);
block.downloadedBytes += buffer.length;
buffer.clear();
await _writeStatus();
_isWriting = false;
}
}
if (buffer.isNotEmpty) {
while (_isWriting) {
await Future.delayed(const Duration(milliseconds: 10));
}
_isWriting = true;
_currentBytes += buffer.length;
await _file!.setPosition(start + block.downloadedBytes);
await _file!.writeFrom(buffer);
block.downloadedBytes += buffer.length;
await _writeStatus();
_isWriting = false;
}
block.downloading = false;
}
Future<void> stop() async {
_canceled = true;
await _file?.close();
_file = null;
}
}
class DownloadingStatus {
/// The current downloaded bytes
final int downloadedBytes;
/// The total bytes of the file
final int totalBytes;
/// Whether the download is finished
final bool isFinished;
/// The download speed in bytes per second
final int bytesPerSecond;
const DownloadingStatus(
this.downloadedBytes, this.totalBytes, this.bytesPerSecond,
[this.isFinished = false]);
@override
String toString() {
return "Downloaded: $downloadedBytes/$totalBytes ${isFinished ? "Finished" : ""}";
}
}
class _DownloadBlock {
final int start;
final int end;
int downloadedBytes;
bool downloading;
_DownloadBlock(this.start, this.end, this.downloadedBytes, this.downloading);
@override
String toString() {
return "$start-$end-$downloadedBytes";
}
_DownloadBlock.fromString(String str)
: start = int.parse(str.split("-")[0]),
end = int.parse(str.split("-")[1]),
downloadedBytes = int.parse(str.split("-")[2]),
downloading = false;
}

View File

@@ -682,6 +682,122 @@ abstract mixin class _ComicPageActions {
App.rootContext.showMessage(message: "The comic is downloaded".tl); App.rootContext.showMessage(message: "The comic is downloaded".tl);
return; return;
} }
if (comicSource.archiveDownloader != null) {
bool useNormalDownload = false;
List<ArchiveInfo>? archives;
int selected = -1;
bool isLoading = false;
bool isGettingLink = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: "Download".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(
value: -1,
groupValue: selected,
title: Text("Normal".tl),
onChanged: (v) {
setState(() {
selected = v!;
});
},
),
ExpansionTile(
title: Text("Archive".tl),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
onExpansionChanged: (b) {
if (!isLoading && b && archives == null) {
isLoading = true;
comicSource.archiveDownloader!
.getArchives(comic.id)
.then((value) {
if (value.success) {
archives = value.data;
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
}
setState(() {
isLoading = false;
});
});
}
},
children: [
if (archives == null)
const ListLoadingIndicator().toCenter()
else
for (int i = 0; i < archives!.length; i++)
RadioListTile<int>(
value: i,
groupValue: selected,
onChanged: (v) {
setState(() {
selected = v!;
});
},
title: Text(archives![i].title),
subtitle: Text(archives![i].description),
)
],
)
],
),
actions: [
Button.filled(
isLoading: isGettingLink,
onPressed: () async {
if (selected == -1) {
useNormalDownload = true;
context.pop();
return;
}
setState(() {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
setState(() {
isGettingLink = false;
});
} else if (context.mounted) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext
.showMessage(message: "Download started".tl);
context.pop();
}
},
child: Text("Confirm".tl),
),
],
);
},
);
},
);
if (!useNormalDownload) {
return;
}
}
if (comic.chapters == null) { if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask( LocalManager().addTask(ImagesDownloadTask(
source: comicSource, source: comicSource,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -161,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: widget.task.cover == null child: widget.task.cover == null
? null ? null
: Image.file( : Image(
File(widget.task.cover!), image: CachedImageProvider(widget.task.cover!),
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
@@ -206,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
Text( Text(
widget.task.message, widget.task.message,
style: ts.s12, style: ts.s12,
maxLines: 3,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator( LinearProgressIndicator(