comic reading

This commit is contained in:
nyne
2024-10-07 22:33:07 +08:00
parent 5ccd0af2d8
commit 601a7cd796
16 changed files with 1127 additions and 278 deletions

View File

@@ -9,6 +9,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/favorites/favorite_actions.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
@@ -458,9 +459,25 @@ abstract mixin class _ComicPageActions {
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {}
void read([int? ep, int? page]) {
App.rootContext.to(
() => Reader(
source: comicSource,
cid: comic.id,
name: comic.title,
chapters: comic.chapters,
initialChapter: ep,
initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0),
),
);
}
void continueRead() {}
void continueRead() {
var ep = history?.ep ?? 1;
var page = history?.page ?? 1;
read(ep, page);
}
void download() {}
@@ -1117,4 +1134,3 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
}
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
@@ -11,6 +9,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/io.dart';
@@ -149,7 +148,12 @@ class _HistoryState extends State<_History> {
itemBuilder: (context, index) {
return InkWell(
onTap: () {
// TODO: toComicPageWithHistory(context, history[index]);
context.to(
() => ComicPage(
id: history[index].id,
sourceKey: history[index].type.comicSource!.key,
),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
@@ -177,7 +181,7 @@ class _HistoryState extends State<_History> {
);
},
),
).paddingHorizontal(8),
).paddingHorizontal(8).paddingBottom(16),
],
),
),

View File

@@ -0,0 +1,165 @@
part of 'reader.dart';
class _ReaderGestureDetector extends StatefulWidget {
const _ReaderGestureDetector({required this.child});
final Widget child;
@override
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
}
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
late TapGestureRecognizer _tapGestureRecognizer;
static const _kDoubleTapMinTime = Duration(milliseconds: 200);
static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0;
static const _kTapToTurnPagePercent = 0.3;
@override
void initState() {
_tapGestureRecognizer = TapGestureRecognizer()
..onTapUp = onTapUp
..onSecondaryTapUp = (details) {
onSecondaryTapUp(details.globalPosition);
};
super.initState();
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_tapGestureRecognizer.addPointer(event);
},
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
onMouseWheel(event.scrollDelta.dy > 0);
}
},
child: widget.child,
);
}
void onMouseWheel(bool forward) {
if (forward) {
if (!context.reader.toNextPage()) {
context.reader.toNextChapter();
}
} else {
if (!context.reader.toPrevPage()) {
context.reader.toPrevChapter();
}
}
}
TapUpDetails? _previousEvent;
void onTapUp(TapUpDetails event) {
final location = event.globalPosition;
final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) {
if ((location - previousLocation).distanceSquared <
_kDoubleTapMaxDistanceSquared) {
onDoubleTap(location);
_previousEvent = null;
return;
} else {
onTap(previousLocation);
}
}
_previousEvent = event;
Future.delayed(_kDoubleTapMinTime, () {
if (_previousEvent == event) {
onTap(location);
_previousEvent = null;
}
});
}
void onTap(Offset location) {
if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose();
} else {
if (appdata.settings['enableTapToTurnPages']) {
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
final width = context.width;
final height = context.height;
final x = location.dx;
final y = location.dy;
if (x < width * _kTapToTurnPagePercent) {
isLeft = true;
} else if (x > width * (1 - _kTapToTurnPagePercent)) {
isRight = true;
}
if (y < height * _kTapToTurnPagePercent) {
isTop = true;
} else if (y > height * (1 - _kTapToTurnPagePercent)) {
isBottom = true;
}
bool isCenter = false;
switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight:
case ReaderMode.continuousLeftToRight:
if (isLeft) {
context.reader.toPrevPage();
} else if (isRight) {
context.reader.toNextPage();
} else {
isCenter = true;
}
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
if (isLeft) {
context.reader.toNextPage();
} else if (isRight) {
context.reader.toPrevPage();
} else {
isCenter = true;
}
case ReaderMode.galleryTopToBottom:
case ReaderMode.continuousTopToBottom:
if (isTop) {
context.reader.toPrevPage();
} else if (isBottom) {
context.reader.toNextPage();
} else {
isCenter = true;
}
}
if (!isCenter) {
return;
}
}
context.readerScaffold.openOrClose();
}
}
void onDoubleTap(Offset location) {
context.reader._imageViewController?.handleDoubleTap(location);
}
void onSecondaryTapUp(Offset location) {
showDesktopMenu(
context,
location,
[
DesktopMenuEntry(text: "Settings".tl, onClick: () {
context.readerScaffold.openSetting();
}),
DesktopMenuEntry(text: "Chapters".tl, onClick: () {
context.readerScaffold.openChapterDrawer();
}),
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () {
context.reader.fullscreen();
}),
DesktopMenuEntry(text: "Exit".tl, onClick: () {
context.pop();
}),
],
);
}
}

