mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
Feat: Image favorites (#126)
* feat: 增加图片收藏 * feat: 主体图片收藏页面实现 * feat: 点击打开大图浏览 * feat: 数据结构变更 * feat: 基本完成 * feat: 翻译与bug修复 * feat: 实机测试和问题修复 * feat: jm导入, pica历史记录nhentai有问题, 一键反转 * fix: 大小写不一致, 一个htManga, 一个htmanga * feat: 拉取收藏优化 * feat: 改成以ep为准 * feat: 兜底一些可能报错场景 * chore: 没有用到 * feat: 尽量保证和网络收藏顺序一致 * feat: 支持显示热点tag * feat: 支持双击收藏, 不过此时禁止放大图片 * fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效 * Refactor * fix updateValue * feat: 双击功能提示 * fix: 被确定取消收藏的才删除 * Refactor ImageFavoritesPage * translate author * feat: 功能提示改到dialog中 * fix text editing * fix text editing * feat: 功能提示放到邮件或长按菜单中 * fix: 修复tag过滤不生效问题 * Improve image loading * The default value of quickCollectImage should be false. * Refactor DragListener * Refactor ImageFavoriteItem & ImageFavoritePhotoView * Refactor * Fix `ImageFavoriteManager.has` * Fix UI * Improve UI --------- Co-authored-by: nyne <me@nyne.dev>
This commit is contained in:
@@ -143,6 +143,7 @@ class _Settings with ChangeNotifier {
|
||||
'quickFavorite': null,
|
||||
'enableTurnPageByVolumeKey': true,
|
||||
'enableClockAndBatteryInfoInReader': true,
|
||||
'quickCollectImage': 'No', // No, DoubleTap, Swipe
|
||||
'authorizationRequired': false,
|
||||
'onClickFavorite': 'viewDetail', // viewDetail, read
|
||||
'enableDnsOverrides': false,
|
||||
@@ -179,4 +180,4 @@ const _defaultCustomImageProcessing = '''
|
||||
async function processImage(image, cid, eid) {
|
||||
return image;
|
||||
}
|
||||
''';
|
||||
''';
|
||||
|
@@ -10,6 +10,10 @@ class FavoriteData {
|
||||
|
||||
final bool multiFolder;
|
||||
|
||||
// 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了
|
||||
// 如果为 null, 当做从新到旧
|
||||
final bool? isOldToNewSort;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page, [String? folder])?
|
||||
loadComic;
|
||||
|
||||
@@ -44,6 +48,7 @@ class FavoriteData {
|
||||
this.addFolder,
|
||||
this.allFavoritesId,
|
||||
this.addOrDelFavorite,
|
||||
this.isOldToNewSort,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -73,7 +73,8 @@ class Comic {
|
||||
this.sourceKey,
|
||||
this.maxPage,
|
||||
this.language,
|
||||
): favoriteId = null, stars = null;
|
||||
) : favoriteId = null,
|
||||
stars = null;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -231,6 +232,34 @@ class ComicDetails with HistoryMixin {
|
||||
String get id => comicId;
|
||||
|
||||
ComicType get comicType => ComicType(sourceKey.hashCode);
|
||||
|
||||
/// Convert tags map to plain list
|
||||
List<String> get plainTags {
|
||||
var res = <String>[];
|
||||
tags.forEach((key, value) {
|
||||
res.addAll(value.map((e) => "$key:$e"));
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Find the first author tag
|
||||
String? findAuthor() {
|
||||
var authorNamespaces = [
|
||||
"author",
|
||||
"authors",
|
||||
"artist",
|
||||
"artists",
|
||||
"作者",
|
||||
"画师"
|
||||
];
|
||||
for (var entry in tags.entries) {
|
||||
if (authorNamespaces.contains(entry.key.toLowerCase()) &&
|
||||
entry.value.isNotEmpty) {
|
||||
return entry.value.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveInfo {
|
||||
@@ -242,4 +271,4 @@ class ArchiveInfo {
|
||||
: title = json["title"],
|
||||
description = json["description"],
|
||||
id = json["id"];
|
||||
}
|
||||
}
|
||||
|
@@ -193,7 +193,7 @@ class ComicSourceParser {
|
||||
login = (account, pwd) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
${jsonEncode(pwd)})
|
||||
""");
|
||||
var source = ComicSource.find(_key!)!;
|
||||
@@ -502,9 +502,9 @@ class ComicSourceParser {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
@@ -618,6 +618,7 @@ class ComicSourceParser {
|
||||
if (!_checkExists("favorites")) return null;
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
|
||||
if (!ComicSource.find(_key!)!.isLogged) {
|
||||
@@ -770,6 +771,7 @@ class ComicSourceParser {
|
||||
addFolder: addFolder,
|
||||
deleteFolder: deleteFolder,
|
||||
addOrDelFavorite: addOrDelFavFunc,
|
||||
isOldToNewSort: isOldToNewSort,
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,17 @@
|
||||
/// If window width is less than this value, it is considered as mobile.
|
||||
const changePoint = 600;
|
||||
|
||||
/// If window width is less than this value, it is considered as tablet.
|
||||
///
|
||||
/// If it is more than this value, it is considered as desktop.
|
||||
const changePoint2 = 1300;
|
||||
|
||||
/// Default user agent for http requests.
|
||||
const webUA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
||||
|
||||
/// Pages for all comics is started from this value.
|
||||
const firstPage = 1;
|
||||
|
||||
/// Chapters for all comics is started from this value.
|
||||
const firstChapter = 1;
|
@@ -36,6 +36,8 @@ extension Navigation on BuildContext {
|
||||
|
||||
Brightness get brightness => Theme.of(this).brightness;
|
||||
|
||||
bool get isDarkMode => brightness == Brightness.dark;
|
||||
|
||||
void showMessage({required String message}) {
|
||||
showToast(message: message, context: this);
|
||||
}
|
||||
|
@@ -1,12 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'consts.dart';
|
||||
|
||||
part "image_favorites.dart";
|
||||
|
||||
typedef HistoryType = ComicType;
|
||||
|
||||
@@ -37,7 +48,7 @@ class History implements Comic {
|
||||
|
||||
@override
|
||||
String cover;
|
||||
|
||||
|
||||
int ep;
|
||||
|
||||
int page;
|
||||
@@ -201,7 +212,12 @@ class HistoryManager with ChangeNotifier {
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
|
||||
bool isInitialized = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
_db.execute("""
|
||||
@@ -220,6 +236,8 @@ class HistoryManager with ChangeNotifier {
|
||||
""");
|
||||
|
||||
notifyListeners();
|
||||
ImageFavoriteManager().init();
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
/// add history. if exists, update time.
|
||||
@@ -275,7 +293,7 @@ class HistoryManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
History? findSync(String id, ComicType type) {
|
||||
if(_cachedHistory == null) {
|
||||
if (_cachedHistory == null) {
|
||||
updateCache();
|
||||
}
|
||||
if (!_cachedHistory!.containsKey(id)) {
|
||||
|
535
lib/foundation/image_favorites.dart
Normal file
535
lib/foundation/image_favorites.dart
Normal file
@@ -0,0 +1,535 @@
|
||||
part of "history.dart";
|
||||
|
||||
class ImageFavorite {
|
||||
final String eid;
|
||||
final String id; // 漫画id
|
||||
final int ep;
|
||||
final String epName;
|
||||
final String sourceKey;
|
||||
String imageKey;
|
||||
int page;
|
||||
bool? isAutoFavorite;
|
||||
|
||||
ImageFavorite(
|
||||
this.page,
|
||||
this.imageKey,
|
||||
this.isAutoFavorite,
|
||||
this.eid,
|
||||
this.id,
|
||||
this.ep,
|
||||
this.sourceKey,
|
||||
this.epName,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'page': page,
|
||||
'imageKey': imageKey,
|
||||
'isAutoFavorite': isAutoFavorite,
|
||||
'eid': eid,
|
||||
'id': id,
|
||||
'ep': ep,
|
||||
'sourceKey': sourceKey,
|
||||
'epName': epName,
|
||||
};
|
||||
}
|
||||
|
||||
ImageFavorite.fromJson(Map<String, dynamic> json)
|
||||
: page = json['page'],
|
||||
imageKey = json['imageKey'],
|
||||
isAutoFavorite = json['isAutoFavorite'],
|
||||
eid = json['eid'],
|
||||
id = json['id'],
|
||||
ep = json['ep'],
|
||||
sourceKey = json['sourceKey'],
|
||||
epName = json['epName'];
|
||||
|
||||
ImageFavorite copyWith({
|
||||
int? page,
|
||||
String? imageKey,
|
||||
bool? isAutoFavorite,
|
||||
String? eid,
|
||||
String? id,
|
||||
int? ep,
|
||||
String? sourceKey,
|
||||
String? epName,
|
||||
}) {
|
||||
return ImageFavorite(
|
||||
page ?? this.page,
|
||||
imageKey ?? this.imageKey,
|
||||
isAutoFavorite ?? this.isAutoFavorite,
|
||||
eid ?? this.eid,
|
||||
id ?? this.id,
|
||||
ep ?? this.ep,
|
||||
sourceKey ?? this.sourceKey,
|
||||
epName ?? this.epName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavorite &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey &&
|
||||
other.page == page &&
|
||||
other.eid == eid &&
|
||||
other.ep == ep;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey, page, eid, ep);
|
||||
}
|
||||
|
||||
class ImageFavoritesEp {
|
||||
// 小心拷贝等多章节的可能更新章节顺序
|
||||
String eid;
|
||||
final int ep;
|
||||
int maxPage;
|
||||
String epName;
|
||||
List<ImageFavorite> imageFavorites;
|
||||
|
||||
ImageFavoritesEp(
|
||||
this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage);
|
||||
|
||||
// 是否有封面
|
||||
bool get isHasFirstPage {
|
||||
return imageFavorites[0].page == firstPage;
|
||||
}
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isHasImageKey {
|
||||
return imageFavorites.every((e) => e.imageKey != "");
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eid': eid,
|
||||
'ep': ep,
|
||||
'maxPage': maxPage,
|
||||
'epName': epName,
|
||||
'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoritesComic {
|
||||
final String id;
|
||||
final String title;
|
||||
String subTitle;
|
||||
String author;
|
||||
final String sourceKey;
|
||||
|
||||
// 不一定是真的这本漫画的所有页数, 如果是多章节的时候
|
||||
int maxPage;
|
||||
List<String> tags;
|
||||
List<String> translatedTags;
|
||||
final DateTime time;
|
||||
List<ImageFavoritesEp> imageFavoritesEp;
|
||||
final Map<String, dynamic> other;
|
||||
|
||||
ImageFavoritesComic(
|
||||
this.id,
|
||||
this.imageFavoritesEp,
|
||||
this.title,
|
||||
this.sourceKey,
|
||||
this.tags,
|
||||
this.translatedTags,
|
||||
this.time,
|
||||
this.author,
|
||||
this.other,
|
||||
this.subTitle,
|
||||
this.maxPage,
|
||||
);
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isAllHasImageKey {
|
||||
return imageFavoritesEp
|
||||
.every((e) => e.imageFavorites.every((j) => j.imageKey != ""));
|
||||
}
|
||||
|
||||
int get maxPageFromEp {
|
||||
int temp = 0;
|
||||
for (var e in imageFavoritesEp) {
|
||||
temp += e.maxPage;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
// 是否都有封面
|
||||
bool get isAllHasFirstPage {
|
||||
return imageFavoritesEp.every((e) => e.isHasFirstPage);
|
||||
}
|
||||
|
||||
Iterable<ImageFavorite> get images sync*{
|
||||
for (var e in imageFavoritesEp) {
|
||||
yield* e.imageFavorites;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavoritesComic &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey);
|
||||
|
||||
factory ImageFavoritesComic.fromRow(Row r) {
|
||||
var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]);
|
||||
List<ImageFavoritesEp> finalImageFavoritesEp = [];
|
||||
tempImageFavoritesEp.forEach((i) {
|
||||
List<ImageFavorite> temp = [];
|
||||
i["imageFavorites"].forEach((j) {
|
||||
temp.add(ImageFavorite(
|
||||
j["page"],
|
||||
j["imageKey"],
|
||||
j["isAutoFavorite"],
|
||||
i["eid"],
|
||||
r["id"],
|
||||
i["ep"],
|
||||
r["source_key"],
|
||||
i["epName"],
|
||||
));
|
||||
});
|
||||
finalImageFavoritesEp.add(ImageFavoritesEp(
|
||||
i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1));
|
||||
});
|
||||
return ImageFavoritesComic(
|
||||
r["id"],
|
||||
finalImageFavoritesEp,
|
||||
r["title"],
|
||||
r["source_key"],
|
||||
r["tags"].split(","),
|
||||
r["translated_tags"].split(","),
|
||||
DateTime.fromMillisecondsSinceEpoch(r["time"]),
|
||||
r["author"],
|
||||
jsonDecode(r["other"]),
|
||||
r["sub_title"],
|
||||
r["max_page"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoriteManager with ChangeNotifier {
|
||||
Database get _db => HistoryManager()._db;
|
||||
|
||||
List<ImageFavoritesComic> get comics => getAll();
|
||||
|
||||
static ImageFavoriteManager? _cache;
|
||||
|
||||
ImageFavoriteManager._();
|
||||
|
||||
factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._());
|
||||
|
||||
/// 检查表image_favorites是否存在, 不存在则创建
|
||||
void init() {
|
||||
_db.execute("CREATE TABLE IF NOT EXISTS image_favorites ("
|
||||
"id TEXT,"
|
||||
"title TEXT NOT NULL,"
|
||||
"sub_title TEXT,"
|
||||
"author TEXT,"
|
||||
"tags TEXT,"
|
||||
"translated_tags TEXT,"
|
||||
"time int,"
|
||||
"max_page int,"
|
||||
"source_key TEXT NOT NULL,"
|
||||
"image_favorites_ep TEXT NOT NULL,"
|
||||
"other TEXT NOT NULL,"
|
||||
"PRIMARY KEY (id,source_key)"
|
||||
");");
|
||||
}
|
||||
|
||||
// 做排序和去重的操作
|
||||
void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) {
|
||||
// 没有章节了就删掉
|
||||
if (favorite.imageFavoritesEp.isEmpty) {
|
||||
_db.execute("""
|
||||
delete from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [favorite.id, favorite.sourceKey]);
|
||||
} else {
|
||||
// 去重章节
|
||||
List<ImageFavoritesEp> tempImageFavoritesEp = [];
|
||||
for (var e in favorite.imageFavoritesEp) {
|
||||
int index = tempImageFavoritesEp.indexWhere((i) {
|
||||
return i.ep == e.ep;
|
||||
});
|
||||
// 再做一层保险, 防止出现ep为0的脏数据
|
||||
if (index == -1 && e.ep > 0) {
|
||||
tempImageFavoritesEp.add(e);
|
||||
}
|
||||
}
|
||||
tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep));
|
||||
List<dynamic> finalImageFavoritesEp =
|
||||
jsonDecode(jsonEncode(tempImageFavoritesEp));
|
||||
for (var e in tempImageFavoritesEp) {
|
||||
List<Map> finalImageFavorites = [];
|
||||
int epIndex = tempImageFavoritesEp.indexOf(e);
|
||||
for (ImageFavorite j in e.imageFavorites) {
|
||||
int index =
|
||||
finalImageFavorites.indexWhere((i) => i["page"] == j.page);
|
||||
if (index == -1 && j.page > 0) {
|
||||
// isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里
|
||||
if (j.isAutoFavorite != null) {
|
||||
finalImageFavorites.add({
|
||||
"page": j.page,
|
||||
"imageKey": j.imageKey,
|
||||
"isAutoFavorite": j.isAutoFavorite
|
||||
});
|
||||
} else {
|
||||
finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey});
|
||||
}
|
||||
}
|
||||
}
|
||||
finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"]));
|
||||
finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites;
|
||||
}
|
||||
if (tempImageFavoritesEp.isEmpty) {
|
||||
throw "Error: No ImageFavoritesEp";
|
||||
}
|
||||
_db.execute("""
|
||||
insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other)
|
||||
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
favorite.id,
|
||||
favorite.title,
|
||||
favorite.subTitle,
|
||||
favorite.author,
|
||||
favorite.tags.join(","),
|
||||
favorite.translatedTags.join(","),
|
||||
favorite.time.millisecondsSinceEpoch,
|
||||
favorite.maxPage,
|
||||
favorite.sourceKey,
|
||||
jsonEncode(finalImageFavoritesEp),
|
||||
jsonEncode(favorite.other)
|
||||
]);
|
||||
}
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool has(String id, String sourceKey, String eid, int page, int ep) {
|
||||
var comic = find(id, sourceKey);
|
||||
if (comic == null) {
|
||||
return false;
|
||||
}
|
||||
var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull;
|
||||
if (epIndex == null) {
|
||||
return false;
|
||||
}
|
||||
return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep);
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> getAll([String? keyword]) {
|
||||
ResultSet res;
|
||||
if (keyword == null || keyword == "") {
|
||||
res = _db.select("select * from image_favorites;");
|
||||
} else {
|
||||
res = _db.select(
|
||||
"""
|
||||
select * from image_favorites
|
||||
WHERE title LIKE ?
|
||||
OR sub_title LIKE ?
|
||||
OR LOWER(tags) LIKE LOWER(?)
|
||||
OR LOWER(translated_tags) LIKE LOWER(?)
|
||||
OR author LIKE ?;
|
||||
""",
|
||||
['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'],
|
||||
);
|
||||
}
|
||||
try {
|
||||
return res.map((e) => ImageFavoritesComic.fromRow(e)).toList();
|
||||
} catch (e, stackTrace) {
|
||||
Log.error("Unhandled Exception", e.toString(), stackTrace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void deleteImageFavorite(Iterable<ImageFavorite> imageFavoriteList) {
|
||||
if (imageFavoriteList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (var i in imageFavoriteList) {
|
||||
ImageFavoritesProvider.deleteFromCache(i);
|
||||
}
|
||||
var comics = <ImageFavoritesComic>{};
|
||||
for (var i in imageFavoriteList) {
|
||||
var comic = comics
|
||||
.where((c) => c.id == i.id && c.sourceKey == i.sourceKey)
|
||||
.firstOrNull ??
|
||||
find(i.id, i.sourceKey);
|
||||
if (comic == null) {
|
||||
continue;
|
||||
}
|
||||
var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep);
|
||||
if (ep == null) {
|
||||
continue;
|
||||
}
|
||||
ep.imageFavorites.remove(i);
|
||||
if (ep.imageFavorites.isEmpty) {
|
||||
comic.imageFavoritesEp.remove(ep);
|
||||
}
|
||||
comics.add(comic);
|
||||
}
|
||||
for (var i in comics) {
|
||||
addOrUpdateOrDelete(i, false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int get length {
|
||||
var res = _db.select("select count(*) from image_favorites;");
|
||||
return res.first.values.first! as int;
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> search(String keyword) {
|
||||
if (keyword == "") {
|
||||
return [];
|
||||
}
|
||||
return getAll(keyword);
|
||||
}
|
||||
|
||||
static Future<ImageFavoritesComputed> computeImageFavorites() {
|
||||
var token = ServicesBinding.rootIsolateToken!;
|
||||
var count = ImageFavoriteManager().length;
|
||||
if (count == 0) {
|
||||
return Future.value(ImageFavoritesComputed([], [], []));
|
||||
} else if (count > 100) {
|
||||
return Isolate.run(() async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
return _computeImageFavorites();
|
||||
});
|
||||
} else {
|
||||
return Future.value(_computeImageFavorites());
|
||||
}
|
||||
}
|
||||
|
||||
static ImageFavoritesComputed _computeImageFavorites() {
|
||||
const maxLength = 20;
|
||||
|
||||
var comics = ImageFavoriteManager().getAll();
|
||||
// 去掉这些没有意义的标签
|
||||
const List<String> exceptTags = [
|
||||
'連載中',
|
||||
'',
|
||||
'translated',
|
||||
'chinese',
|
||||
'sole male',
|
||||
'sole female',
|
||||
'original',
|
||||
'doujinshi',
|
||||
'manga',
|
||||
'multi-work series',
|
||||
'mosaic censorship',
|
||||
'dilf',
|
||||
'bbm',
|
||||
'uncensored',
|
||||
'full censorship'
|
||||
];
|
||||
|
||||
Map<String, int> tagCount = {};
|
||||
Map<String, int> authorCount = {};
|
||||
Map<ImageFavoritesComic, int> comicImageCount = {};
|
||||
Map<ImageFavoritesComic, int> comicMaxPages = {};
|
||||
|
||||
for (var comic in comics) {
|
||||
for (var tag in comic.tags) {
|
||||
String finalTag = tag;
|
||||
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (comic.author != "") {
|
||||
String finalAuthor = comic.author;
|
||||
authorCount[finalAuthor] =
|
||||
(authorCount[finalAuthor] ?? 0) + comic.images.length;
|
||||
}
|
||||
// 小于10页的漫画不统计
|
||||
if (comic.maxPageFromEp < 10) {
|
||||
continue;
|
||||
}
|
||||
comicImageCount[comic] =
|
||||
(comicImageCount[comic] ?? 0) + comic.images.length;
|
||||
comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp;
|
||||
}
|
||||
|
||||
// 按数量排序标签
|
||||
List<String> sortedTags = tagCount.keys.toList()
|
||||
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
|
||||
|
||||
// 按数量排序作者
|
||||
List<String> sortedAuthors = authorCount.keys.toList()
|
||||
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
|
||||
|
||||
// 按收藏数量排序漫画
|
||||
List<MapEntry<ImageFavoritesComic, int>> sortedComicsByNum =
|
||||
comicImageCount.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
validateTag(String tag) {
|
||||
if (tag.startsWith("Category:")) {
|
||||
return false;
|
||||
}
|
||||
return !exceptTags.contains(tag.split(":").last.toLowerCase()) &&
|
||||
!tag.isNum;
|
||||
}
|
||||
|
||||
return ImageFavoritesComputed(
|
||||
sortedTags
|
||||
.where(validateTag)
|
||||
.map((tag) => TextWithCount(tag, tagCount[tag]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedAuthors
|
||||
.map((author) => TextWithCount(author, authorCount[author]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedComicsByNum
|
||||
.map((comic) => TextWithCount(comic.key.title, comic.value))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
ImageFavoritesComic? find(String id, String sourceKey) {
|
||||
var row = _db.select("""
|
||||
select * from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [id, sourceKey]);
|
||||
if (row.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ImageFavoritesComic.fromRow(row.first);
|
||||
}
|
||||
}
|
||||
|
||||
class TextWithCount {
|
||||
final String text;
|
||||
final int count;
|
||||
|
||||
const TextWithCount(this.text, this.count);
|
||||
}
|
||||
|
||||
class ImageFavoritesComputed {
|
||||
/// 基于收藏的标签数排序
|
||||
final List<TextWithCount> tags;
|
||||
|
||||
/// 基于收藏的作者数排序
|
||||
final List<TextWithCount> authors;
|
||||
|
||||
/// 基于喜欢的图片数排序
|
||||
final List<TextWithCount> comics;
|
||||
|
||||
/// 计算后的图片收藏数据
|
||||
const ImageFavoritesComputed(
|
||||
this.tags,
|
||||
this.authors,
|
||||
this.comics,
|
||||
);
|
||||
|
||||
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
|
||||
}
|
@@ -6,6 +6,7 @@ import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
|
||||
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
extends ImageProvider<T> {
|
||||
@@ -126,10 +127,11 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
Log.error("Image Loading", e, s);
|
||||
rethrow;
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
|
146
lib/foundation/image_provider/image_favorites_provider.dart
Normal file
146
lib/foundation/image_provider/image_favorites_provider.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
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/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import '../history.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'image_favorites_provider.dart' as image_provider;
|
||||
|
||||
class ImageFavoritesProvider
|
||||
extends BaseImageProvider<image_provider.ImageFavoritesProvider> {
|
||||
/// Image provider for imageFavorites
|
||||
const ImageFavoritesProvider(this.imageFavorite);
|
||||
|
||||
final ImageFavorite imageFavorite;
|
||||
|
||||
int get page => imageFavorite.page;
|
||||
|
||||
String get sourceKey => imageFavorite.sourceKey;
|
||||
|
||||
String get cid => imageFavorite.id;
|
||||
|
||||
String get eid => imageFavorite.eid;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent>? chunkEvents) async {
|
||||
var imageKey = imageFavorite.imageKey;
|
||||
var localImage = await getImageFromLocal();
|
||||
if (localImage != null) {
|
||||
return localImage;
|
||||
}
|
||||
var cacheImage = await readFromCache();
|
||||
if (cacheImage != null) {
|
||||
return cacheImage;
|
||||
}
|
||||
var gotImageKey = false;
|
||||
if (imageKey == "") {
|
||||
imageKey = await getImageKey();
|
||||
gotImageKey = true;
|
||||
}
|
||||
Uint8List image;
|
||||
try {
|
||||
image = await getImageFromNetwork(imageKey, chunkEvents);
|
||||
} catch (e) {
|
||||
if (gotImageKey) {
|
||||
rethrow;
|
||||
} else {
|
||||
imageKey = await getImageKey();
|
||||
image = await getImageFromNetwork(imageKey, chunkEvents);
|
||||
}
|
||||
}
|
||||
await writeToCache(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
Future<void> writeToCache(Uint8List image) async {
|
||||
var fileName = md5.convert(key.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (!file.existsSync()) {
|
||||
file.createSync(recursive: true);
|
||||
}
|
||||
await file.writeAsBytes(image);
|
||||
}
|
||||
|
||||
Future<Uint8List?> readFromCache() async {
|
||||
var fileName = md5.convert(key.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (!file.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
return await file.readAsBytes();
|
||||
}
|
||||
|
||||
/// Delete a image favorite cache
|
||||
static Future<void> deleteFromCache(ImageFavorite imageFavorite) async {
|
||||
var fileName = md5.convert(imageFavorite.imageKey.codeUnits).toString();
|
||||
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> getImageFromLocal() async {
|
||||
var localComic =
|
||||
LocalManager().find(sourceKey, ComicType.fromKey(sourceKey));
|
||||
if (localComic == null) {
|
||||
return null;
|
||||
}
|
||||
var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1;
|
||||
if (epIndex == -1 && localComic.hasChapters) {
|
||||
return null;
|
||||
}
|
||||
var images = await LocalManager().getImages(
|
||||
sourceKey,
|
||||
ComicType.fromKey(sourceKey),
|
||||
epIndex,
|
||||
);
|
||||
var data = await File(images[page]).readAsBytes();
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Uint8List> getImageFromNetwork(
|
||||
String imageKey, StreamController<ImageChunkEvent>? chunkEvents) async {
|
||||
await for (var progress
|
||||
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||
if (chunkEvents != null) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
}
|
||||
if (progress.imageBytes != null) {
|
||||
return progress.imageBytes!;
|
||||
}
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
|
||||
Future<String> getImageKey() async {
|
||||
String sourceKey = imageFavorite.sourceKey;
|
||||
String cid = imageFavorite.id;
|
||||
String eid = imageFavorite.eid;
|
||||
var page = imageFavorite.page;
|
||||
var comicSource = ComicSource.find(sourceKey);
|
||||
if (comicSource == null) {
|
||||
throw "Error: Comic source not found.";
|
||||
}
|
||||
var res = await comicSource.loadComicPages!(cid, eid);
|
||||
return res.data[page - 1];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ImageFavoritesProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key =>
|
||||
"ImageFavorites ${imageFavorite.imageKey}@${imageFavorite.sourceKey}@${imageFavorite.id}@${imageFavorite.eid}";
|
||||
}
|
@@ -22,7 +22,7 @@ class LocalFavoriteImageProvider
|
||||
static void delete(String id, int intKey) {
|
||||
var fileName = (id + intKey.toString()).hashCode.toString();
|
||||
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
|
||||
if(file.existsSync()) {
|
||||
if (file.existsSync()) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class LocalFavoriteImageProvider
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
expectedTotalBytes: progress.totalBytes,
|
||||
));
|
||||
if(progress.imageBytes != null) {
|
||||
if (progress.imageBytes != null) {
|
||||
var data = progress.imageBytes!;
|
||||
await file.writeAsBytes(data);
|
||||
return data;
|
||||
@@ -52,7 +52,8 @@ class LocalFavoriteImageProvider
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
Future<LocalFavoriteImageProvider> obtainKey(
|
||||
ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
|
@@ -36,6 +36,8 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
bool get hasChapters => chapters != null;
|
||||
|
||||
/// relative path to the cover image
|
||||
@override
|
||||
final String cover;
|
||||
@@ -119,6 +121,8 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
author: subtitle,
|
||||
tags: tags,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -266,7 +270,7 @@ class LocalManager with ChangeNotifier {
|
||||
String findValidId(ComicType type) {
|
||||
final res = _db.select(
|
||||
'''
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
ORDER BY CAST(id AS INTEGER) DESC
|
||||
LIMIT 1;
|
||||
''',
|
||||
@@ -318,8 +322,8 @@ class LocalManager with ChangeNotifier {
|
||||
List<LocalComic> getComics(LocalSortType sortType) {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
ORDER BY
|
||||
${sortType.value == 'name' ? 'title' : 'created_at'}
|
||||
ORDER BY
|
||||
${sortType.value == 'name' ? 'title' : 'created_at'}
|
||||
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
|
||||
;
|
||||
''');
|
||||
@@ -361,7 +365,7 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
LocalComic? findByName(String name) {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
SELECT * FROM comics
|
||||
WHERE title = ? OR directory = ?;
|
||||
''', [name, name]);
|
||||
if (res.isEmpty) {
|
||||
@@ -385,7 +389,7 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
var comic = find(id, type) ?? (throw "Comic Not Found");
|
||||
var directory = Directory(comic.baseDir);
|
||||
if (comic.chapters != null) {
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
|
Reference in New Issue
Block a user