download & view local comics

This commit is contained in:
nyne
2024-10-14 11:10:24 +08:00
parent 5a3537657a
commit c0a0dc59e1
20 changed files with 1467 additions and 158 deletions

View File

@@ -165,19 +165,24 @@ class _ButtonState extends State<Button> {
if (height != null) { if (height != null) {
height = height - padding.vertical; height = height - padding.vertical;
} }
Widget child = DefaultTextStyle( Widget child = IconTheme(
style: TextStyle( data: IconThemeData(
color: textColor, color: textColor,
fontSize: 16,
), ),
child: isLoading child: DefaultTextStyle(
? CircularProgressIndicator( style: TextStyle(
color: widget.type == ButtonType.filled color: textColor,
? context.colorScheme.inversePrimary fontSize: 16,
: context.colorScheme.primary, ),
strokeWidth: 1.8, child: isLoading
).fixWidth(16).fixHeight(16) ? CircularProgressIndicator(
: widget.child, color: widget.type == ButtonType.filled
? context.colorScheme.inversePrimary
: context.colorScheme.primary,
strokeWidth: 1.8,
).fixWidth(16).fixHeight(16)
: widget.child,
),
); );
if (width != null || height != null) { if (width != null || height != null) {
child = child.toCenter(); child = child.toCenter();
@@ -255,7 +260,7 @@ class _ButtonState extends State<Button> {
Color get textColor { Color get textColor {
if (widget.type == ButtonType.outlined) { if (widget.type == ButtonType.outlined) {
return widget.color ?? context.colorScheme.onSurface; return widget.color ?? context.colorScheme.primary;
} }
return widget.type == ButtonType.filled return widget.type == ButtonType.filled
? context.colorScheme.onPrimary ? context.colorScheme.onPrimary

View File

@@ -7,6 +7,7 @@ class ComicTile extends StatelessWidget {
this.enableLongPressed = true, this.enableLongPressed = true,
this.badge, this.badge,
this.menuOptions, this.menuOptions,
this.onTap,
}); });
final Comic comic; final Comic comic;
@@ -17,7 +18,13 @@ class ComicTile extends StatelessWidget {
final List<MenuEntry>? menuOptions; final List<MenuEntry>? menuOptions;
void onTap() { final VoidCallback? onTap;
void _onTap() {
if(onTap != null) {
onTap!();
return;
}
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
} }
@@ -43,7 +50,7 @@ class ComicTile extends StatelessWidget {
MenuEntry( MenuEntry(
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl, text: 'Details'.tl,
onClick: onTap, onClick: _onTap,
), ),
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
@@ -155,7 +162,7 @@ class ComicTile extends StatelessWidget {
final height = constrains.maxHeight - 16; final height = constrains.maxHeight - 16;
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: onTap, onTap: _onTap,
onLongPress: enableLongPressed ? () => onLongPress(context) : null, onLongPress: enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: onSecondaryTap, onSecondaryTapDown: onSecondaryTap,
child: Padding( child: Padding(
@@ -250,7 +257,7 @@ class ComicTile extends StatelessWidget {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: onTap, onTap: _onTap,
onLongPress: onLongPress:
enableLongPressed ? () => onLongPress(context) : null, enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: onSecondaryTap, onSecondaryTapDown: onSecondaryTap,
@@ -464,6 +471,7 @@ class SliverGridComics extends StatelessWidget {
this.onLastItemBuild, this.onLastItemBuild,
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap,
}); });
final List<Comic> comics; final List<Comic> comics;
@@ -474,6 +482,8 @@ class SliverGridComics extends StatelessWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; final List<MenuEntry> Function(Comic)? menuBuilder;
final void Function(Comic)? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StateBuilder<SliverGridComicsController>( return StateBuilder<SliverGridComicsController>(
@@ -490,6 +500,7 @@ class SliverGridComics extends StatelessWidget {
onLastItemBuild: onLastItemBuild, onLastItemBuild: onLastItemBuild,
badgeBuilder: badgeBuilder, badgeBuilder: badgeBuilder,
menuBuilder: menuBuilder, menuBuilder: menuBuilder,
onTap: onTap,
); );
}, },
); );
@@ -502,6 +513,7 @@ class _SliverGridComics extends StatelessWidget {
this.onLastItemBuild, this.onLastItemBuild,
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap,
}); });
final List<Comic> comics; final List<Comic> comics;
@@ -512,6 +524,8 @@ class _SliverGridComics extends StatelessWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; final List<MenuEntry> Function(Comic)? menuBuilder;
final void Function(Comic)? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverGrid( return SliverGrid(
@@ -525,6 +539,7 @@ class _SliverGridComics extends StatelessWidget {
comic: comics[index], comic: comics[index],
badge: badge, badge: badge,
menuOptions: menuBuilder?.call(comics[index]), menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null,
); );
}, },
childCount: comics.length, childCount: comics.length,

View File

