comic page

This commit is contained in:
nyne
2024-10-04 21:56:15 +08:00
parent 2772289a19
commit 07dbf6e6af
7 changed files with 915 additions and 178 deletions

View File

@@ -14,7 +14,10 @@ class ComicTile extends StatelessWidget {
final String? badge; final String? badge;
void onTap() {} void onTap() {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
}
void onLongPress() {} void onLongPress() {}
@@ -721,8 +724,7 @@ class _ComicListState extends State<ComicList> {
if (widget.leadingSliver != null) widget.leadingSliver!, if (widget.leadingSliver != null) widget.leadingSliver!,
buildSliverPageSelector(), buildSliverPageSelector(),
SliverGridComics(comics: data[page] ?? const []), SliverGridComics(comics: data[page] ?? const []),
if(data[page]!.length > 6) if (data[page]!.length > 6) buildSliverPageSelector(),
buildSliverPageSelector(),
if (widget.trailingSliver != null) widget.trailingSliver!, if (widget.trailingSliver != null) widget.trailingSliver!,
], ],
); );

View File

@@ -2,7 +2,6 @@ library components;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
@@ -23,8 +22,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart'; import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
part 'image.dart'; part 'image.dart';

View File

@@ -64,6 +64,8 @@ class _App {
'green' => Colors.green, 'green' => Colors.green,
'orange' => Colors.orange, 'orange' => Colors.orange,
'blue' => Colors.blue, 'blue' => Colors.blue,
'yellow' => Colors.yellow,
'cyan' => Colors.cyan,
_ => Colors.blue, _ => Colors.blue,
}; };
} }

View File