View File

@@ -0,0 +1,212 @@
part of 'reader.dart';
class _ReaderImages extends StatefulWidget {
const _ReaderImages({super.key});
@override
State<_ReaderImages> createState() => _ReaderImagesState();
}
class _ReaderImagesState extends State<_ReaderImages> {
String? error;
bool inProgress = false;
@override
void initState() {
context.reader.isLoading = true;
super.initState();
}
void load() async {
if (inProgress) return;
inProgress = true;
var res = await context.reader.widget.source.loadComicPages!(
context.reader.widget.cid,
context.reader.widget.chapters?.keys
.elementAt(context.reader.chapter - 1),
);
if (res.error) {
setState(() {
error = res.errorMessage;
context.reader.isLoading = false;
inProgress = false;
});
} else {
setState(() {
context.reader.images = res.data;
context.reader.isLoading = false;
inProgress = false;
});
}
context.readerScaffold.update();
}
@override
Widget build(BuildContext context) {
if (context.reader.isLoading) {
load();
return const Center(
child: CircularProgressIndicator(),
);
} else if (error != null) {
return NetworkError(
message: error!,
retry: () {
setState(() {
context.reader.isLoading = true;
error = null;
});
},
);
} else {
if (context.reader.mode.isGallery) {
return _GalleryMode(key: Key(context.reader.mode.key));
} else {
// TODO: Implement other modes
throw UnimplementedError();
}
}
}
}
class _GalleryMode extends StatefulWidget {
const _GalleryMode({super.key});
@override
State<_GalleryMode> createState() => _GalleryModeState();
}
class _GalleryModeState extends State<_GalleryMode>
implements _ImageViewController {
late PageController controller;
late List<bool> cached;
int get preCacheCount => 4;
var photoViewControllers = <int, PhotoViewController>{};
@override
void initState() {
controller = PageController(initialPage: context.reader.page);
context.reader._imageViewController = this;
cached = List.filled(context.reader.maxPage + 2, false);
super.initState();
}
void cache(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= context.reader.maxPage && !cached[i]) {
_precacheImage(i, context);
cached[i] = true;
}
}
}
@override
Widget build(BuildContext context) {
return PhotoViewGallery.builder(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
reverse: context.reader.mode == ReaderMode.galleryRightToLeft,
scrollDirection: context.reader.mode == ReaderMode.galleryTopToBottom
? Axis.vertical
: Axis.horizontal,
itemCount: context.reader.images!.length + 2,
builder: (BuildContext context, int index) {
ImageProvider? imageProvider;
if (index != 0 && index != context.reader.images!.length + 1) {
imageProvider = _createImageProvider(index, context);
} else {
return PhotoViewGalleryPageOptions.customChild(
scaleStateController: PhotoViewScaleStateController(),
child: const SizedBox(),
);
}
cached[index] = true;
cache(index);
photoViewControllers[index] ??= PhotoViewController();
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider: imageProvider,
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
},
);
},
pageController: controller,
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor: context.colorScheme.surfaceContainerHigh,
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
),
),
),
onPageChanged: (i) {
if (i == 0) {
if (!context.reader.toNextChapter()) {
context.reader.toPage(1);
}
} else if (i == context.reader.maxPage + 1) {
if (!context.reader.toPrevChapter()) {
context.reader.toPage(context.reader.maxPage);
}
} else {
context.reader.setPage(i);
context.readerScaffold.update();
}
},
);
}
@override
Future<void> animateToPage(int page) {
if ((page - controller.page!).abs() > 1) {
controller.jumpToPage(page > controller.page! ? page - 1 : page + 1);
}
return controller.animateToPage(
page,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
@override
void toPage(int page) {
controller.jumpToPage(page);
}
@override
void handleDoubleTap(Offset location) {
var controller = photoViewControllers[context.reader.page]!;
controller.onDoubleClick?.call();
}
}
ImageProvider _createImageProvider(int page, BuildContext context) {
return ReaderImageProvider(
context.reader.images![page - 1],
context.reader.widget.source.key,
context.reader.cid,
context.reader.eid,
);
}
void _precacheImage(int page, BuildContext context) {
precacheImage(
_createImageProvider(page, context),
context,
);
}

View File

@@ -0,0 +1,275 @@
library venera_reader;
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/utils/translations.dart';
import 'package:window_manager/window_manager.dart';
part 'scaffold.dart';
part 'images.dart';
part 'gesture.dart';
extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
_ReaderScaffoldState get readerScaffold =>
findAncestorStateOfType<_ReaderScaffoldState>()!;
}
class Reader extends StatefulWidget {
const Reader({
super.key,
required this.source,
required this.cid,
required this.name,
required this.chapters,
required this.history,
this.initialPage,
this.initialChapter,
});
final ComicSource source;
final String cid;
final String name;
/// Map<Chapter ID, Chapter Name>.
/// null if the comic is a gallery
final Map<String, String>? chapters;
/// Starts from 1, invalid values equal to 1
final int? initialPage;
/// Starts from 1, invalid values equal to 1
final int? initialChapter;
final History history;
@override
State<Reader> createState() => _ReaderState();
}
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
@override
void update() {
setState(() {});
}
@override
int get maxPage => images?.length ?? 1;
String get cid => widget.cid;
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
List<String>? images;
late ReaderMode mode;
History? history;
@override
bool isLoading = false;
@override
void initState() {
page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1;
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history;
super.initState();
}
@override
Widget build(BuildContext context) {
return _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
);
}
@override
int get maxChapter => widget.chapters?.length ?? 1;
@override
void onPageChanged() {
updateHistory();
}
void updateHistory() {
if(history != null) {
history!.page = page;
history!.ep = chapter;
history!.readEpisode.add(chapter);
HistoryManager().addHistory(history!);
}
}
}
abstract mixin class _ReaderLocation {
int _page = 1;
int get page => _page;
set page(int value) {
_page = value;
onPageChanged();
}
int chapter = 1;
int get maxPage;
int get maxChapter;
bool get isLoading;
void update();
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
_ImageViewController? _imageViewController;
void onPageChanged();
void setPage(int page) {
// Prevent page change during animation
if (_animationCount > 0) {
return;
}
this.page = page;
}
bool _validatePage(int page) {
return page >= 1 && page <= maxPage;
}
/// Returns true if the page is changed
bool toNextPage() {
return toPage(page + 1);
}
/// Returns true if the page is changed
bool toPrevPage() {
return toPage(page - 1);
}
int _animationCount = 0;
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page) {
return false;
}
this.page = page;
update();
if (enablePageAnimation) {
_animationCount++;
_imageViewController!.animateToPage(page).then((_) {
_animationCount--;
});
} else {
_imageViewController!.toPage(page);
}
return true;
}
return false;
}
bool _validateChapter(int chapter) {
return chapter >= 1 && chapter <= maxChapter;
}
/// Returns true if the chapter is changed
bool toNextChapter() {
return toChapter(chapter + 1);
}
/// Returns true if the chapter is changed
bool toPrevChapter() {
return toChapter(chapter - 1);
}
bool toChapter(int c) {
if (_validateChapter(c) && !isLoading) {
chapter = c;
page = 1;
update();
return true;
}
return false;
}
Timer? autoPageTurningTimer;
void autoPageTurning() {
if (autoPageTurningTimer != null) {
autoPageTurningTimer!.cancel();
autoPageTurningTimer = null;
} else {
int interval = appdata.settings['autoPageTurningInterval'];
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
if (page == maxPage) {
autoPageTurningTimer!.cancel();
}
toNextPage();
});
}
}
}
mixin class _ReaderWindow {
bool isFullscreen = false;
void fullscreen() {
windowManager.setFullScreen(!isFullscreen);
isFullscreen = !isFullscreen;
}
}
enum ReaderMode {
galleryLeftToRight('galleryLeftToRight'),
galleryRightToLeft('galleryRightToLeft'),
galleryTopToBottom('galleryTopToBottom'),
continuousTopToBottom('continuousTopToBottom'),
continuousLeftToRight('continuousLeftToRight'),
continuousRightToLeft('continuousRightToLeft');
final String key;
bool get isGallery => key.startsWith('gallery');
const ReaderMode(this.key);
static ReaderMode fromKey(String key) {
for (var mode in values) {
if (mode.key == key) {
return mode;
}
}
return galleryLeftToRight;
}
}
abstract interface class _ImageViewController {
void toPage(int page);
Future<void> animateToPage(int page);
void handleDoubleTap(Offset location);
}