@@ -95,7 +95,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
Future<Res<S>> loadData(); Future<Res<S>> loadData();
void onDataLoaded() {} FutureOr<void> onDataLoaded() {}
Widget buildContent(BuildContext context, S data); Widget buildContent(BuildContext context, S data);
@@ -114,13 +114,13 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
isLoading = true; isLoading = true;
error = null; error = null;
}); });
loadData().then((value) { loadData().then((value) async {
if (value.success) { if (value.success) {
await onDataLoaded();
setState(() { setState(() {
isLoading = false; isLoading = false;
data = value.data; data = value.data;
}); });
onDataLoaded();
} else { } else {
setState(() { setState(() {
isLoading = false; isLoading = false;

View File

@@ -54,8 +54,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Material( child: Material(
color: context.brightness == Brightness.light color: context.brightness == Brightness.light
? const Color(0xFFFAFAFA).withOpacity(0.72) ? const Color(0xFFFAFAFA).withOpacity(0.82)
: const Color(0xFF090909).withOpacity(0.72), : const Color(0xFF090909).withOpacity(0.82),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Container( child: Container(
width: width, width: width,

View File

@@ -86,7 +86,7 @@ class PopupIndicatorWidget extends InheritedWidget {
} }
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async { Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
return await Navigator.of(context).push(PopUpWidget(widget)); return await Navigator.of(context, rootNavigator: true).push(PopUpWidget(widget));
} }
class PopUpWidgetScaffold extends StatefulWidget { class PopUpWidgetScaffold extends StatefulWidget {
@@ -124,7 +124,7 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
width: 8, width: 8,
), ),
Tooltip( Tooltip(
message: "返回".tl, message: "Back".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back_sharp), icon: const Icon(Icons.arrow_back_sharp),
onPressed: () => context.canPop() onPressed: () => context.canPop()

View File

@@ -203,13 +203,13 @@ class _SidebarBodyState extends State<SidebarBody> {
} }
} }
void showSideBar(BuildContext context, Widget widget, Future<void> showSideBar(BuildContext context, Widget widget,
{String? title, {String? title,
bool showBarrier = true, bool showBarrier = true,
bool useSurfaceTintColor = false, bool useSurfaceTintColor = false,
double width = 500, double width = 500,
bool addTopPadding = false}) { bool addTopPadding = false}) {
Navigator.of(context).push( return Navigator.of(context).push(
SideBarRoute( SideBarRoute(
title, title,
widget, widget,

View File

@@ -139,6 +139,29 @@ class ComicDetails with HistoryMixin {
uploadTime = json["uploadTime"], uploadTime = json["uploadTime"],
updateTime = json["updateTime"]; updateTime = json["updateTime"];
Map<String, dynamic> toJson() {
return {
"title": title,
"subTitle": subTitle,
"cover": cover,
"description": description,
"tags": tags,
"chapters": chapters,
"thumbnails": thumbnails,
"recommend": null,
"sourceKey": sourceKey,
"comicId": comicId,
"isFavorite": isFavorite,
"subId": subId,
"isLiked": isLiked,
"likesCount": likesCount,
"commentsCount": commentsCount,
"uploader": uploader,
"uploadTime": uploadTime,
"updateTime": updateTime,
};
}
@override @override
HistoryType get historyType => HistoryType(sourceKey.hashCode); HistoryType get historyType => HistoryType(sourceKey.hashCode);
@@ -146,4 +169,4 @@ class ComicDetails with HistoryMixin {
String get id => comicId; String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode); ComicType get comicType => ComicType(sourceKey.hashCode);
} }

View File

@@ -1,10 +1,7 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future, StreamController;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/network/images.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/network/app_dio.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'cached_image.dart' as image_provider; import 'cached_image.dart' as image_provider;
@@ -21,52 +18,16 @@ class CachedImageProvider
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
final cacheKey = "$url@$sourceKey"; await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
final cache = await CacheManager().findCache(cacheKey); chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
if (cache != null) { expectedTotalBytes: progress.totalBytes,
return await cache.readAsBytes(); ));
} if(progress.imageBytes != null) {
return progress.imageBytes!;
var configs = <String, dynamic>{};
if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey!);
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
}
configs['headers'] ??= {
'user-agent': webUA,
};
var dio = AppDio(BaseOptions(
headers: 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) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: buffer.length,
expectedTotalBytes: expectedBytes,
));
} }
} }
throw "Error: Empty response body.";
if(configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer);
}
await CacheManager().writeCache(cacheKey, buffer);
return Uint8List.fromList(buffer);
} }
@override @override

View File

@@ -1,10 +1,7 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future, StreamController;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/network/images.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/network/app_dio.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'reader_image.dart' as image_provider; import 'reader_image.dart' as image_provider;
@@ -23,52 +20,17 @@ class ReaderImageProvider
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; await for (var event
final cache = await CacheManager().findCache(cacheKey); in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
chunkEvents.add(ImageChunkEvent(
if (cache != null) { cumulativeBytesLoaded: event.currentBytes,
return await cache.readAsBytes(); expectedTotalBytes: event.totalBytes,
} ));
if (event.imageBytes != null) {
var configs = <String, dynamic>{}; return event.imageBytes!;
if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey!);
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {};
}
configs['headers'] ??= {
'user-agent': webUA,
};
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);
if (expectedBytes != null) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: buffer.length,
expectedTotalBytes: expectedBytes,
));
} }
} }
throw "Error: Empty response body.";
if(configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer);
}
await CacheManager().writeCache(cacheKey, buffer);
return Uint8List.fromList(buffer);
} }
@override @override

View File

