add support for novel image

This commit is contained in:
wgh19
2024-05-20 17:42:54 +08:00
parent 93ce4eb94b
commit c51df1efde
4 changed files with 323 additions and 172 deletions

View File

@@ -45,10 +45,10 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
Future<ui.Codec> _loadBufferAsync( Future<ui.Codec> _loadBufferAsync(
T key, T key,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) async { ) async {
try { try {
int retryTime = 1; int retryTime = 1;
@@ -83,11 +83,11 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
} }
if(stop) { if (stop) {
throw Exception("Image loading is stopped"); throw Exception("Image loading is stopped");
} }
if(data!.isEmpty) { if (data!.isEmpty) {
throw Exception("Empty image data"); throw Exception("Empty image data");
} }
@@ -147,13 +147,13 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
String get key => url; String get key => url;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async{ Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
chunkEvents.add(const ImageChunkEvent( chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 0, cumulativeBytesLoaded: 0,
expectedTotalBytes: 1, expectedTotalBytes: 1,
)); ));
var cached = await CacheManager().findCache(key); var cached = await CacheManager().findCache(key);
if(cached != null) { if (cached != null) {
chunkEvents.add(const ImageChunkEvent( chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 1, cumulativeBytesLoaded: 1,
expectedTotalBytes: 1, expectedTotalBytes: 1,
@@ -161,30 +161,28 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
return await File(cached).readAsBytes(); return await File(cached).readAsBytes();
} }
var dio = AppDio(); var dio = AppDio();
final time = DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now()); final time =
DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString(); final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString();
var res = await dio.get<ResponseBody>( var res = await dio.get<ResponseBody>(url,
url, options: Options(
options: Options( responseType: ResponseType.stream,
responseType: ResponseType.stream, validateStatus: (status) => status != null && status < 500,
validateStatus: (status) => status != null && status < 500, headers: {
headers: { "referer": "https://app-api.pixiv.net/",
"referer": "https://app-api.pixiv.net/", "user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)", "x-client-time": time,
"x-client-time": time, "x-client-hash": hash,
"x-client-hash": hash, "accept-enconding": "gzip",
"accept-enconding": "gzip", }));
} if (res.statusCode != 200) {
)
);
if(res.statusCode != 200) {
throw BadRequestException("Failed to load image: ${res.statusCode}"); throw BadRequestException("Failed to load image: ${res.statusCode}");
} }
var data = <int>[]; var data = <int>[];
var cachingFile = await CacheManager().openWrite(key); var cachingFile = await CacheManager().openWrite(key);
await for (var chunk in res.data!.stream) { await for (var chunk in res.data!.stream) {
var length = res.data!.contentLength+1; var length = res.data!.contentLength + 1;
if(length < data.length) { if (length < data.length) {
length = data.length + 1; length = data.length + 1;
} }
data.addAll(chunk); data.addAll(chunk);
@@ -203,3 +201,71 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
return SynchronousFuture<CachedImageProvider>(this); return SynchronousFuture<CachedImageProvider>(this);
} }
} }
class CachedNovelImageProvider
extends BaseImageProvider<CachedNovelImageProvider> {
final String novelId;
final String imageId;
CachedNovelImageProvider(this.novelId, this.imageId);
@override
String get key => "$novelId/$imageId";
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 0,
expectedTotalBytes: 1,
));
var cached = await CacheManager().findCache(key);
if (cached != null) {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 1,
expectedTotalBytes: 1,
));
return await File(cached).readAsBytes();
}
var urlRes = await Network().getNovelImage(novelId, imageId);
var url = urlRes.data;
var dio = AppDio();
final time =
DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString();
var res = await dio.get<ResponseBody>(url,
options: Options(
responseType: ResponseType.stream,
validateStatus: (status) => status != null && status < 500,
headers: {
"referer": "https://app-api.pixiv.net/",
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
"x-client-time": time,
"x-client-hash": hash,
"accept-enconding": "gzip",
}));
if (res.statusCode != 200) {
throw BadRequestException("Failed to load image: ${res.statusCode}");
}
var data = <int>[];
var cachingFile = await CacheManager().openWrite(key);
await for (var chunk in res.data!.stream) {
var length = res.data!.contentLength + 1;
if (length < data.length) {
length = data.length + 1;
}
data.addAll(chunk);
await cachingFile.writeBytes(chunk);
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: data.length,
expectedTotalBytes: length,
));
}
await cachingFile.close();
return Uint8List.fromList(data);
}
@override
Future<CachedNovelImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<CachedNovelImageProvider>(this);
}
}

