mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
download & view local comics
This commit is contained in:
@@ -165,19 +165,24 @@ class _ButtonState extends State<Button> {
|
||||
if (height != null) {
|
||||
height = height - padding.vertical;
|
||||
}
|
||||
Widget child = DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
Widget child = IconTheme(
|
||||
data: IconThemeData(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(
|
||||
color: widget.type == ButtonType.filled
|
||||
? context.colorScheme.inversePrimary
|
||||
: context.colorScheme.primary,
|
||||
strokeWidth: 1.8,
|
||||
).fixWidth(16).fixHeight(16)
|
||||
: widget.child,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
child: isLoading
|
||||
? CircularProgressIndicator(
|
||||
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) {
|
||||
child = child.toCenter();
|
||||
@@ -255,7 +260,7 @@ class _ButtonState extends State<Button> {
|
||||
|
||||
Color get textColor {
|
||||
if (widget.type == ButtonType.outlined) {
|
||||
return widget.color ?? context.colorScheme.onSurface;
|
||||
return widget.color ?? context.colorScheme.primary;
|
||||
}
|
||||
return widget.type == ButtonType.filled
|
||||
? context.colorScheme.onPrimary
|
||||
|
@@ -7,6 +7,7 @@ class ComicTile extends StatelessWidget {
|
||||
this.enableLongPressed = true,
|
||||
this.badge,
|
||||
this.menuOptions,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Comic comic;
|
||||
@@ -17,7 +18,13 @@ class ComicTile extends StatelessWidget {
|
||||
|
||||
final List<MenuEntry>? menuOptions;
|
||||
|
||||
void onTap() {
|
||||
final VoidCallback? onTap;
|
||||
|
||||
void _onTap() {
|
||||
if(onTap != null) {
|
||||
onTap!();
|
||||
return;
|
||||
}
|
||||
App.mainNavigatorKey?.currentContext
|
||||
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
||||
}
|
||||
@@ -43,7 +50,7 @@ class ComicTile extends StatelessWidget {
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
text: 'Details'.tl,
|
||||
onClick: onTap,
|
||||
onClick: _onTap,
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
@@ -155,7 +162,7 @@ class ComicTile extends StatelessWidget {
|
||||
final height = constrains.maxHeight - 16;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
onTap: _onTap,
|
||||
onLongPress: enableLongPressed ? () => onLongPress(context) : null,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
child: Padding(
|
||||
@@ -250,7 +257,7 @@ class ComicTile extends StatelessWidget {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onTap: _onTap,
|
||||
onLongPress:
|
||||
enableLongPressed ? () => onLongPress(context) : null,
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
@@ -464,6 +471,7 @@ class SliverGridComics extends StatelessWidget {
|
||||
this.onLastItemBuild,
|
||||
this.badgeBuilder,
|
||||
this.menuBuilder,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Comic> comics;
|
||||
@@ -474,6 +482,8 @@ class SliverGridComics extends StatelessWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<SliverGridComicsController>(
|
||||
@@ -490,6 +500,7 @@ class SliverGridComics extends StatelessWidget {
|
||||
onLastItemBuild: onLastItemBuild,
|
||||
badgeBuilder: badgeBuilder,
|
||||
menuBuilder: menuBuilder,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -502,6 +513,7 @@ class _SliverGridComics extends StatelessWidget {
|
||||
this.onLastItemBuild,
|
||||
this.badgeBuilder,
|
||||
this.menuBuilder,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Comic> comics;
|
||||
@@ -512,6 +524,8 @@ class _SliverGridComics extends StatelessWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
@@ -525,6 +539,7 @@ class _SliverGridComics extends StatelessWidget {
|
||||
comic: comics[index],
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
||||
);
|
||||
},
|
||||
childCount: comics.length,
|
||||
|
@@ -95,7 +95,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
|
||||
Future<Res<S>> loadData();
|
||||
|
||||
void onDataLoaded() {}
|
||||
FutureOr<void> onDataLoaded() {}
|
||||
|
||||
Widget buildContent(BuildContext context, S data);
|
||||
|
||||
@@ -114,13 +114,13 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
isLoading = true;
|
||||
error = null;
|
||||
});
|
||||
loadData().then((value) {
|
||||
loadData().then((value) async {
|
||||
if (value.success) {
|
||||
await onDataLoaded();
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
onDataLoaded();
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
|
@@ -54,8 +54,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Material(
|
||||
color: context.brightness == Brightness.light
|
||||
? const Color(0xFFFAFAFA).withOpacity(0.72)
|
||||
: const Color(0xFF090909).withOpacity(0.72),
|
||||
? const Color(0xFFFAFAFA).withOpacity(0.82)
|
||||
: const Color(0xFF090909).withOpacity(0.82),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: width,
|
||||
|
@@ -86,7 +86,7 @@ class PopupIndicatorWidget extends InheritedWidget {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -124,7 +124,7 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "返回".tl,
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_sharp),
|
||||
onPressed: () => context.canPop()
|
||||
|
@@ -203,13 +203,13 @@ class _SidebarBodyState extends State<SidebarBody> {
|
||||
}
|
||||
}
|
||||
|
||||
void showSideBar(BuildContext context, Widget widget,
|
||||
Future<void> showSideBar(BuildContext context, Widget widget,
|
||||
{String? title,
|
||||
bool showBarrier = true,
|
||||
bool useSurfaceTintColor = false,
|
||||
double width = 500,
|
||||
bool addTopPadding = false}) {
|
||||
Navigator.of(context).push(
|
||||
return Navigator.of(context).push(
|
||||
SideBarRoute(
|
||||
title,
|
||||
widget,
|
||||
|
@@ -139,6 +139,29 @@ class ComicDetails with HistoryMixin {
|
||||
uploadTime = json["uploadTime"],
|
||||
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
|
||||
HistoryType get historyType => HistoryType(sourceKey.hashCode);
|
||||
|
||||
|
@@ -1,10 +1,7 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'cached_image.dart' as image_provider;
|
||||
|
||||
@@ -21,52 +18,16 @@ class CachedImageProvider
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
final cacheKey = "$url@$sourceKey";
|
||||
final cache = await CacheManager().findCache(cacheKey);
|
||||
|
||||
if (cache != null) {
|
||||
return await cache.readAsBytes();
|
||||
}
|
||||
|
||||
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,
|
||||
));
|
||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if(progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
}
|
||||
|
||||
if(configs['onResponse'] != null) {
|
||||
buffer = configs['onResponse'](buffer);
|
||||
}
|
||||
|
||||
await CacheManager().writeCache(cacheKey, buffer);
|
||||
return Uint8List.fromList(buffer);
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -1,10 +1,7 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'reader_image.dart' as image_provider;
|
||||
|
||||
@@ -23,52 +20,17 @@ class ReaderImageProvider
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||
final cache = await CacheManager().findCache(cacheKey);
|
||||
|
||||
if (cache != null) {
|
||||
return await cache.readAsBytes();
|
||||
}
|
||||
|
||||
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) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: buffer.length,
|
||||
expectedTotalBytes: expectedBytes,
|
||||
));
|
||||
await for (var event
|
||||
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: event.currentBytes,
|
||||
expectedTotalBytes: event.totalBytes,
|
||||
));
|
||||
if (event.imageBytes != null) {
|
||||
return event.imageBytes!;
|
||||
}
|
||||
}
|
||||
|
||||
if(configs['onResponse'] != null) {
|
||||
buffer = configs['onResponse'](buffer);
|
||||
}
|
||||
|
||||
await CacheManager().writeCache(cacheKey, buffer);
|
||||
return Uint8List.fromList(buffer);
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -1,16 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.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 'app.dart';
|
||||
import 'history.dart';
|
||||
|
||||
class LocalComic implements Comic {
|
||||
class LocalComic with HistoryMixin implements Comic {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@@ -37,6 +39,8 @@ class LocalComic implements Comic {
|
||||
|
||||
final ComicType comicType;
|
||||
|
||||
final List<String> downloadedChapters;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
const LocalComic({
|
||||
@@ -48,6 +52,7 @@ class LocalComic implements Comic {
|
||||
required this.chapters,
|
||||
required this.cover,
|
||||
required this.comicType,
|
||||
required this.downloadedChapters,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
@@ -60,9 +65,14 @@ class LocalComic implements Comic {
|
||||
chapters = Map.from(jsonDecode(row[5] as String)),
|
||||
cover = row[6] as String,
|
||||
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
|
||||
String get description => "";
|
||||
@@ -85,6 +95,31 @@ class LocalComic implements Comic {
|
||||
|
||||
@override
|
||||
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 {
|
||||
@@ -103,10 +138,10 @@ class LocalManager with ChangeNotifier {
|
||||
// return error message if failed
|
||||
Future<String?> setNewPath(String newPath) async {
|
||||
var newDir = Directory(newPath);
|
||||
if(!await newDir.exists()) {
|
||||
if (!await newDir.exists()) {
|
||||
return "Directory does not exist";
|
||||
}
|
||||
if(!await newDir.list().isEmpty) {
|
||||
if (!await newDir.list().isEmpty) {
|
||||
return "Directory is not empty";
|
||||
}
|
||||
try {
|
||||
@@ -137,6 +172,7 @@ class LocalManager with ChangeNotifier {
|
||||
chapters TEXT NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
comic_type INTEGER NOT NULL,
|
||||
downloadedChapters TEXT NOT NULL,
|
||||
created_at INTEGER,
|
||||
PRIMARY KEY (id, comic_type)
|
||||
);
|
||||
@@ -147,17 +183,18 @@ class LocalManager with ChangeNotifier {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
path = FilePath.join(external.first.path, 'local_path');
|
||||
path = FilePath.join(external.first.path, 'local');
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local_path');
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local_path');
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
}
|
||||
if (!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
}
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
|
||||
String findValidId(ComicType type) {
|
||||
@@ -175,8 +212,13 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
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(
|
||||
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
id ?? comic.id,
|
||||
comic.title,
|
||||
@@ -186,6 +228,7 @@ class LocalManager with ChangeNotifier {
|
||||
jsonEncode(comic.chapters),
|
||||
comic.cover,
|
||||
comic.comicType.value,
|
||||
jsonEncode(downloaded),
|
||||
comic.createdAt.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
@@ -274,7 +317,7 @@ class LocalManager with ChangeNotifier {
|
||||
files.sort((a, b) {
|
||||
var ai = int.tryParse(a.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 a.name.compareTo(b.name);
|
||||
@@ -284,9 +327,80 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
Future<bool> isDownloaded(String id, ComicType type, int ep) async {
|
||||
var comic = find(id, type);
|
||||
if(comic == null) return false;
|
||||
if(comic.chapters == null) return true;
|
||||
var eid = comic.chapters!.keys.elementAt(ep);
|
||||
return Directory(FilePath.join(path, comic.directory, eid)).exists();
|
||||
if (comic == null) return false;
|
||||
if (comic.chapters == null) return true;
|
||||
return comic.downloadedChapters
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
@@ -82,7 +82,10 @@ class Log {
|
||||
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);
|
||||
}
|
||||
|
||||
|
@@ -15,10 +15,10 @@ Future<void> init() async {
|
||||
await appdata.init();
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
await LocalManager().init();
|
||||
await LocalFavoritesManager().init();
|
||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||
await JsEngine().init();
|
||||
await ComicSource.init();
|
||||
await LocalManager().init();
|
||||
CacheManager();
|
||||
}
|
@@ -1,14 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 {
|
||||
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;
|
||||
|
||||
void cancel();
|
||||
@@ -20,4 +33,549 @@ abstract class DownloadTask with ChangeNotifier {
|
||||
String get title;
|
||||
|
||||
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
139
lib/network/images.dart
Normal 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,
|
||||
});
|
||||
}
|
@@ -7,9 +7,12 @@ import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.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/network/download.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@@ -32,6 +35,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
var scrollController = ScrollController();
|
||||
|
||||
bool isDownloaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
scrollController.addListener(onScroll);
|
||||
@@ -92,9 +97,16 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
}
|
||||
|
||||
@override
|
||||
onDataLoaded() {
|
||||
Future<void> onDataLoaded() async {
|
||||
isLiked = comic.isLiked ?? false;
|
||||
isFavorite = comic.isFavorite ?? false;
|
||||
if (comic.chapters == null) {
|
||||
isDownloaded = await LocalManager().isDownloaded(
|
||||
comic.id,
|
||||
comic.comicType,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<Widget> buildTitle() sync* {
|
||||
@@ -173,7 +185,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
onPressed: read,
|
||||
iconColor: context.useTextColor(Colors.orange),
|
||||
),
|
||||
if (!isMobile)
|
||||
if (!isMobile && !isDownloaded)
|
||||
_ActionButton(
|
||||
icon: const Icon(Icons.download),
|
||||
text: 'Download'.tl,
|
||||
@@ -219,7 +231,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () {},
|
||||
onPressed: download,
|
||||
child: Text("Download".tl),
|
||||
),
|
||||
),
|
||||
@@ -335,7 +347,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
children: [
|
||||
buildTag(text: e.key, isTitle: true),
|
||||
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)
|
||||
@@ -455,7 +467,9 @@ abstract mixin class _ComicPageActions {
|
||||
);
|
||||
}
|
||||
|
||||
void share() {}
|
||||
void share() {
|
||||
Share.shareText(comic.title);
|
||||
}
|
||||
|
||||
/// read the comic
|
||||
///
|
||||
@@ -482,9 +496,57 @@ abstract mixin class _ComicPageActions {
|
||||
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() {}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
218
lib/pages/downloading_page.dart
Normal file
218
lib/pages/downloading_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -12,11 +12,14 @@ import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/accounts_page.dart';
|
||||
import 'package:venera/pages/comic_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/search_page.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'local_comics_page.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@@ -163,8 +166,8 @@ class _HistoryState extends State<_History> {
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 128,
|
||||
width: 92,
|
||||
height: 114,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -242,7 +245,9 @@ class _LocalState extends State<_Local> {
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
context.to(() => const LocalComicsPage());
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -277,12 +282,12 @@ class _LocalState extends State<_Local> {
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// TODO: view local comic
|
||||
local[index].read();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 128,
|
||||
width: 92,
|
||||
height: 114,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -307,6 +312,24 @@ class _LocalState extends State<_Local> {
|
||||
).paddingHorizontal(8),
|
||||
Row(
|
||||
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(),
|
||||
Button.filled(
|
||||
onPressed: import,
|
||||
@@ -601,6 +624,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
chapters: Map.fromIterables(chapters, chapters),
|
||||
cover: coverPath,
|
||||
comicType: ComicType.local,
|
||||
downloadedChapters: chapters,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
66
lib/pages/local_comics_page.dart
Normal file
66
lib/pages/local_comics_page.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -510,6 +510,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
});
|
||||
}
|
||||
if(event is KeyUpEvent) {
|
||||
return;
|
||||
}
|
||||
bool? forward;
|
||||
if (reader.mode == ReaderMode.continuousLeftToRight &&
|
||||
event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
|
@@ -9,6 +9,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:share_plus/share_plus.dart' as s;
|
||||
|
||||
export 'dart:io';
|
||||
export 'dart:typed_data';
|
||||
|
||||
class FilePath {
|
||||
const FilePath._();
|
||||
@@ -64,6 +65,10 @@ extension DirectoryExtension on Directory {
|
||||
newName = sanitizeFileName(newName);
|
||||
return renameSync(path.replaceLast(name, newName));
|
||||
}
|
||||
|
||||
File joinFile(String name) {
|
||||
return File(FilePath.join(path, name));
|
||||
}
|
||||
}
|
||||
|
||||
String sanitizeFileName(String fileName) {
|
||||
@@ -108,7 +113,7 @@ String findValidDirectoryName(String path, String directory) {
|
||||
var name = sanitizeFileName(directory);
|
||||
var dir = Directory("$path/$name");
|
||||
var i = 1;
|
||||
while (dir.existsSync()) {
|
||||
while (dir.existsSync() && dir.listSync().isNotEmpty) {
|
||||
name = sanitizeFileName("$directory($i)");
|
||||
dir = Directory("$path/$name");
|
||||
i++;
|
||||
|
Reference in New Issue
Block a user