@@ -1,16 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'app.dart'; import 'app.dart';
import 'history.dart';
class LocalComic implements Comic { class LocalComic with HistoryMixin implements Comic {
@override @override
final String id; final String id;
@@ -37,6 +39,8 @@ class LocalComic implements Comic {
final ComicType comicType; final ComicType comicType;
final List<String> downloadedChapters;
final DateTime createdAt; final DateTime createdAt;
const LocalComic({ const LocalComic({
@@ -48,6 +52,7 @@ class LocalComic implements Comic {
required this.chapters, required this.chapters,
required this.cover, required this.cover,
required this.comicType, required this.comicType,
required this.downloadedChapters,
required this.createdAt, required this.createdAt,
}); });
@@ -60,9 +65,14 @@ class LocalComic implements Comic {
chapters = Map.from(jsonDecode(row[5] as String)), chapters = Map.from(jsonDecode(row[5] as String)),
cover = row[6] as String, cover = row[6] as String,
comicType = ComicType(row[7] as int), comicType = ComicType(row[7] as int),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int); downloadedChapters = List.from(jsonDecode(row[8] as String)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => File('${LocalManager().path}/$directory/$cover'); File get coverFile => File(FilePath.join(
LocalManager().path,
directory,
cover,
));
@override @override
String get description => ""; String get description => "";
@@ -85,6 +95,31 @@ class LocalComic implements Comic {
@override @override
int? get maxPage => null; int? get maxPage => null;
void read() async {
var history = await HistoryManager().find(id, comicType);
App.rootContext.to(
() => Reader(
type: comicType,
cid: id,
name: title,
chapters: chapters,
initialChapter: history?.ep,
initialPage: history?.page,
history: history ?? History.fromModel(
model: this,
ep: 0,
page: 0,
),
),
);
}
@override
HistoryType get historyType => comicType;
@override
String? get subTitle => subtitle;
} }
class LocalManager with ChangeNotifier { class LocalManager with ChangeNotifier {
@@ -103,10 +138,10 @@ class LocalManager with ChangeNotifier {
// return error message if failed // return error message if failed
Future<String?> setNewPath(String newPath) async { Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath); var newDir = Directory(newPath);
if(!await newDir.exists()) { if (!await newDir.exists()) {
return "Directory does not exist"; return "Directory does not exist";
} }
if(!await newDir.list().isEmpty) { if (!await newDir.list().isEmpty) {
return "Directory is not empty"; return "Directory is not empty";
} }
try { try {
@@ -137,6 +172,7 @@ class LocalManager with ChangeNotifier {
chapters TEXT NOT NULL, chapters TEXT NOT NULL,
cover TEXT NOT NULL, cover TEXT NOT NULL,
comic_type INTEGER NOT NULL, comic_type INTEGER NOT NULL,
downloadedChapters TEXT NOT NULL,
created_at INTEGER, created_at INTEGER,
PRIMARY KEY (id, comic_type) PRIMARY KEY (id, comic_type)
); );
@@ -147,17 +183,18 @@ class LocalManager with ChangeNotifier {
if (App.isAndroid) { if (App.isAndroid) {
var external = await getExternalStorageDirectories(); var external = await getExternalStorageDirectories();
if (external != null && external.isNotEmpty) { if (external != null && external.isNotEmpty) {
path = FilePath.join(external.first.path, 'local_path'); path = FilePath.join(external.first.path, 'local');
} else { } else {
path = FilePath.join(App.dataPath, 'local_path'); path = FilePath.join(App.dataPath, 'local');
} }
} else { } else {
path = FilePath.join(App.dataPath, 'local_path'); path = FilePath.join(App.dataPath, 'local');
} }
} }
if (!Directory(path).existsSync()) { if (!Directory(path).existsSync()) {
await Directory(path).create(); await Directory(path).create();
} }
restoreDownloadingTasks();
} }
String findValidId(ComicType type) { String findValidId(ComicType type) {
@@ -175,8 +212,13 @@ class LocalManager with ChangeNotifier {
} }
Future<void> add(LocalComic comic, [String? id]) async { Future<void> add(LocalComic comic, [String? id]) async {
var old = find(id ?? comic.id, comic.comicType);
var downloaded = comic.downloadedChapters;
if (old != null) {
downloaded.addAll(old.downloadedChapters);
}
_db.execute( _db.execute(
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);', 'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
[ [
id ?? comic.id, id ?? comic.id,
comic.title, comic.title,
@@ -186,6 +228,7 @@ class LocalManager with ChangeNotifier {
jsonEncode(comic.chapters), jsonEncode(comic.chapters),
comic.cover, comic.cover,
comic.comicType.value, comic.comicType.value,
jsonEncode(downloaded),
comic.createdAt.millisecondsSinceEpoch, comic.createdAt.millisecondsSinceEpoch,
], ],
); );
@@ -274,7 +317,7 @@ class LocalManager with ChangeNotifier {
files.sort((a, b) { files.sort((a, b) {
var ai = int.tryParse(a.name.split('.').first); var ai = int.tryParse(a.name.split('.').first);
var bi = int.tryParse(b.name.split('.').first); var bi = int.tryParse(b.name.split('.').first);
if(ai != null && bi != null) { if (ai != null && bi != null) {
return ai.compareTo(bi); return ai.compareTo(bi);
} }
return a.name.compareTo(b.name); return a.name.compareTo(b.name);
@@ -284,9 +327,80 @@ class LocalManager with ChangeNotifier {
Future<bool> isDownloaded(String id, ComicType type, int ep) async { Future<bool> isDownloaded(String id, ComicType type, int ep) async {
var comic = find(id, type); var comic = find(id, type);
if(comic == null) return false; if (comic == null) return false;
if(comic.chapters == null) return true; if (comic.chapters == null) return true;
var eid = comic.chapters!.keys.elementAt(ep); return comic.downloadedChapters
return Directory(FilePath.join(path, comic.directory, eid)).exists(); .contains(comic.chapters!.keys.elementAt(ep));
}
List<DownloadTask> downloadingTasks = [];
bool isDownloading(String id, ComicType type) {
return downloadingTasks
.any((element) => element.id == id && element.comicType == type);
}
Future<Directory> findValidDirectory(
String id, ComicType type, String name) async {
var comic = find(id, type);
if (comic != null) {
return Directory(FilePath.join(path, comic.directory));
}
var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value);
}
void completeTask(DownloadTask task) {
add(task.toLocalComic());
downloadingTasks.remove(task);
notifyListeners();
saveCurrentDownloadingTasks();
downloadingTasks.firstOrNull?.resume();
}
void removeTask(DownloadTask task) {
downloadingTasks.remove(task);
notifyListeners();
saveCurrentDownloadingTasks();
}
void moveToFirst(DownloadTask task) {
if (downloadingTasks.first != task) {
var shouldResume = !downloadingTasks.first.isPaused;
downloadingTasks.first.pause();
downloadingTasks.remove(task);
downloadingTasks.insert(0, task);
notifyListeners();
saveCurrentDownloadingTasks();
if (shouldResume) {
downloadingTasks.first.resume();
}
}
}
Future<void> saveCurrentDownloadingTasks() async {
var tasks = downloadingTasks.map((e) => e.toJson()).toList();
await File(FilePath.join(App.dataPath, 'downloading_tasks.json'))
.writeAsString(jsonEncode(tasks));
}
void restoreDownloadingTasks() {
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
if (file.existsSync()) {
var tasks = jsonDecode(file.readAsStringSync());
for (var e in tasks) {
var task = DownloadTask.fromJson(e);
if (task != null) {
downloadingTasks.add(task);
}
}
}
}
void addTask(DownloadTask task) {
downloadingTasks.add(task);
notifyListeners();
saveCurrentDownloadingTasks();
downloadingTasks.first.resume();
} }
} }

View File

@@ -82,7 +82,10 @@ class Log {
addLog(LogLevel.warning, title, content); addLog(LogLevel.warning, title, content);
} }
static error(String title, String content) { static error(String title, String content, [Object? stackTrace]) {
if(stackTrace != null) {
content += "\n${stackTrace.toString()}";
}
addLog(LogLevel.error, title, content); addLog(LogLevel.error, title, content);
} }

View File

@@ -15,10 +15,10 @@ Future<void> init() async {
await appdata.init(); await appdata.init();
await App.init(); await App.init();
await HistoryManager().init(); await HistoryManager().init();
await LocalManager().init();
await LocalFavoritesManager().init(); await LocalFavoritesManager().init();
SingleInstanceCookieJar("${App.dataPath}/cookie.db"); SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init(); await JsEngine().init();
await ComicSource.init(); await ComicSource.init();
await LocalManager().init();
CacheManager(); CacheManager();
} }

View File

@@ -1,14 +1,27 @@
import 'dart:async';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
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';
abstract class DownloadTask with ChangeNotifier { abstract class DownloadTask with ChangeNotifier {
int get current; /// 0-1
double get progress;
int get total; bool get isComplete;
double get progress => current / total; bool get isError;
bool get isComplete => current == total; bool get isPaused;
/// bytes per second
int get speed; int get speed;
void cancel(); void cancel();
@@ -20,4 +33,549 @@ abstract class DownloadTask with ChangeNotifier {
String get title; String get title;
String? get cover; String? get cover;
}
String get message;
/// root path for the comic. If null, the task is not scheduled.
String? path;
/// convert current state to json, which can be used to restore the task
Map<String, dynamic> toJson();
LocalComic toLocalComic();
String get id;
ComicType get comicType;
static DownloadTask? fromJson(Map<String, dynamic> json) {
switch (json["type"]) {
case "ImagesDownloadTask":
return ImagesDownloadTask.fromJson(json);
default:
return null;
}
}
}
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
final ComicSource source;
final String comicId;
/// comic details. If null, the comic details will be fetched from the source.
ComicDetails? comic;
/// chapters to download. If null, all chapters will be downloaded.
final List<String>? chapters;
@override
String get id => comicId;
@override
ComicType get comicType => ComicType(source.key.hashCode);
ImagesDownloadTask({
required this.source,
required this.comicId,
this.comic,
this.chapters,
});
@override
void cancel() {
_isRunning = false;
LocalManager().removeTask(this);
var local = LocalManager().find(id, comicType);
if (path != null) {
if (local == null) {
Directory(path!).deleteIgnoreError();
} else if (chapters != null) {
for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c));
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
}
}
}
}
@override
String? get cover => _cover;
@override
bool get isComplete => _totalCount == _downloadedCount;
@override
String get message => _message;
@override
void pause() {
if (isPaused) {
return;
}
_isRunning = false;
_message = "Paused";
_currentSpeed = 0;
var shouldMove = <int>[];
for (var entry in tasks.entries) {
if (!entry.value.isComplete) {
entry.value.cancel();
shouldMove.add(entry.key);
}
}
for (var i in shouldMove) {
tasks.remove(i);
}
stopRecorder();
notifyListeners();
}
@override
double get progress => _totalCount == 0 ? 0 : _downloadedCount / _totalCount;
bool _isRunning = false;
bool _isError = false;
String _message = "Fetching comic info...";
String? _cover;
Map<String, List<String>>? _images;
int _downloadedCount = 0;
int _totalCount = 0;
int _index = 0;
int _chapter = 0;
var tasks = <int, _ImageDownloadWrapper>{};
int get _maxConcurrentTasks => 5;
void _scheduleTasks() {
var images = _images![_images!.keys.elementAt(_chapter)]!;
var downloading = 0;
for (var i = _index; i < images.length; i++) {
if (downloading >= _maxConcurrentTasks) {
return;
}
if (tasks[i] != null) {
if (!tasks[i]!.isComplete) {
downloading++;
}
if (tasks[i]!.error == null) {
continue;
}
}
Directory saveTo;
if (comic!.chapters != null) {
saveTo = Directory(FilePath.join(
path!,
comic!.chapters!.keys.elementAt(_chapter),
));
if (!saveTo.existsSync()) {
saveTo.createSync();
}
} else {
saveTo = Directory(path!);
}
var task = _ImageDownloadWrapper(
this,
_images!.keys.elementAt(_chapter),
images[i],
saveTo,
i,
);
tasks[i] = task;
task.wait().then((task) {
if (task.isComplete) {
_scheduleTasks();
}
});
}
}
@override
void resume() async {
if (_isRunning) return;
_isRunning = true;
notifyListeners();
runRecorder();
if (comic == null) {
var res = await runWithRetry(() async {
var r = await source.loadComicInfo!(comicId);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
comic = res.data;
}
}
if (path == null) {
var dir = await LocalManager().findValidDirectory(
comicId,
comicType,
comic!.title,
);
if (!(await dir.exists())) {
try {
await dir.create();
} catch (e) {
_setError("Error: $e");
return;
}
}
path = dir.path;
}
await LocalManager().saveCurrentDownloadingTasks();
if (cover == null) {
var res = await runWithRetry(() async {
Uint8List? data;
await for (var progress
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
if (progress.imageBytes != null) {
data = progress.imageBytes;
}
}
if (data == null) {
throw "Failed to download cover";
}
var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data);
return file.path;
});
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_cover = res.data;
notifyListeners();
}
await LocalManager().saveCurrentDownloadingTasks();
}
if (_images == null) {
if (comic!.chapters == null) {
var res = await runWithRetry(() async {
var r = await source.loadComicPages!(comicId, null);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_images = {'': res.data};
_totalCount = _images!['']!.length;
}
} else {
_images = {};
_totalCount = 0;
for (var i in comic!.chapters!.keys) {
if (chapters != null && !chapters!.contains(i)) {
continue;
}
if (_images![i] != null) {
_totalCount += _images![i]!.length;
continue;
}
var res = await runWithRetry(() async {
var r = await source.loadComicPages!(comicId, i);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_images![i] = res.data;
_totalCount += _images![i]!.length;
}
}
}
_message = "$_downloadedCount/$_totalCount";
notifyListeners();
await LocalManager().saveCurrentDownloadingTasks();
}
while (_chapter < _images!.length) {
var images = _images![_images!.keys.elementAt(_chapter)]!;
tasks.clear();
while (_index < images.length) {
_scheduleTasks();
var task = tasks[_index]!;
await task.wait();
if (isPaused) {
return;
}
if (task.error != null) {
_setError("Error: ${task.error}");
return;
}
_index++;
_downloadedCount++;
_message = "$_downloadedCount/$_totalCount";
await LocalManager().saveCurrentDownloadingTasks();
}
_index = 0;
_chapter++;
}
LocalManager().completeTask(this);
}
@override
void onNextSecond(Timer t) {
notifyListeners();
super.onNextSecond(t);
}
void _setError(String message) {
_isRunning = false;
_isError = true;
_message = message;
notifyListeners();
stopRecorder();
Log.error("Download", message);
}
@override
int get speed => currentSpeed;
@override
String get title => comic?.title ?? "Loading...";
@override
Map<String, dynamic> toJson() {
return {
"type": "ImagesDownloadTask",
"source": source.key,
"comicId": comicId,
"comic": comic?.toJson(),
"chapters": chapters,
"path": path,
"cover": cover,
"images": _images,
"downloadedCount": _downloadedCount,
"totalCount": _totalCount,
"index": _index,
"chapter": _chapter,
};
}
static ImagesDownloadTask? fromJson(Map<String, dynamic> json) {
if (json["type"] != "ImagesDownloadTask") {
return null;
}
Map<String, List<String>>? images;
if (json["images"] != null) {
images = {};
for (var entry in json["images"].entries) {
images[entry.key] = List<String>.from(entry.value);
}
}
return ImagesDownloadTask(
source: ComicSource.find(json["source"])!,
comicId: json["comicId"],
comic:
json["comic"] == null ? null : ComicDetails.fromJson(json["comic"]),
chapters: ListOrNull.from(json["chapters"]),
)
..path = json["path"]
.._cover = json["cover"]
.._images = images
.._downloadedCount = json["downloadedCount"]
.._totalCount = json["totalCount"]
.._index = json["index"]
.._chapter = json["chapter"];
}
@override
bool get isError => _isError;
@override
bool get isPaused => !_isRunning;
@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: comic!.chapters,
cover: File(_cover!).uri.pathSegments.last,
comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [],
createdAt: DateTime.now(),
);
}
@override
bool operator ==(Object other) {
if (other is ImagesDownloadTask) {
return other.comicId == comicId && other.source.key == source.key;
}
return false;
}
@override
int get hashCode => Object.hash(comicId, source.key);
}
Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
{int retry = 3}) async {
for (var i = 0; i < retry; i++) {
try {
return Res(await task());
} catch (e) {
if (i == retry - 1) {
return Res.error(e.toString());
}
}
}
throw UnimplementedError();
}
class _ImageDownloadWrapper {
final ImagesDownloadTask task;
final String chapter;
final int index;
final String image;
final Directory saveTo;
_ImageDownloadWrapper(
this.task,
this.chapter,
this.image,
this.saveTo,
this.index,
) {
start();
}
bool isComplete = false;
String? error;
bool isCancelled = false;
void cancel() {
isCancelled = true;
}
var completers = <Completer<_ImageDownloadWrapper>>[];
void start() async {
int lastBytes = 0;
try {
await for (var p in ImageDownloader.loadComicImage(
image, task.source.key, task.comicId, chapter)) {
if (isCancelled) {
return;
}
task.onData(p.currentBytes - lastBytes);
lastBytes = p.currentBytes;
if (p.imageBytes != null) {
var fileType = detectFileType(p.imageBytes!);
var file = saveTo.joinFile("$index${fileType.ext}");
await file.writeAsBytes(p.imageBytes!);
isComplete = true;
for (var c in completers) {
c.complete(this);
}
completers.clear();
}
}
} catch (e, s) {
Log.error("Download", e.toString(), s);
error = e.toString();
for (var c in completers) {
if (!c.isCompleted) {
c.complete(this);
}
}
}
}
Future<_ImageDownloadWrapper> wait() {
if (isComplete) {
return Future.value(this);
}
var c = Completer<_ImageDownloadWrapper>();
completers.add(c);
return c.future;
}
}
abstract mixin class _TransferSpeedMixin {
int _bytesSinceLastSecond = 0;
int _currentSpeed = 0;
int get currentSpeed => _currentSpeed;
Timer? timer;
void onData(int length) {
if (timer == null) return;
_bytesSinceLastSecond += length;
}
void onNextSecond(Timer t) {
_currentSpeed = _bytesSinceLastSecond;
_bytesSinceLastSecond = 0;
}
void runRecorder() {
if (timer != null) {
timer!.cancel();
}
timer = Timer.periodic(const Duration(seconds: 1), onNextSecond);
}
void stopRecorder() {
timer?.cancel();
timer = null;
}
}

139
lib/network/images.dart Normal file
View File

@@ -0,0 +1,139 @@
import 'dart:typed_data';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'app_dio.dart';
class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail(String url, String? sourceKey) async* {
final cacheKey = "$url@$sourceKey";
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'] ??= {
'user-agent': webUA,
};
var dio = AppDio(BaseOptions(
headers: 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'] != null) {
buffer = configs['onResponse'](buffer);
}
await CacheManager().writeCache(cacheKey, buffer);
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: buffer.length,
imageBytes: Uint8List.fromList(buffer),
);
}
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,
);
}
var configs = <String, dynamic>{};
if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey);
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {};
}
configs['headers'] ??= {
'user-agent': webUA,
};
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);
if (expectedBytes != null) {
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
}
if(configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer);
}
await CacheManager().writeCache(cacheKey, buffer);
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: buffer.length,
imageBytes: Uint8List.fromList(buffer),
);
}
}
class ImageDownloadProgress {
final int currentBytes;
final int totalBytes;
final Uint8List? imageBytes;
const ImageDownloadProgress({
required this.currentBytes,
required this.totalBytes,
this.imageBytes,
});
}