View File

@@ -0,0 +1,302 @@
part of 'reader.dart';
class _ReaderScaffold extends StatefulWidget {
const _ReaderScaffold({required this.child});
final Widget child;
@override
State<_ReaderScaffold> createState() => _ReaderScaffoldState();
}
class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool _isOpen = false;
static const kTopBarHeight = 56.0;
static const kBottomBarHeight = 105.0;
bool get isOpen => _isOpen;
void openOrClose() {
setState(() {
_isOpen = !_isOpen;
});
}
bool? rotation;
void update() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: widget.child,
),
buildPageInfoText(),
AnimatedPositioned(
duration: const Duration(milliseconds: 180),
top: _isOpen ? 0 : -(kTopBarHeight + context.padding.top),
left: 0,
right: 0,
height: kTopBarHeight + context.padding.top,
child: buildTop(),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 180),
bottom: _isOpen ? 0 : -(kBottomBarHeight + context.padding.bottom),
left: 0,
right: 0,
height: kBottomBarHeight + context.padding.bottom,
child: buildBottom(),
),
],
);
}
Widget buildTop() {
return BlurEffect(
child: Container(
padding: EdgeInsets.only(top: context.padding.top),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.grey.withOpacity(0.5),
width: 0.5,
),
),
),
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).pop();
},
),
const SizedBox(width: 8),
Expanded(
child: Text(context.reader.widget.name, style: ts.s18),
),
],
),
),
);
}
Widget buildBottom() {
var text = "E${context.reader.chapter} : P${context.reader.page}";
if (context.reader.widget.chapters == null) {
text = "P${context.reader.page}";
}
Widget child = SizedBox(
height: kBottomBarHeight + MediaQuery.of(context).padding.bottom,
child: Column(
children: [
const SizedBox(
height: 8,
),
Row(
children: [
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: context.reader.toPrevChapter,
icon: const Icon(Icons.first_page),
),
Expanded(
child: buildSlider(),
),
IconButton.filledTonal(
onPressed: context.reader.toNextChapter,
icon: const Icon(Icons.last_page)),
const SizedBox(
width: 8,
),
],
),
Row(
children: [
const SizedBox(
width: 16,
),
Container(
height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(text),
),
const Spacer(),
if (App.isWindows)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
context.reader.fullscreen();
},
),
),
if (App.isAndroid)
Tooltip(
message: "Screen Rotation".tl,
child: IconButton(
icon: () {
if (rotation == null) {
return const Icon(Icons.screen_rotation);
} else if (rotation == false) {
return const Icon(Icons.screen_lock_portrait);
} else {
return const Icon(Icons.screen_lock_landscape);
}
}.call(),
onPressed: () {
if (rotation == null) {
setState(() {
rotation = false;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else if (rotation == false) {
setState(() {
rotation = true;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
} else {
setState(() {
rotation = null;
});
SystemChrome.setPreferredOrientations(
DeviceOrientation.values);
}
},
),
),
Tooltip(
message: "Auto Page Turning".tl,
child: IconButton(
icon: context.reader.autoPageTurningTimer != null
? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp),
onPressed: context.reader.autoPageTurning,
),
),
if (context.reader.widget.chapters != null)
Tooltip(
message: "Chapters".tl,
child: IconButton(
icon: const Icon(Icons.library_books),
onPressed: openChapterDrawer,
),
),
Tooltip(
message: "Save Image".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: saveCurrentImage,
),
),
Tooltip(
message: "Share".tl,
child: IconButton(
icon: const Icon(Icons.share),
onPressed: share,
),
),
const SizedBox(width: 4)
],
)
],
),
);
return BlurEffect(
child: Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Colors.grey.withOpacity(0.5),
width: 0.5,
),
),
),
padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child,
),
);
}
Widget buildSlider() {
return Slider(
value: context.reader.page.toDouble(),
min: 1,
max: context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) {
context.reader.toPage(i.toInt());
},
);
}
Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.values
.elementAt(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
if (epName.length > 8) {
epName = "${epName.substring(0, 8)}...";
}
var pageText = "${context.reader.page}/${context.reader.maxPage}";
var text = context.reader.widget.chapters != null
? "$epName : $pageText"
: pageText;
return Positioned(
bottom: 13,
left: 25,
child: Stack(
children: [
Text(
text,
style: TextStyle(
fontSize: 14,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = context.colorScheme.onInverseSurface,
),
),
Text(text),
],
),
);
}
void openChapterDrawer() {
// TODO
}
void saveCurrentImage() {
// TODO
}
void share() {
// TODO
}
void openSetting() {
// TODO
}
}