diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 9f9ee4c..08a95bc 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -154,8 +154,6 @@ class ComicTile extends StatelessWidget { ImageProvider image; if (comic is LocalComic) { image = FileImage((comic as LocalComic).coverFile); - } else if (comic.cover.startsWith('file://')) { - image = FileImage(File(comic.cover.substring(7))); } else if (comic.sourceKey == 'local') { var localComic = LocalManager().find(comic.id, ComicType.local); if (localComic == null) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index a537918..7854b73 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -215,6 +215,8 @@ class ComicSource { final StarRatingFunc? starRatingFunc; + final ArchiveDownloader? archiveDownloader; + Future loadData() async { var file = File("${App.dataPath}/comic_source/$key.data"); if (await file.exists()) { @@ -284,6 +286,7 @@ class ComicSource { this.enableTagsSuggestions, this.enableTagsTranslate, this.starRatingFunc, + this.archiveDownloader, ); } @@ -465,3 +468,11 @@ class LinkHandler { const LinkHandler(this.domains, this.linkToId); } + +class ArchiveDownloader { + final Future>> Function(String cid) getArchives; + + final Future> Function(String cid, String aid) getDownloadUrl; + + const ArchiveDownloader(this.getArchives, this.getDownloadUrl); +} \ No newline at end of file diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 80843d0..9155765 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -232,3 +232,14 @@ class ComicDetails with HistoryMixin { ComicType get comicType => ComicType(sourceKey.hashCode); } + +class ArchiveInfo { + final String title; + final String description; + final String id; + + ArchiveInfo.fromJson(Map json) + : title = json["title"], + description = json["description"], + id = json["id"]; +} \ No newline at end of file diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 23b8d37..65019f4 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -153,11 +153,12 @@ class ComicSourceParser { _getValue("search.enableTagsSuggestions") ?? false, _getValue("comic.enableTagsTranslate") ?? false, _parseStarRatingFunc(), + _parseArchiveDownloader(), ); await source.loadData(); - if(_checkExists("init")) { + if (_checkExists("init")) { Future.delayed(const Duration(milliseconds: 50), () { JsEngine().runCode("ComicSource.sources.$_key.init()"); }); @@ -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()); + } + }, + ); + } } diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index 90dbbf0..daf1338 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -1,4 +1,5 @@ import 'dart:async' show Future, StreamController; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/network/images.dart'; @@ -8,6 +9,8 @@ import 'cached_image.dart' as image_provider; class CachedImageProvider extends BaseImageProvider { /// 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}); final String url; @@ -20,6 +23,10 @@ class CachedImageProvider @override Future load(StreamController 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)) { chunkEvents.add(ImageChunkEvent( cumulativeBytesLoaded: progress.currentBytes, diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 88eae18..206ffa2 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; +import 'package:dio/io.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; @@ -184,7 +185,23 @@ class JsEngine with _JSEngineApi { if (headers["user-agent"] == null && headers["User-Agent"] == null) { 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"], options: Options( method: req['http_method'], diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 233b932..45eca7d 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -97,6 +97,9 @@ 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}"); options.connectTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15); diff --git a/lib/network/download.dart b/lib/network/download.dart index e68fa26..1d0683e 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:isolate'; import 'package:flutter/widgets.dart' show ChangeNotifier; 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/file_type.dart'; import 'package:venera/utils/io.dart'; +import 'package:zip_flutter/zip_flutter.dart'; + +import 'file_downloader.dart'; abstract class DownloadTask with ChangeNotifier { /// 0-1 double get progress; - bool get isComplete; - bool get isError; bool get isPaused; @@ -106,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { } @override - String? get cover => _cover; - - @override - bool get isComplete => _totalCount == _downloadedCount; + String? get cover => _cover ?? comic?.cover; @override String get message => _message; @@ -159,7 +158,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { var tasks = {}; - int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); + int get _maxConcurrentTasks => + (appdata.settings["downloadThreads"] as num).toInt(); void _scheduleTasks() { var images = _images![_images!.keys.elementAt(_chapter)]!; @@ -268,7 +268,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { var fileType = detectFileType(data); var file = File(FilePath.join(path!, "cover${fileType.ext}")); file.writeAsBytesSync(data); - return file.path; + return "file://${file.path}"; }); if (res.error) { _setError("Error: ${res.errorMessage}"); @@ -382,7 +382,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { int get speed => currentSpeed; @override - String get title => comic?.title ?? comicTitle ?? "Loading..."; + String get title => comic?.title ?? comicTitle ?? "Loading..."; @override Map toJson() { @@ -448,7 +448,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { }).toList(), directory: Directory(path!).name, chapters: comic!.chapters, - cover: File(_cover!).uri.pathSegments.last, + cover: File(_cover!.split("file://").last).uri.pathSegments.last, comicType: ComicType(source.key.hashCode), downloadedChapters: chapters ?? [], createdAt: DateTime.now(), @@ -577,7 +577,7 @@ abstract mixin class _TransferSpeedMixin { void onData(int length) { if (timer == null) return; - if(length < 0) { + if (length < 0) { return; } _bytesSinceLastSecond += length; @@ -603,3 +603,217 @@ abstract mixin class _TransferSpeedMixin { _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 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 toJson() { + return { + "type": "ArchiveDownloadTask", + "archiveUrl": archiveUrl, + "comic": comic.toJson(), + "path": path, + }; + } + + static ArchiveDownloadTask? fromJson(Map 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(), + ); + } +} diff --git a/lib/network/file_downloader.dart b/lib/network/file_downloader.dart new file mode 100644 index 0000000..3d91643 --- /dev/null +++ b/lib/network/file_downloader.dart @@ -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 _writeStatus() async { + var file = File("$savePath.download"); + await file.writeAsString(_blocks.map((e) => e.toString()).join("\n")); + } + + Future _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 _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 _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(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 start() { + var stream = StreamController(); + _download(stream); + return stream.stream; + } + + void _reportStatus(StreamController stream) { + stream.add(DownloadingStatus(_currentBytes, _fileSize, 0)); + } + + void _download(StreamController 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 _scheduleDownload() async { + var tasks = []; + 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 _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(url, options: options); + if (_canceled) return; + if (res.data == null) { + throw Exception("Failed to block $start-$end"); + } + + var buffer = []; + 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 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; +} diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 6e8f68a..b17906e 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -682,6 +682,122 @@ abstract mixin class _ComicPageActions { App.rootContext.showMessage(message: "The comic is downloaded".tl); return; } + + if (comicSource.archiveDownloader != null) { + bool useNormalDownload = false; + List? 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( + 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( + 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) { LocalManager().addTask(ImagesDownloadTask( source: comicSource, diff --git a/lib/pages/downloading_page.dart b/lib/pages/downloading_page.dart index f066184..790e482 100644 --- a/lib/pages/downloading_page.dart +++ b/lib/pages/downloading_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:venera/components/components.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/network/download.dart'; import 'package:venera/utils/io.dart'; @@ -161,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> { clipBehavior: Clip.antiAlias, child: widget.task.cover == null ? null - : Image.file( - File(widget.task.cover!), + : Image( + image: CachedImageProvider(widget.task.cover!), filterQuality: FilterQuality.medium, fit: BoxFit.cover, ), @@ -206,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> { Text( widget.task.message, style: ts.s12, + maxLines: 3, ), const SizedBox(height: 4), LinearProgressIndicator(