View File

@@ -7,9 +7,12 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'dart:math' as math; import 'dart:math' as math;
@@ -32,6 +35,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
var scrollController = ScrollController(); var scrollController = ScrollController();
bool isDownloaded = false;
@override @override
void initState() { void initState() {
scrollController.addListener(onScroll); scrollController.addListener(onScroll);
@@ -92,9 +97,16 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
@override @override
onDataLoaded() { Future<void> onDataLoaded() async {
isLiked = comic.isLiked ?? false; isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false; isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) {
isDownloaded = await LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
);
}
} }
Iterable<Widget> buildTitle() sync* { Iterable<Widget> buildTitle() sync* {
@@ -173,7 +185,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
onPressed: read, onPressed: read,
iconColor: context.useTextColor(Colors.orange), iconColor: context.useTextColor(Colors.orange),
), ),
if (!isMobile) if (!isMobile && !isDownloaded)
_ActionButton( _ActionButton(
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
text: 'Download'.tl, text: 'Download'.tl,
@@ -219,7 +231,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [ children: [
Expanded( Expanded(
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: () {}, onPressed: download,
child: Text("Download".tl), child: Text("Download".tl),
), ),
), ),
@@ -335,7 +347,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [ children: [
buildTag(text: e.key, isTitle: true), buildTag(text: e.key, isTitle: true),
for (var tag in e.value) for (var tag in e.value)
buildTag(text: tag, onTap: () => onTagTap(tag, e.key)), buildTag(text: tag, onTap: () => onTapTag(tag, e.key)),
], ],
), ),
if (comic.uploader != null) if (comic.uploader != null)
@@ -455,7 +467,9 @@ abstract mixin class _ComicPageActions {
); );
} }
void share() {} void share() {
Share.shareText(comic.title);
}
/// read the comic /// read the comic
/// ///
@@ -482,9 +496,57 @@ abstract mixin class _ComicPageActions {
read(ep, page); read(ep, page);
} }
void download() {} void download() async {
if (LocalManager().isDownloading(comic.id, comic.comicType)) {
App.rootContext.showMessage(message: "The comic is downloading".tl);
return;
}
if (comic.chapters == null &&
await LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
App.rootContext.showMessage(message: "The comic is downloaded".tl);
return;
}
if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
));
} else {
List<int>? selected;
var downloaded = <int>[];
var localComic = LocalManager().find(comic.id, comic.comicType);
if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(i))) {
downloaded.add(i);
}
}
}
await showSideBar(
App.rootContext,
_SelectDownloadChapter(
comic.chapters!.values.toList(),
(v) => selected = v,
downloaded,
),
);
if (selected == null) return;
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
chapters: selected!.map((i) {
return comic.chapters!.keys.elementAt(i);
}).toList(),
));
}
App.rootContext.showMessage(message: "Download started".tl);
update();
}
void onTagTap(String tag, String namespace) {} void onTapTag(String tag, String namespace) {}
void showMoreActions() {} void showMoreActions() {}
@@ -1137,3 +1199,95 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
} }
} }
} }
class _SelectDownloadChapter extends StatefulWidget {
const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps);
final List<String> eps;
final void Function(List<int>) finishSelect;
final List<int> downloadedEps;
@override
State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState();
}
class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
List<int> selected = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(title: Text("Download".tl), backgroundColor: context.colorScheme.surfaceContainerLow,),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: widget.eps.length,
itemBuilder: (context, i) {
return CheckboxListTile(
title: Text(widget.eps[i]),
value: selected.contains(i) ||
widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
});
},
),
),
Container(
height: 50,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
),
),
),
child: Row(
children: [
const SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () {
var res = <int>[];
for (int i = 0; i < widget.eps.length; i++) {
if (!widget.downloadedEps.contains(i)) {
res.add(i);
}
}
widget.finishSelect(res);
context.pop();
},
child: Text("Download All".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: () {
widget.finishSelect(selected);
context.pop();
},
child: Text("Download Selected".tl),
),
),
const SizedBox(width: 16),
],
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 4),
],
),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/download.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
class DownloadingPage extends StatefulWidget {
const DownloadingPage({super.key});
@override
State<DownloadingPage> createState() => _DownloadingPageState();
}
class _DownloadingPageState extends State<DownloadingPage> {
@override
void initState() {
LocalManager().addListener(update);
super.initState();
}
@override
void dispose() {
LocalManager().removeListener(update);
super.dispose();
}
void update() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "",
body: ListView.builder(
itemCount: LocalManager().downloadingTasks.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return buildTop();
}
i--;
return _DownloadTaskTile(
task: LocalManager().downloadingTasks[i],
);
},
),
);
}
Widget buildTop() {
int speed = 0;
if (LocalManager().downloadingTasks.isNotEmpty) {
speed = LocalManager().downloadingTasks.first.speed;
}
var first = LocalManager().downloadingTasks.firstOrNull;
return Container(
height: 48,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
if (first?.isPaused == true)
Text(
"Paused".tl,
style: ts.s18.bold,
)
else if (first?.isError == true)
Text(
"Error".tl,
style: ts.s18.bold,
)
else
Text(
"${bytesToReadableString(speed)}/s",
style: ts.s18.bold,
),
const Spacer(),
if (first?.isPaused == true || first?.isError == true)
OutlinedButton(
child: Row(
children: [
const Icon(Icons.play_arrow, size: 18),
const SizedBox(width: 4),
Text("Start".tl),
],
),
onPressed: () {
first!.resume();
},
)
else if (first != null)
OutlinedButton(
child: Row(
children: [
const Icon(Icons.pause, size: 18),
const SizedBox(width: 4),
Text("Pause".tl),
],
),
onPressed: () {
first.pause();
},
),
],
).paddingHorizontal(16),
);
}
}
class _DownloadTaskTile extends StatefulWidget {
const _DownloadTaskTile({required this.task});
final DownloadTask task;
@override
State<_DownloadTaskTile> createState() => _DownloadTaskTileState();
}
class _DownloadTaskTileState extends State<_DownloadTaskTile> {
@override
void initState() {
widget.task.addListener(update);
super.initState();
}
@override
void dispose() {
widget.task.removeListener(update);
super.dispose();
}
void update() {
context.findAncestorStateOfType<_DownloadingPageState>()?.update();
}
@override
Widget build(BuildContext context) {
return Container(
height: 136,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: Row(
children: [
Container(
width: 82,
height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: context.colorScheme.primaryContainer,
),
clipBehavior: Clip.antiAlias,
child: widget.task.cover == null
? null
: Image.file(
File(widget.task.cover!),
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.task.title,
style: Theme.of(context).textTheme.bodyMedium,
),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.close,
text: "Cancel".tl,
onClick: () {
widget.task.cancel();
},
),
MenuEntry(
icon: Icons.vertical_align_top,
text: "Move To First".tl,
onClick: () {
LocalManager().moveToFirst(widget.task);
},
),
],
),
],
),
const Spacer(),
if (!widget.task.isPaused || widget.task.isError)
Text(
widget.task.message,
style: ts.s12,
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: widget.task.progress,
),
const SizedBox(height: 8),
],
),
),
],
),
);
}
}