View File

@@ -110,9 +110,9 @@ class Network {
contentType: Headers.formUrlEncodedContentType, contentType: Headers.formUrlEncodedContentType,
validateStatus: (i) => true, validateStatus: (i) => true,
headers: headers)); headers: headers));
if(res.statusCode != 200) { if (res.statusCode != 200) {
var data = res.data ?? ""; var data = res.data ?? "";
if(data.contains("Invalid refresh token")) { if (data.contains("Invalid refresh token")) {
throw "Failed to refresh token. Please log out."; throw "Failed to refresh token. Please log out.";
} }
} }
@@ -134,8 +134,7 @@ class Network {
} }
final res = await dio.get<Map<String, dynamic>>(path, final res = await dio.get<Map<String, dynamic>>(path,
queryParameters: query, queryParameters: query,
options: options: Options(headers: headers, validateStatus: (status) => true));
Options(headers: headers, validateStatus: (status) => true));
if (res.statusCode == 200) { if (res.statusCode == 200) {
return Res(res.data!); return Res(res.data!);
} else if (res.statusCode == 400) { } else if (res.statusCode == 400) {
@@ -161,7 +160,7 @@ class Network {
} }
} }
Future<Res<String>> apiGetPlain(String path, Future<Res<String>> apiGetPlain(String path,
{Map<String, dynamic>? query}) async { {Map<String, dynamic>? query}) async {
try { try {
if (!path.startsWith("http")) { if (!path.startsWith("http")) {
@@ -169,8 +168,7 @@ class Network {
} }
final res = await dio.get<String>(path, final res = await dio.get<String>(path,
queryParameters: query, queryParameters: query,
options: options: Options(headers: headers, validateStatus: (status) => true));
Options(headers: headers, validateStatus: (status) => true));
if (res.statusCode == 200) { if (res.statusCode == 200) {
return Res(res.data!); return Res(res.data!);
} else if (res.statusCode == 400) { } else if (res.statusCode == 400) {
@@ -242,14 +240,15 @@ class Network {
} }
} }
static const recommendationUrl = "/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true"; static const recommendationUrl =
"/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true";
Future<Res<List<Illust>>> getRecommendedIllusts() async { Future<Res<List<Illust>>> getRecommendedIllusts() async {
var res = await apiGet(recommendationUrl); var res = await apiGet(recommendationUrl);
if (res.success) { if (res.success) {
return Res((res.data["illusts"] as List) return Res(
.map((e) => Illust.fromJson(e)) (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
.toList(), subData: recommendationUrl); subData: recommendationUrl);
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
} }
@@ -268,9 +267,10 @@ class Network {
} }
} }
Future<Res<List<Illust>>> getUserBookmarks(String uid, [String? nextUrl]) async { Future<Res<List<Illust>>> getUserBookmarks(String uid,
var res = await apiGet(nextUrl ?? [String? nextUrl]) async {
"/v1/user/bookmarks/illust?user_id=$uid&restrict=public"); var res = await apiGet(
nextUrl ?? "/v1/user/bookmarks/illust?user_id=$uid&restrict=public");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -345,7 +345,7 @@ class Network {
} }
} }
Future<Res<List<Illust>>> getIllustsWithNextUrl(String nextUrl) async{ Future<Res<List<Illust>>> getIllustsWithNextUrl(String nextUrl) async {
var res = await apiGet(nextUrl); var res = await apiGet(nextUrl);
if (res.success) { if (res.success) {
return Res( return Res(
@@ -356,12 +356,16 @@ class Network {
} }
} }
Future<Res<List<UserPreview>>> searchUsers(String keyword, [String? nextUrl]) async{ Future<Res<List<UserPreview>>> searchUsers(String keyword,
var path = nextUrl ?? "/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}"; [String? nextUrl]) async {
var path = nextUrl ??
"/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}";
var res = await apiGet(path); var res = await apiGet(path);
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e)).toList(), (res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]); subData: res.data["next_url"]);
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
@@ -369,7 +373,8 @@ class Network {
} }
Future<Res<List<Illust>>> getUserIllusts(String uid) async { Future<Res<List<Illust>>> getUserIllusts(String uid) async {
var res = await apiGet("/v1/user/illusts?filter=for_android&user_id=$uid&type=illust"); var res = await apiGet(
"/v1/user/illusts?filter=for_android&user_id=$uid&type=illust");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -379,19 +384,24 @@ class Network {
} }
} }
Future<Res<List<UserPreview>>> getFollowing(String uid, String type, [String? nextUrl]) async { Future<Res<List<UserPreview>>> getFollowing(String uid, String type,
var path = nextUrl ?? "/v1/user/following?filter=for_android&user_id=$uid&restrict=$type"; [String? nextUrl]) async {
var path = nextUrl ??
"/v1/user/following?filter=for_android&user_id=$uid&restrict=$type";
var res = await apiGet(path); var res = await apiGet(path);
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e)).toList(), (res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]); subData: res.data["next_url"]);
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
} }
} }
Future<Res<List<Illust>>> getFollowingArtworks(String restrict, [String? nextUrl]) async { Future<Res<List<Illust>>> getFollowingArtworks(String restrict,
[String? nextUrl]) async {
var res = await apiGet(nextUrl ?? "/v2/illust/follow?restrict=$restrict"); var res = await apiGet(nextUrl ?? "/v2/illust/follow?restrict=$restrict");
if (res.success) { if (res.success) {
return Res( return Res(
@@ -406,7 +416,9 @@ class Network {
var res = await apiGet("/v1/user/recommended?filter=for_android"); var res = await apiGet("/v1/user/recommended?filter=for_android");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e)).toList(), (res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]); subData: res.data["next_url"]);
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
@@ -415,7 +427,8 @@ class Network {
/// mode: day, week, month, day_male, day_female, week_original, week_rookie, day_manga, week_manga, month_manga, day_r18_manga, day_r18 /// mode: day, week, month, day_male, day_female, week_original, week_rookie, day_manga, week_manga, month_manga, day_r18_manga, day_r18
Future<Res<List<Illust>>> getRanking(String mode, [String? nextUrl]) async { Future<Res<List<Illust>>> getRanking(String mode, [String? nextUrl]) async {
var res = await apiGet(nextUrl ?? "/v1/illust/ranking?filter=for_android&mode=$mode"); var res = await apiGet(
nextUrl ?? "/v1/illust/ranking?filter=for_android&mode=$mode");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -429,7 +442,9 @@ class Network {
var res = await apiGet(nextUrl ?? "/v3/illust/comments?illust_id=$id"); var res = await apiGet(nextUrl ?? "/v3/illust/comments?illust_id=$id");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["comments"] as List).map((e) => Comment.fromJson(e)).toList(), (res.data["comments"] as List)
.map((e) => Comment.fromJson(e))
.toList(),
subData: res.data["next_url"]); subData: res.data["next_url"]);
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
@@ -456,7 +471,8 @@ class Network {
} }
Future<Res<List<Illust>>> getRecommendedMangas() async { Future<Res<List<Illust>>> getRecommendedMangas() async {
var res = await apiGet("/v1/manga/recommended?filter=for_android&include_ranking_illusts=true&include_privacy_policy=true"); var res = await apiGet(
"/v1/manga/recommended?filter=for_android&include_ranking_illusts=true&include_privacy_policy=true");
if (res.success) { if (res.success) {
return Res( return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), (res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -468,13 +484,14 @@ class Network {
Future<Res<List<Illust>>> getHistory(int page) async { Future<Res<List<Illust>>> getHistory(int page) async {
String param = ""; String param = "";
if(page > 1) { if (page > 1) {
param = "?offset=${30*(page-1)}"; param = "?offset=${30 * (page - 1)}";
} }
var res = await apiGet("/v1/user/browsing-history/illusts$param"); var res = await apiGet("/v1/user/browsing-history/illusts$param");
if (res.success) { if (res.success) {
return Res((res.data["illusts"] as List) return Res((res.data["illusts"] as List)
.map((e) => Illust.fromJson(e)).toList()); .map((e) => Illust.fromJson(e))
.toList());
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
} }
@@ -483,19 +500,18 @@ class Network {
Future<List<Tag>> getMutedTags() async { Future<List<Tag>> getMutedTags() async {
var res = await apiGet("/v1/mute/list"); var res = await apiGet("/v1/mute/list");
if (res.success) { if (res.success) {
return res.data["mute_tags"].map<Tag>((e) => return res.data["mute_tags"]
Tag(e["tag"]["name"], e["tag"]["translated_name"])) .map<Tag>((e) => Tag(e["tag"]["name"], e["tag"]["translated_name"]))
.toList(); .toList();
} else { } else {
return []; return [];
} }
} }
Future<Res<bool>> muteTags(List<String> muteTags, List<String> unmuteTags) async { Future<Res<bool>> muteTags(
var res = await apiPost("/v1/mute/edit", data: { List<String> muteTags, List<String> unmuteTags) async {
"add_tags": muteTags, var res = await apiPost("/v1/mute/edit",
"delete_tags": unmuteTags data: {"add_tags": muteTags, "delete_tags": unmuteTags});
});
if (res.success) { if (res.success) {
return const Res(true); return const Res(true);
} else { } else {
@@ -504,20 +520,37 @@ class Network {
} }
Future<Res<List<UserPreview>>> relatedUsers(String id) async { Future<Res<List<UserPreview>>> relatedUsers(String id) async {
var res = await apiGet("/v1/user/related?filter=for_android&seed_user_id=$id"); var res =
await apiGet("/v1/user/related?filter=for_android&seed_user_id=$id");
if (res.success) { if (res.success) {
return Res( return Res((res.data["user_previews"] as List)
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e)).toList()); .map((e) => UserPreview.fromJson(e))
.toList());
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
} }
} }
Future<Res<List<Illust>>> relatedIllusts(String id) async { Future<Res<List<Illust>>> relatedIllusts(String id) async {
var res = await apiGet("/v2/illust/related?filter=for_android&illust_id=$id"); var res =
await apiGet("/v2/illust/related?filter=for_android&illust_id=$id");
if (res.success) { if (res.success) {
return Res( return Res((res.data["illusts"] as List)
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList()); .map((e) => Illust.fromJson(e))
.toList());
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<String>> getNovelImage(String novelId, String imageId) async {
var res = await apiGetPlain(
"/web/v1/novel/image?novel_id=$novelId&uploaded_image_id=$imageId");
if (res.success) {
var html = res.data;
int start = html.indexOf('<img src="') + 10;
int end = html.indexOf('"', start);
return Res(html.substring(start, end));
} else { } else {
return Res.error(res.errorMessage); return Res.error(res.errorMessage);
} }

View File

@@ -16,15 +16,14 @@ import 'package:share_plus/share_plus.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
class ImagePage extends StatefulWidget { class ImagePage extends StatefulWidget {
const ImagePage(this.urls, {this.initialPage = 1, super.key}); const ImagePage(this.urls, {this.initialPage = 0, super.key});
final List<String> urls; final List<String> urls;
final int initialPage; final int initialPage;
static show(List<String> urls, {int initialPage = 1}) { static show(List<String> urls, {int initialPage = 0}) {
App.rootNavigatorKey.currentState App.rootNavigatorKey.currentState?.push(AppPageRoute(
?.push(AppPageRoute(
builder: (context) => ImagePage(urls, initialPage: initialPage))); builder: (context) => ImagePage(urls, initialPage: initialPage)));
} }
@@ -69,61 +68,67 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
Future<File?> getFile() async { Future<File?> getFile() async {
var image = widget.urls[currentPage]; var image = widget.urls[currentPage];
if(image.startsWith("file://")){ if (image.startsWith("file://")) {
return File(image.replaceFirst("file://", "")); return File(image.replaceFirst("file://", ""));
} }
var file = await CacheManager().findCache(image); var key = image;
return file == null if (key.startsWith("novel:")) {
? null key = key.split(':').last;
: File(file); }
var file = await CacheManager().findCache(key);
return file == null ? null : File(file);
} }
String getExtensionName() { String getExtensionName() {
var fileName = widget.urls[currentPage].split('/').last; var fileName = widget.urls[currentPage].split('/').last;
if(fileName.contains('.')){ if (fileName.contains('.')) {
return '.${fileName.split('.').last}'; return '.${fileName.split('.').last}';
} }
return '.jpg'; return '.jpg';
} }
void showMenu() { void showMenu() {
menuController.showFlyout(builder: (context) => MenuFlyout( menuController.showFlyout(
items: [ builder: (context) => MenuFlyout(
MenuFlyoutItem(text: Text("Save to".tl), onPressed: () async{ items: [
var file = await getFile(); MenuFlyoutItem(
if(file != null){ text: Text("Save to".tl),
var fileName = file.path.split('/').last; onPressed: () async {
if(!fileName.contains('.')){ var file = await getFile();
fileName += getExtensionName(); if (file != null) {
} var fileName = file.path.split('/').last;
saveFile(file, fileName); if (!fileName.contains('.')) {
} fileName += getExtensionName();
}), }
MenuFlyoutItem(text: Text("Share".tl), onPressed: () async{ saveFile(file, fileName);
var file = await getFile(); }
if(file != null){ }),
var ext = getExtensionName(); MenuFlyoutItem(
var fileName = file.path.split('/').last; text: Text("Share".tl),
if(!fileName.contains('.')){ onPressed: () async {
fileName += ext; var file = await getFile();
} if (file != null) {
var mediaType = switch(ext){ var ext = getExtensionName();
'.jpg' => 'image/jpeg', var fileName = file.path.split('/').last;
'.jpeg' => 'image/jpeg', if (!fileName.contains('.')) {
'.png' => 'image/png', fileName += ext;
'.gif' => 'image/gif', }
'.webp' => 'image/webp', var mediaType = switch (ext) {
_ => 'application/octet-stream' '.jpg' => 'image/jpeg',
}; '.jpeg' => 'image/jpeg',
Share.shareXFiles([XFile.fromData( '.png' => 'image/png',
await file.readAsBytes(), '.gif' => 'image/gif',
mimeType: mediaType, '.webp' => 'image/webp',
name: fileName)] _ => 'application/octet-stream'
); };
} Share.shareXFiles([
}), XFile.fromData(await file.readAsBytes(),
], mimeType: mediaType, name: fileName)
)); ]);
}
}),
],
));
} }
@override @override
@@ -133,12 +138,13 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
color: FluentTheme.of(context).micaBackgroundColor, color: FluentTheme.of(context).micaBackgroundColor,
child: Listener( child: Listener(
onPointerSignal: (event) { onPointerSignal: (event) {
if(event is PointerScrollEvent && if (event is PointerScrollEvent &&
!HardwareKeyboard.instance.isControlPressed) { !HardwareKeyboard.instance.isControlPressed) {
if(event.scrollDelta.dy > 0 if (event.scrollDelta.dy > 0 &&
&& controller.page!.toInt() < widget.urls.length - 1) { controller.page!.toInt() < widget.urls.length - 1) {
controller.jumpToPage(controller.page!.toInt() + 1); controller.jumpToPage(controller.page!.toInt() + 1);
} else if(event.scrollDelta.dy < 0 && controller.page!.toInt() > 0){ } else if (event.scrollDelta.dy < 0 &&
controller.page!.toInt() > 0) {
controller.jumpToPage(controller.page!.toInt() - 1); controller.jumpToPage(controller.page!.toInt() - 1);
} }
} }
@@ -148,19 +154,17 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
var height = constrains.maxHeight; var height = constrains.maxHeight;
return Stack( return Stack(
children: [ children: [
Positioned.fill(child: PhotoViewGallery.builder( Positioned.fill(
child: PhotoViewGallery.builder(
pageController: controller, pageController: controller,
backgroundDecoration: const BoxDecoration( backgroundDecoration:
color: Colors.transparent const BoxDecoration(color: Colors.transparent),
),
itemCount: widget.urls.length, itemCount: widget.urls.length,
builder: (context, index) { builder: (context, index) {
var image = widget.urls[index]; var image = widget.urls[index];
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: image.startsWith("file://") imageProvider: getImageProvider(image),
? FileImage(File(image.replaceFirst("file://", "")))
: CachedImageProvider(image) as ImageProvider,
); );
}, },
onPageChanged: (index) { onPageChanged: (index) {
@@ -177,17 +181,22 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
height: 36, height: 36,
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 6,), const SizedBox(
width: 6,
),
IconButton( IconButton(
icon: const Icon(FluentIcons.back).paddingAll(2), icon: const Icon(FluentIcons.back).paddingAll(2),
onPressed: () => context.pop() onPressed: () => context.pop()),
),
const Expanded( const Expanded(
child: DragToMoveArea(child: SizedBox.expand(),), child: DragToMoveArea(
child: SizedBox.expand(),
),
), ),
buildActions(), buildActions(),
if(App.isDesktop) if (App.isDesktop)
WindowButtons(key: ValueKey(windowButtonKey),), WindowButtons(
key: ValueKey(windowButtonKey),
),
], ],
), ),
), ),
@@ -196,7 +205,10 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
left: 0, left: 0,
top: height / 2 - 9, top: height / 2 - 9,
child: IconButton( child: IconButton(
icon: const Icon(FluentIcons.chevron_left, size: 18,), icon: const Icon(
FluentIcons.chevron_left,
size: 18,
),
onPressed: () { onPressed: () {
controller.previousPage( controller.previousPage(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -239,25 +251,35 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
controller: menuController, controller: menuController,
child: width > 600 child: width > 600
? Button( ? Button(
onPressed: showMenu, onPressed: showMenu,
child: const Row( child: const Row(
children: [ children: [
Icon( Icon(
MdIcons.menu, MdIcons.menu,
size: 18, size: 18,
), ),
SizedBox( SizedBox(
width: 8, width: 8,
), ),
Text('Actions'), Text('Actions'),
], ],
)) ))
: IconButton( : IconButton(
icon: const Icon( icon: const Icon(
MdIcons.more_horiz, MdIcons.more_horiz,
size: 20, size: 20,
), ),
onPressed: showMenu), onPressed: showMenu),
); );
} }
ImageProvider getImageProvider(String url) {
if (url.startsWith("file://")) {
return FileImage(File(url.replaceFirst("file://", "")));
} else if (url.startsWith("novel:")) {
var ids = url.split(':').last.split('/');
return CachedNovelImageProvider(ids[0], ids[1]);
}
return CachedImageProvider(url) as ImageProvider;
}
} }

View File

@@ -1,6 +1,10 @@
import 'package:fluent_ui/fluent_ui.dart'; import 'package:fluent_ui/fluent_ui.dart';
import 'package:pixes/components/animated_image.dart';
import 'package:pixes/components/loading.dart'; import 'package:pixes/components/loading.dart';
import 'package:pixes/foundation/image_provider.dart';
import 'package:pixes/network/network.dart'; import 'package:pixes/network/network.dart';
import 'package:pixes/pages/image_page.dart';
import 'package:pixes/utils/ext.dart';
class NovelReadingPage extends StatefulWidget { class NovelReadingPage extends StatefulWidget {
const NovelReadingPage(this.novel, {super.key}); const NovelReadingPage(this.novel, {super.key});
@@ -14,31 +18,20 @@ class NovelReadingPage extends StatefulWidget {
class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> { class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
@override @override
Widget buildContent(BuildContext context, String data) { Widget buildContent(BuildContext context, String data) {
var content = buildList(context).toList();
return ScaffoldPage( return ScaffoldPage(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
content: SelectionArea( content: SelectionArea(
child: SingleChildScrollView( child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 16.0, height: 1.6),
child: ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( itemBuilder: (context, index) {
crossAxisAlignment: CrossAxisAlignment.start, return content[index];
children: [ },
Text(widget.novel.title, itemCount: content.length,
style: const TextStyle(
fontSize: 24.0, fontWeight: FontWeight.bold)),
const SizedBox(height: 12.0),
const Divider(
style: DividerThemeData(horizontalMargin: EdgeInsets.all(0)),
),
const SizedBox(height: 12.0),
Text(data,
style: const TextStyle(
fontSize: 16.0,
height: 1.6,
)),
],
),
), ),
), )),
); );
} }
@@ -46,4 +39,41 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
Future<Res<String>> loadData() { Future<Res<String>> loadData() {
return Network().getNovelContent(widget.novel.id.toString()); return Network().getNovelContent(widget.novel.id.toString());
} }
Iterable<Widget> buildList(BuildContext context) sync* {
yield Text(widget.novel.title,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold));
yield const SizedBox(height: 12.0);
yield const Divider(
style: DividerThemeData(horizontalMargin: EdgeInsets.all(0)),
);
yield const SizedBox(height: 12.0);
var novelContent = data!.split('\n');
for (var content in novelContent) {
if (content.isEmpty) continue;
if (content.startsWith('[uploadedimage:')) {
var imageId = content.nums;
yield GestureDetector(
onTap: () {
ImagePage.show(["novel:${widget.novel.id.toString()}/$imageId"]);
},
child: SizedBox(
height: 300,
width: double.infinity,
child: AnimatedImage(
image:
CachedNovelImageProvider(widget.novel.id.toString(), imageId),
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
height: 300,
width: double.infinity,
),
),
);
} else {
yield Text(content);
}
}
}
} }