@@ -43,6 +43,8 @@ typedef GetImageLoadingConfigFunc = Map<String, dynamic> Function(
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function( typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?; String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(String comicId, String? next);
class ComicSource { class ComicSource {
static final List<ComicSource> _sources = []; static final List<ComicSource> _sources = [];
@@ -140,6 +142,8 @@ class ComicSource {
/// Load comic info. /// Load comic info.
final LoadComicFunc? loadComicInfo; final LoadComicFunc? loadComicInfo;
final ComicThumbnailLoader? loadComicThumbnail;
/// Load comic pages. /// Load comic pages.
final LoadComicPagesFunc? loadComicPages; final LoadComicPagesFunc? loadComicPages;
@@ -216,6 +220,7 @@ class ComicSource {
this.searchPageData, this.searchPageData,
this.settings, this.settings,
this.loadComicInfo, this.loadComicInfo,
this.loadComicThumbnail,
this.loadComicPages, this.loadComicPages,
this.getImageLoadingConfig, this.getImageLoadingConfig,
this.getThumbnailLoadingConfig, this.getThumbnailLoadingConfig,
@@ -237,6 +242,7 @@ class ComicSource {
searchPageData = null, searchPageData = null,
settings = [], settings = [],
loadComicInfo = null, loadComicInfo = null,
loadComicThumbnail = null,
loadComicPages = null, loadComicPages = null,
getImageLoadingConfig = null, getImageLoadingConfig = null,
getThumbnailLoadingConfig = null, getThumbnailLoadingConfig = null,
@@ -338,8 +344,8 @@ class SearchPageData {
/// If this is not null, the default value of search options will be first element. /// If this is not null, the default value of search options will be first element.
final List<SearchOptions>? searchOptions; final List<SearchOptions>? searchOptions;
final Widget Function(BuildContext, List<String> initialValues, void Function(List<String>))? final Widget Function(BuildContext, List<String> initialValues,
customOptionsBuilder; void Function(List<String>))? customOptionsBuilder;
final Widget Function(String keyword, List<String> options)? final Widget Function(String keyword, List<String> options)?
overrideSearchResultBuilder; overrideSearchResultBuilder;
@@ -399,7 +405,8 @@ class Comic {
final int? maxPage; final int? maxPage;
const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, this.description, this.sourceKey, this.maxPage); const Comic(this.title, this.cover, this.id, this.subtitle, this.tags,
this.description, this.sourceKey, this.maxPage);
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -443,12 +450,7 @@ class ComicDetails with HistoryMixin {
final List<String>? thumbnails; final List<String>? thumbnails;
final Future<Res<List<String>>> Function(String id, int page)? final List<Comic>? recommend;
thumbnailLoader;
final int thumbnailMaxPage;
final List<Comic>? suggestions;
final String sourceKey; final String sourceKey;
@@ -458,36 +460,17 @@ class ComicDetails with HistoryMixin {
final String? subId; final String? subId;
const ComicDetails( final bool? isLiked;
this.title,
this.subTitle,
this.cover,
this.description,
this.tags,
this.chapters,
this.thumbnails,
this.thumbnailLoader,
this.thumbnailMaxPage,
this.suggestions,
this.sourceKey,
this.comicId,
{this.isFavorite,
this.subId});
Map<String, dynamic> toJson() { final int? likesCount;
return {
"title": title, final int? commentsCount;
"subTitle": subTitle,
"cover": cover, final String? uploader;
"description": description,
"tags": tags, final String? uploadTime;
"chapters": chapters,
"sourceKey": sourceKey, final String? updateTime;
"comicId": comicId,
"isFavorite": isFavorite,
"subId": subId,
};
}
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) { static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{}; var res = <String, List<String>>{};
@@ -503,15 +486,23 @@ class ComicDetails with HistoryMixin {
cover = json["cover"], cover = json["cover"],
description = json["description"], description = json["description"],
tags = _generateMap(json["tags"]), tags = _generateMap(json["tags"]),
chapters = Map<String, String>.from(json["chapters"]), chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
sourceKey = json["sourceKey"], sourceKey = json["sourceKey"],
comicId = json["comicId"], comicId = json["comicId"],
thumbnails = null, thumbnails = ListOrNull.from(json["thumbnails"]),
thumbnailLoader = null, recommend = (json["recommend"] as List?)
thumbnailMaxPage = 0, ?.map((e) => Comic.fromJson(e, json["sourceKey"]))
suggestions = null, .toList(),
isFavorite = json["isFavorite"], isFavorite = json["isFavorite"],
subId = json["subId"]; subId = json["subId"],
likesCount = json["likesCount"],
isLiked = json["isLiked"],
commentsCount = json["commentsCount"],
uploader = json["uploader"],
uploadTime = json["uploadTime"],
updateTime = json["updateTime"];
@override @override
HistoryType get historyType => HistoryType(sourceKey.hashCode); HistoryType get historyType => HistoryType(sourceKey.hashCode);

View File

@@ -55,20 +55,21 @@ class ComicSourceParser {
String? _name; String? _name;
Future<ComicSource> createAndParse(String js, String fileName) async{ Future<ComicSource> createAndParse(String js, String fileName) async {
if(!fileName.endsWith("js")){ if (!fileName.endsWith("js")) {
fileName = "$fileName.js"; fileName = "$fileName.js";
} }
var file = File(FilePath.join(App.dataPath, "comic_source", fileName)); var file = File(FilePath.join(App.dataPath, "comic_source", fileName));
if(file.existsSync()){ if (file.existsSync()) {
int i = 0; int i = 0;
while(file.existsSync()){ while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source", "${fileName.split('.').first}($i).js")); file = File(FilePath.join(App.dataPath, "comic_source",
"${fileName.split('.').first}($i).js"));
i++; i++;
} }
} }
await file.writeAsString(js); await file.writeAsString(js);
try{ try {
return await parse(js, file.path); return await parse(js, file.path);
} catch (e) { } catch (e) {
await file.delete(); await file.delete();
@@ -78,9 +79,12 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async { Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n"); js = js.replaceAll("\r\n", "\n");
var line1 = js.split('\n') var line1 = js
.split('\n')
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty); .firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
if(line1 == null || !line1.startsWith("class ") || !line1.contains("extends ComicSource")){ if (line1 == null ||
!line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) {
throw ComicSourceParseException("Invalid Content"); throw ComicSourceParseException("Invalid Content");
} }
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
@@ -91,22 +95,24 @@ class ComicSourceParser {
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
"""); """);
_name = JsEngine().runCode("this['temp'].name") _name = JsEngine().runCode("this['temp'].name") ??
?? (throw ComicSourceParseException('name is required')); (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") var key = JsEngine().runCode("this['temp'].key") ??
?? (throw ComicSourceParseException('key is required')); (throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") var version = JsEngine().runCode("this['temp'].version") ??
?? (throw ComicSourceParseException('version is required')); (throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion"); var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url"); var url = JsEngine().runCode("this['temp'].url");
var matchBriefIdRegex = JsEngine().runCode("this['temp'].comic.matchBriefIdRegex"); var matchBriefIdRegex =
if(minAppVersion != null){ JsEngine().runCode("this['temp'].comic.matchBriefIdRegex");
if(compareSemVer(minAppVersion, App.version.split('-').first)){ if (minAppVersion != null) {
throw ComicSourceParseException("minAppVersion $minAppVersion is required"); if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException(
"minAppVersion $minAppVersion is required");
} }
} }
for(var source in ComicSource.all()){ for (var source in ComicSource.all()) {
if(source.key == key){ if (source.key == key) {
throw ComicSourceParseException("key($key) already exists"); throw ComicSourceParseException("key($key) already exists");
} }
} }
@@ -120,10 +126,10 @@ class ComicSourceParser {
final account = _loadAccountConfig(); final account = _loadAccountConfig();
final explorePageData = _loadExploreData(); final explorePageData = _loadExploreData();
final categoryPageData = _loadCategoryData(); final categoryPageData = _loadCategoryData();
final categoryComicsData = final categoryComicsData = _loadCategoryComicsData();
_loadCategoryComicsData();
final searchData = _loadSearchData(); final searchData = _loadSearchData();
final loadComicFunc = _parseLoadComicFunc(); final loadComicFunc = _parseLoadComicFunc();
final loadComicThumbnailFunc = _parseThumbnailLoader();
final loadComicPagesFunc = _parseLoadComicPagesFunc(); final loadComicPagesFunc = _parseLoadComicPagesFunc();
final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc(); final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc();
final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc(); final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc();
@@ -131,26 +137,28 @@ class ComicSourceParser {
final commentsLoader = _parseCommentsLoader(); final commentsLoader = _parseCommentsLoader();
final sendCommentFunc = _parseSendCommentFunc(); final sendCommentFunc = _parseSendCommentFunc();
var source = ComicSource( var source = ComicSource(
_name!, _name!,
key, key,
account, account,
categoryPageData, categoryPageData,
categoryComicsData, categoryComicsData,
favoriteData, favoriteData,
explorePageData, explorePageData,
searchData, searchData,
[], [],
loadComicFunc, loadComicFunc,
loadComicPagesFunc, loadComicThumbnailFunc,
getImageLoadingConfigFunc, loadComicPagesFunc,
getThumbnailLoadingConfigFunc, getImageLoadingConfigFunc,
matchBriefIdRegex, getThumbnailLoadingConfigFunc,
filePath, matchBriefIdRegex,
url ?? "", filePath,
version ?? "1.0.0", url ?? "",
commentsLoader, version ?? "1.0.0",
sendCommentFunc); commentsLoader,
sendCommentFunc,
);
await source.loadData(); await source.loadData();
@@ -168,7 +176,7 @@ class ComicSourceParser {
} }
} }
bool _checkExists(String index){ bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null " return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined"); "&& ComicSource.sources.$_key.$index !== undefined");
} }
@@ -198,16 +206,12 @@ class ComicSourceParser {
} }
} }
void logout(){ void logout() {
JsEngine().runCode("ComicSource.sources.$_key.account.logout()"); JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
} }
return AccountConfig( return AccountConfig(login, _getValue("account.login.website"),
login, _getValue("account.registerWebsite"), logout);
_getValue("account.login.website"),
_getValue("account.registerWebsite"),
logout
);
} }
List<ExplorePageData> _loadExploreData() { List<ExplorePageData> _loadExploreData() {
@@ -216,7 +220,7 @@ class ComicSourceParser {
} }
var length = JsEngine().runCode("ComicSource.sources.$_key.explore.length"); var length = JsEngine().runCode("ComicSource.sources.$_key.explore.length");
var pages = <ExplorePageData>[]; var pages = <ExplorePageData>[];
for (int i=0; i<length; i++) { for (int i = 0; i < length; i++) {
final String title = _getValue("explore[$i].title"); final String title = _getValue("explore[$i].title");
final String type = _getValue("explore[$i].type"); final String type = _getValue("explore[$i].type");
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart; Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
@@ -226,12 +230,13 @@ class ComicSourceParser {
try { try {
var res = await JsEngine() var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()"); .runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(List.from(res.keys.map((e) => ExplorePagePart( return Res(List.from(res.keys
e, .map((e) => ExplorePagePart(
(res[e] as List) e,
.map<Comic>((e) => Comic.fromJson(e, _key!)) (res[e] as List)
.toList(), .map<Comic>((e) => Comic.fromJson(e, _key!))
null)) .toList(),
null))
.toList())); .toList()));
} catch (e, s) { } catch (e, s) {
Log.error("Data Analysis", "$e\n$s"); Log.error("Data Analysis", "$e\n$s");
@@ -241,11 +246,11 @@ class ComicSourceParser {
} else if (type == "multiPageComicList") { } else if (type == "multiPageComicList") {
loadPage = (int page) async { loadPage = (int page) async {
try { try {
var res = await JsEngine() var res = await JsEngine().runCode(
.runCode("ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
return Res( return Res(
List.generate(res["comics"].length, List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)), (index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]); subData: res["maxPage"]);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
@@ -317,18 +322,16 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; map[key] = value;
} }
options.add( options.add(CategoryComicsOptions(
CategoryComicsOptions( map,
map, List.from(element["notShowWhen"] ?? []),
List.from(element["notShowWhen"] ?? []), element["showWhen"] == null ? null : List.from(element["showWhen"])));
element["showWhen"] == null ? null : List.from(element["showWhen"])
));
} }
RankingData? rankingData; RankingData? rankingData;
if(_checkExists("categoryComics.ranking")){ if (_checkExists("categoryComics.ranking")) {
var options = <String, String>{}; var options = <String, String>{};
for(var option in _getValue("categoryComics.ranking.options")){ for (var option in _getValue("categoryComics.ranking.options")) {
if(option.isEmpty || !option.contains("-")){ if (option.isEmpty || !option.contains("-")) {
continue; continue;
} }
var split = option.split("-"); var split = option.split("-");
@@ -336,7 +339,7 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
options[key] = value; options[key] = value;
} }
rankingData = RankingData(options, (option, page) async{ rankingData = RankingData(options, (option, page) async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.ranking.load( ComicSource.sources.$_key.categoryComics.ranking.load(
@@ -344,7 +347,7 @@ class ComicSourceParser {
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)), (index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]); subData: res["maxPage"]);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
@@ -412,27 +415,10 @@ class ComicSourceParser {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)}) ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)})
"""); """);
var tags = <String, List<String>>{}; if (res is! Map<String, dynamic>) throw "Invalid data";
(res["tags"] as Map<String, dynamic>?) res['comicId'] = id;
?.forEach((key, value) => tags[key] = List.from(value ?? const [])); res['sourceKey'] = _key;
return Res(ComicDetails( return Res(ComicDetails.fromJson(res));
res["title"],
res["subTitle"],
res["cover"],
res["description"],
tags,
res["chapters"] == null ? null : Map.from(res["chapters"]),
ListOrNull.from(res["thumbnails"]),
// TODO: implement thumbnailLoader
null,
res["thumbnailMaxPage"] ?? 1,
(res["recommend"] as List?)
?.map((e) => Comic.fromJson(e, _key!))
.toList(),
_key!,
id,
isFavorite: res["isFavorite"],
subId: res["subId"],));
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -459,8 +445,8 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async{ Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if(!ComicSource.find(_key!)!.isLogged){ if (!ComicSource.find(_key!)!.isLogged) {
return const Res.error("Not login"); return const Res.error("Not login");
} }
var res = await func(); var res = await func();
@@ -493,7 +479,7 @@ class ComicSourceParser {
} }
Future<Res<List<Comic>>> loadComic(int page, [String? folder]) async { Future<Res<List<Comic>>> loadComic(int page, [String? folder]) async {
Future<Res<List<Comic>>> func() async{ Future<Res<List<Comic>>> func() async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.favorites.loadComics( ComicSource.sources.$_key.favorites.loadComics(
@@ -501,13 +487,14 @@ class ComicSourceParser {
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)), (index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]); subData: res["maxPage"]);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
} }
} }
return retryZone(func); return retryZone(func);
} }
@@ -517,15 +504,15 @@ class ComicSourceParser {
Future<Res<bool>> Function(String key)? deleteFolder; Future<Res<bool>> Function(String key)? deleteFolder;
if(multiFolder) { if (multiFolder) {
loadFolders = ([String? comicId]) async { loadFolders = ([String? comicId]) async {
Future<Res<Map<String, String>>> func() async{ Future<Res<Map<String, String>>> func() async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.favorites.loadFolders(${jsonEncode(comicId)}) ComicSource.sources.$_key.favorites.loadFolders(${jsonEncode(comicId)})
"""); """);
List<String>? subData; List<String>? subData;
if(res["favorited"] != null){ if (res["favorited"] != null) {
subData = List.from(res["favorited"]); subData = List.from(res["favorited"]);
} }
return Res(Map.from(res["folders"]), subData: subData); return Res(Map.from(res["folders"]), subData: subData);
@@ -562,19 +549,19 @@ class ComicSourceParser {
} }
return FavoriteData( return FavoriteData(
key: _key!, key: _key!,
title: _name!, title: _name!,
multiFolder: multiFolder, multiFolder: multiFolder,
loadComic: loadComic, loadComic: loadComic,
loadFolders: loadFolders, loadFolders: loadFolders,
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc, addOrDelFavorite: addOrDelFavFunc,
); );
} }
CommentsLoader? _parseCommentsLoader(){ CommentsLoader? _parseCommentsLoader() {
if(!_checkExists("comic.loadComments")) return null; if (!_checkExists("comic.loadComments")) return null;
return (id, subId, page, replyTo) async { return (id, subId, page, replyTo) async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
@@ -582,9 +569,10 @@ class ComicSourceParser {
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)}) ${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
"""); """);
return Res( return Res(
(res["comments"] as List).map((e) => Comment( (res["comments"] as List)
e["userName"], e["avatar"], e["content"], e["time"], e["replyCount"], e["id"].toString() .map((e) => Comment(e["userName"], e["avatar"], e["content"],
)).toList(), e["time"], e["replyCount"], e["id"].toString()))
.toList(),
subData: res["maxPage"]); subData: res["maxPage"]);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
@@ -593,10 +581,10 @@ class ComicSourceParser {
}; };
} }
SendCommentFunc? _parseSendCommentFunc(){ SendCommentFunc? _parseSendCommentFunc() {
if(!_checkExists("comic.sendComment")) return null; if (!_checkExists("comic.sendComment")) return null;
return (id, subId, content, replyTo) async { return (id, subId, content, replyTo) async {
Future<Res<bool>> func() async{ Future<Res<bool>> func() async {
try { try {
await JsEngine().runCode(""" await JsEngine().runCode("""
ComicSource.sources.$_key.comic.sendComment( ComicSource.sources.$_key.comic.sendComment(
@@ -608,8 +596,9 @@ class ComicSourceParser {
return Res.error(e.toString()); return Res.error(e.toString());
} }
} }
var res = await func(); var res = await func();
if(res.error && res.errorMessage!.contains("Login expired")){ if (res.error && res.errorMessage!.contains("Login expired")) {
var reLoginRes = await ComicSource.find(_key!)!.reLogin(); var reLoginRes = await ComicSource.find(_key!)!.reLogin();
if (!reLoginRes) { if (!reLoginRes) {
return const Res.error("Login expired and re-login failed"); return const Res.error("Login expired and re-login failed");
@@ -621,8 +610,8 @@ class ComicSourceParser {
}; };
} }
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc(){ GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() {
if(!_checkExists("comic.onImageLoad")){ if (!_checkExists("comic.onImageLoad")) {
return null; return null;
} }
return (imageKey, comicId, ep) { return (imageKey, comicId, ep) {
@@ -633,19 +622,36 @@ class ComicSourceParser {
}; };
} }
GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc(){ GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc() {
if(!_checkExists("comic.onThumbnailLoad")){ if (!_checkExists("comic.onThumbnailLoad")) {
return null; return null;
} }
return (imageKey) { return (imageKey) {
var res = JsEngine().runCode(""" var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onThumbnailLoad(${jsonEncode(imageKey)}) ComicSource.sources.$_key.comic.onThumbnailLoad(${jsonEncode(imageKey)})
"""); """);
if(res is! Map) { if (res is! Map) {
Log.error("Network", "function onThumbnailLoad return invalid data"); Log.error("Network", "function onThumbnailLoad return invalid data");
throw "function onThumbnailLoad return invalid data"; throw "function onThumbnailLoad return invalid data";
} }
return res as Map<String, dynamic>; return res as Map<String, dynamic>;
}; };
} }
ComicThumbnailLoader? _parseThumbnailLoader() {
if (!_checkExists("comic.loadThumbnail")) {
return null;
}
return (id, next) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.loadThumbnail(${jsonEncode(id)}, ${jsonEncode(next)})
""");
return Res(List<String>.from(res['thumbnails']), subData: res['next']);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
} }

View File

@@ -34,4 +34,12 @@ extension Navigation on BuildContext {
void showMessage({required String message}) { void showMessage({required String message}) {
showToast(message: message, context: this); showToast(message: message, context: this);
} }
Color useBackgroundColor(MaterialColor color) {
return color[brightness == Brightness.light ? 100 : 800]!;
}
Color useTextColor(MaterialColor color) {
return color[brightness == Brightness.light ? 800 : 100]!;
}
} }

729
lib/pages/comic_page.dart Normal file
View File

@@ -0,0 +1,729 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.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/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/res.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey});
final String id;
final String sourceKey;
@override
State<ComicPage> createState() => _ComicPageState();
}
class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions {
bool showAppbarTitle = false;
var scrollController = ScrollController();
@override
void initState() {
scrollController.addListener(onScroll);
super.initState();
}
@override
void update() {
setState(() {});
}
@override
ComicDetails get comic => data!;
void onScroll() {
if (scrollController.offset > 100) {
if (!showAppbarTitle) {
setState(() {
showAppbarTitle = true;
});
}
} else {
if (showAppbarTitle) {
setState(() {
showAppbarTitle = false;
});
}
}
}
@override
Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView(
controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildThumbnails(),
buildRecommend(),
],
);
}
@override
Future<Res<ComicDetails>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
isAddToLocalFav = LocalFavoritesManager().isExist(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history = await HistoryManager()
.find(widget.id, ComicType(widget.sourceKey.hashCode));
return comicSource!.loadComicInfo!(widget.id);
}
Iterable<Widget> buildTitle() sync* {
yield SliverAppbar(
title: AnimatedOpacity(
opacity: showAppbarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(comic.title),
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
],
);
yield Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
),
width: double.infinity,
height: double.infinity,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(comic.title, style: ts.s18),
if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
).toSliver();
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
return SliverToBoxAdapter(
child: Column(
children: [
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
if(history != null && (history!.ep > 1 || history!.page > 1))
_ActionButton(
icon: const Icon(Icons.menu_book),
text: 'Continue'.tl,
onPressed: continueRead,
iconColor: context.useTextColor(Colors.yellow),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.play_circle_outline),
text: 'Read'.tl,
onPressed: read,
iconColor: context.useTextColor(Colors.orange),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.download),
text: 'Download'.tl,
onPressed: download,
iconColor: context.useTextColor(Colors.cyan),
),
if (data!.isLiked != null)
_ActionButton(
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: data!.isLiked,
text: (data!.likesCount ??
(comic.isLiked! ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
),
_ActionButton(
icon: const Icon(Icons.bookmark_border),
activeIcon: const Icon(Icons.bookmark),
isActive: (data!.isFavorite ?? false) || isAddToLocalFav,
text: 'Favorite'.tl,
isLoading: isFavoriting,
onPressed: favoriteOrUnfavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
isLoading: isFavoriting,
onPressed: favoriteOrUnfavorite,
iconColor: context.useTextColor(Colors.green),
),
_ActionButton(
icon: const Icon(Icons.share),
text: 'Share'.tl,
onPressed: share,
iconColor: context.useTextColor(Colors.blue),
),
],
).fixHeight(48),
if (isMobile)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () {},
child: Text("Download".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(onPressed: read, child: Text("Read".tl)),
)
],
).paddingHorizontal(16).paddingVertical(8),
const Divider(),
],
).paddingTop(16),
);
}
Widget buildDescription() {
if (comic.description == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!),
),
const SizedBox(height: 16),
const Divider(),
],
),
);
}
Widget buildInfo() {
if (comic.tags.isEmpty &&
comic.uploader == null &&
comic.uploadTime == null &&
comic.uploadTime == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
int i = 0;
Widget buildTag({
required String text,
VoidCallback? onTap,
bool isTitle = false,
}) {
Color color;
if (isTitle) {
const colors = [
Colors.blue,
Colors.cyan,
Colors.red,
Colors.pink,
Colors.purple,
Colors.indigo,
Colors.teal,
Colors.green,
Colors.lime,
Colors.yellow,
];
color = context.useBackgroundColor(colors[(i++) % (colors.length)]);
} else {
color = context.colorScheme.surfaceContainer;
}
final borderRadius = BorderRadius.circular(12);
const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
if (onTap != null) {
return Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Text(text).padding(padding),
),
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding),
);
}
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: children,
).paddingHorizontal(16).paddingBottom(8);
}
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
for (var e in comic.tags.entries)
buildWrap(
children: [
buildTag(text: e.key, isTitle: true),
for (var tag in e.value)
buildTag(text: tag, onTap: () => onTagTap(tag, e.key)),
],
),
if (comic.uploader != null)
buildWrap(
children: [
buildTag(text: 'Uploader'.tl, isTitle: true),
buildTag(text: comic.uploader!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comicSource.name),
],
),
const SizedBox(height: 12),
const Divider(),
],
),
);
}
Widget buildChapters() {
if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicChapters();
}
Widget buildThumbnails() {
if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicThumbnails();
}
Widget buildRecommend() {
if (comic.recommend == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
}
}
// TODO: Implement the _ComicPageActions mixin
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? history;
bool isLiking = false;
void likeOrUnlike() {}
bool isAddToLocalFav = false;
bool isFavoriting = false;
void favoriteOrUnfavorite() {}
void share() {}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {}
void continueRead() {}
void download() {}
void onTagTap(String tag, String namespace) {}
void showMoreActions() {}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.text,
required this.onPressed,
this.activeIcon,
this.isActive,
this.isLoading,
this.iconColor,
});
final Widget icon;
final Widget? activeIcon;
final bool? isActive;
final String text;
final void Function() onPressed;
final bool? isLoading;
final Color? iconColor;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading ?? false)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 1.8),
)
else
(isActive ?? false) ? (activeIcon ?? icon) : icon,
const SizedBox(width: 8),
Text(text),
],
).paddingHorizontal(16),
),
),
);
}
}
class _ComicChapters extends StatefulWidget {
const _ComicChapters();
@override
State<_ComicChapters> createState() => _ComicChaptersState();
}
class _ComicChaptersState extends State<_ComicChapters> {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final eps = state.comic.chapters!;
int length = eps.length;
if (!showAll) {
length = math.min(length, 20);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate:
SliverChildBuilderDelegate(childCount: length, (context, i) {
if (reverse) {
i = eps.length - i - 1;
}
var key = eps.keys.elementAt(i);
var value = eps[key]!;
bool visited =
(state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Material(
elevation: 5,
color: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
borderRadius: const BorderRadius.all(Radius.circular(12)),
shadowColor: Colors.transparent,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
visited ? context.colorScheme.outline : null),
),
),
),
),
onTap: () => state.read(i + 1),
),
);
}),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, itemHeight: 48),
),
if (eps.length > 20 && !showAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: FilledButton.tonal(
style: ButtonStyle(
shape: WidgetStateProperty.all(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)))),
),
onPressed: () {
setState(() {
showAll = true;
});
},
child: Text("${"Show all".tl} (${eps.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = false;
String? next;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
bool isLoading = false;
void loadNext() async {
if(state.comicSource.loadComicThumbnail == null || isLoading) return;
if(!isInitialLoading && next == null) {
return;
}
setState(() {
isLoading = true;
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if(res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
}
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if(thumbnails.isEmpty) {
Future.microtask(loadNext);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1) {
loadNext();
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
width: double.infinity,
height: double.infinity,
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage(
image: CachedImageProvider(
thumbnails[index],
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
}),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.65,
),
),
if(isLoading)
const SliverToBoxAdapter(
child: ListLoadingIndicator(),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}