View File

@@ -12,11 +12,14 @@ import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart';
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -163,8 +166,8 @@ class _HistoryState extends State<_History> {
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
width: 96, width: 92,
height: 128, height: 114,
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -242,7 +245,9 @@ class _LocalState extends State<_Local> {
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () {}, onTap: () {
context.to(() => const LocalComicsPage());
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -277,12 +282,12 @@ class _LocalState extends State<_Local> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
return InkWell( return InkWell(
onTap: () { onTap: () {
// TODO: view local comic local[index].read();
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
width: 96, width: 92,
height: 128, height: 114,
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -307,6 +312,24 @@ class _LocalState extends State<_Local> {
).paddingHorizontal(8), ).paddingHorizontal(8),
Row( Row(
children: [ children: [
if (LocalManager().downloadingTasks.isNotEmpty)
Button.outlined(
child: Row(
children: [
if(LocalManager().downloadingTasks.first.isPaused)
const Icon(Icons.pause_circle_outline, size: 18)
else
const _AnimatedDownloadingIcon(),
const SizedBox(width: 8),
Text("@a Tasks".tlParams({
'a': LocalManager().downloadingTasks.length,
})),
],
),
onPressed: () {
showPopUpWidget(context, const DownloadingPage());
},
),
const Spacer(), const Spacer(),
Button.filled( Button.filled(
onPressed: import, onPressed: import,
@@ -601,6 +624,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
chapters: Map.fromIterables(chapters, chapters), chapters: Map.fromIterables(chapters, chapters),
cover: coverPath, cover: coverPath,
comicType: ComicType.local, comicType: ComicType.local,
downloadedChapters: chapters,
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
} }
@@ -810,3 +834,62 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
); );
} }
} }
class _AnimatedDownloadingIcon extends StatefulWidget {
const _AnimatedDownloadingIcon();
@override
State<_AnimatedDownloadingIcon> createState() =>
__AnimatedDownloadingIconState();
}
class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
lowerBound: -1,
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
clipBehavior: Clip.hardEdge,
child: Transform.translate(
offset: Offset(0, 18 * _controller.value),
child: Icon(
Icons.arrow_downward,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/utils/translations.dart';
class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key});
@override
State<LocalComicsPage> createState() => _LocalComicsPageState();
}
class _LocalComicsPageState extends State<LocalComicsPage> {
late List<LocalComic> comics;
void update() {
setState(() {
comics = LocalManager().getComics();
});
}
@override
void initState() {
comics = LocalManager().getComics();
LocalManager().addListener(update);
super.initState();
}
@override
void dispose() {
LocalManager().removeListener(update);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text("Local".tl),
actions: [
Tooltip(
message: "Downloading".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: () {
showPopUpWidget(context, const DownloadingPage());
},
),
)
],
),
SliverGridComics(
comics: comics,
onTap: (c) {
(c as LocalComic).read();
},
),
],
),
);
}
}

View File

@@ -510,6 +510,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
}); });
} }
if(event is KeyUpEvent) {
return;
}
bool? forward; bool? forward;
if (reader.mode == ReaderMode.continuousLeftToRight && if (reader.mode == ReaderMode.continuousLeftToRight &&
event.logicalKey == LogicalKeyboardKey.arrowRight) { event.logicalKey == LogicalKeyboardKey.arrowRight) {

View File

@@ -9,6 +9,7 @@ import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart' as s; import 'package:share_plus/share_plus.dart' as s;
export 'dart:io'; export 'dart:io';
export 'dart:typed_data';
class FilePath { class FilePath {
const FilePath._(); const FilePath._();
@@ -64,6 +65,10 @@ extension DirectoryExtension on Directory {
newName = sanitizeFileName(newName); newName = sanitizeFileName(newName);
return renameSync(path.replaceLast(name, newName)); return renameSync(path.replaceLast(name, newName));
} }
File joinFile(String name) {
return File(FilePath.join(path, name));
}
} }
String sanitizeFileName(String fileName) { String sanitizeFileName(String fileName) {
@@ -108,7 +113,7 @@ String findValidDirectoryName(String path, String directory) {
var name = sanitizeFileName(directory); var name = sanitizeFileName(directory);
var dir = Directory("$path/$name"); var dir = Directory("$path/$name");
var i = 1; var i = 1;
while (dir.existsSync()) { while (dir.existsSync() && dir.listSync().isNotEmpty) {
name = sanitizeFileName("$directory($i)"); name = sanitizeFileName("$directory($i)");
dir = Directory("$path/$name"); dir = Directory("$path/$name");
i++; i++;