mangayomi/lib/modules/novel/novel_reader_view.dart
2024-12-21 00:21:03 +01:00

535 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/page.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/anime/widgets/desktop.dart';
import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart';
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/modules/novel/novel_reader_controller_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/services/get_html_content.dart';
import 'package:mangayomi/utils/utils.dart';
import 'package:mangayomi/modules/manga/reader/providers/push_router.dart';
import 'package:mangayomi/services/get_chapter_pages.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:mangayomi/utils/global_style.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
typedef DoubleClickAnimationListener = void Function();
class NovelReaderView extends ConsumerWidget {
final Chapter chapter;
const NovelReaderView({
super.key,
required this.chapter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final htmlContent = ref.watch(getHtmlContentProvider(chapter: chapter));
return NovelWebView(
chapter: chapter,
htmlContent: htmlContent,
);
}
}
class NovelWebView extends ConsumerStatefulWidget {
const NovelWebView({
super.key,
required this.chapter,
required this.htmlContent,
});
final Chapter chapter;
final AsyncValue<String> htmlContent;
@override
ConsumerState createState() {
return _NovelWebViewState();
}
}
class _NovelWebViewState extends ConsumerState<NovelWebView>
with TickerProviderStateMixin {
late final NovelReaderController _readerController =
ref.read(novelReaderControllerProvider(chapter: chapter).notifier);
bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows;
@override
void dispose() {
_readerController.setMangaHistoryUpdate();
_readerController.checkAndSyncProgress();
_rebuildDetail.close();
clearGestureDetailsCache();
if (isDesktop) {
setFullScreen(value: false);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
super.dispose();
}
late Chapter chapter = widget.chapter;
final StreamController<double> _rebuildDetail =
StreamController<double>.broadcast();
@override
void initState() {
super.initState();
}
late bool _isBookmarked = _readerController.getChapterBookmarked();
bool _isView = false;
double get pixelRatio => View.of(context).devicePixelRatio;
Size get size => View.of(context).physicalSize / pixelRatio;
Color _backgroundColor(BuildContext context) =>
Theme.of(context).scaffoldBackgroundColor.withOpacity(0.9);
void _setFullScreen({bool? value}) async {
if (isDesktop) {
value = await windowManager.isFullScreen();
setFullScreen(value: !value);
}
ref.read(fullScreenReaderStateProvider.notifier).set(!value!);
}
@override
Widget build(BuildContext context) {
final backgroundColor = ref.watch(backgroundColorStateProvider);
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
final l10n = l10nLocalizations(context)!;
return KeyboardListener(
autofocus: true,
focusNode: FocusNode(),
onKeyEvent: (event) {
bool isLogicalKeyPressed(LogicalKeyboardKey key) =>
HardwareKeyboard.instance.isLogicalKeyPressed(key);
bool hasNextChapter = _readerController.getChapterIndex().$1 != 0;
bool hasPrevChapter = _readerController.getChapterIndex().$1 + 1 !=
_readerController
.getChaptersLength(_readerController.getChapterIndex().$2);
final action = switch (event.logicalKey) {
LogicalKeyboardKey.f11 =>
(!isLogicalKeyPressed(LogicalKeyboardKey.f11))
? _setFullScreen()
: null,
LogicalKeyboardKey.escape =>
(!isLogicalKeyPressed(LogicalKeyboardKey.escape))
? _goBack(context)
: null,
LogicalKeyboardKey.backspace =>
(!isLogicalKeyPressed(LogicalKeyboardKey.backspace))
? _goBack(context)
: null,
LogicalKeyboardKey.keyN ||
LogicalKeyboardKey.pageDown =>
((!isLogicalKeyPressed(LogicalKeyboardKey.keyN) ||
!isLogicalKeyPressed(LogicalKeyboardKey.pageDown)))
? switch (hasNextChapter) {
true => pushReplacementMangaReaderView(
context: context,
chapter: _readerController.getNextChapter(),
),
_ => null
}
: null,
LogicalKeyboardKey.keyP ||
LogicalKeyboardKey.pageUp =>
((!isLogicalKeyPressed(LogicalKeyboardKey.keyP) ||
!isLogicalKeyPressed(LogicalKeyboardKey.pageUp)))
? switch (hasPrevChapter) {
true => pushReplacementMangaReaderView(
context: context,
chapter: _readerController.getPrevChapter()),
_ => null
}
: null,
_ => null
};
action;
},
child: NotificationListener<UserScrollNotification>(
onNotification: (notification) {
if (notification.direction == ScrollDirection.idle) {
if (_isView) {
_isViewFunction();
}
}
return true;
},
child: Material(
child: SafeArea(
top: !fullScreenReader,
bottom: false,
child: Stack(
children: [
Row(
children: [
widget.htmlContent.when(
data: (htmlContent) {
return Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: HtmlWidget(
htmlContent,
customStylesBuilder: (element) {
switch (backgroundColor) {
case BackgroundColor.black:
return {
'background-color': 'black',
};
default:
return {
'background-color': '#F0F0F0',
};
}
},
onTapUrl: (url) {
print('tapped $url');
return true;
},
renderMode: RenderMode.column,
textStyle: TextStyle(
color: backgroundColor ==
BackgroundColor.white
? Colors.black
: Colors.white,
//fontFamily: "Times New Roman",
//fontFamilyFallback: ["Times", "serif"],
fontSize: 14),
),
),
);},
loading: () => const Expanded(
child: Center(
child: CircularProgressIndicator(),
)),
error: (err, stack) => Center(
child: Text(err.toString()),
)),
],
),
_gestureRightLeft(),
_gestureTopBottom(),
_appBar(),
],
),
),
),
),
);
}
void _goBack(BuildContext context) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
Navigator.pop(context);
}
Widget _appBar() {
if (!_isView && Platform.isIOS) {
return const SizedBox.shrink();
}
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
double height = _isView
? Platform.isIOS
? 120
: !fullScreenReader && !isDesktop
? 55
: 80
: 0;
return Positioned(
top: 0,
child: AnimatedContainer(
width: context.width(1),
height: height,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: PreferredSize(
preferredSize: Size.fromHeight(height),
child: AppBar(
centerTitle: false,
automaticallyImplyLeading: false,
titleSpacing: 0,
leading: BackButton(
onPressed: () {
Navigator.pop(context);
},
),
title: ListTile(
dense: true,
title: SizedBox(
width: context.width(0.8),
child: Text(
'${_readerController.getMangaName()} ',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
subtitle: SizedBox(
width: context.width(0.8),
child: Text(
_readerController.getChapterTitle(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
),
overflow: TextOverflow.ellipsis,
),
),
),
actions: [
btnToShowChapterListDialog(
context, context.l10n.chapters, widget.chapter),
IconButton(
onPressed: () {
_readerController.setChapterBookmarked();
setState(() {
_isBookmarked = !_isBookmarked;
});
},
icon: Icon(_isBookmarked
? Icons.bookmark
: Icons.bookmark_border_outlined)),
if ((chapter.manga.value!.isLocalArchive ?? false) == false)
IconButton(
onPressed: () async {
final manga = chapter.manga.value!;
final source = getSource(manga.lang!, manga.source!)!;
String url = chapter.url!.startsWith('/')
? "${source.baseUrl}/${chapter.url!}"
: chapter.url!;
Map<String, dynamic> data = {
'url': url,
'sourceId': source.id.toString(),
'title': chapter.name!
};
if (Platform.isLinux) {
final urll = Uri.parse(url);
if (!await launchUrl(
urll,
mode: LaunchMode.inAppBrowserView,
)) {
if (!await launchUrl(
urll,
mode: LaunchMode.externalApplication,
)) {
throw 'Could not launch $url';
}
}
} else {
context.push("/mangawebview", extra: data);
}
},
icon: const Icon(Icons.public)),
],
backgroundColor: _backgroundColor(context),
),
),
),
);
}
void _isViewFunction() {
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
if (mounted) {
setState(() {
_isView = !_isView;
});
}
if (fullScreenReader) {
if (_isView) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
}
}
Widget _gestureRightLeft() {
return Consumer(
builder: (context, ref, child) {
return Row(
children: [
/// left region
Expanded(
flex: 2,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
),
),
/// center region
Expanded(
flex: 2,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
),
),
/// right region
Expanded(
flex: 2,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
),
),
],
);
},
);
}
Widget _gestureTopBottom() {
return Consumer(
builder: (context, ref, child) {
return Column(
children: [
/// top region
Expanded(
flex: 2,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
),
),
/// center region
const Expanded(flex: 5, child: SizedBox.shrink()),
/// bottom region
Expanded(
flex: 2,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
),
),
],
);
},
);
}
}
class UChapDataPreload {
Chapter? chapter;
Directory? directory;
PageUrl? pageUrl;
bool? isLocale;
Uint8List? archiveImage;
int? index;
GetChapterPagesModel? chapterUrlModel;
int? pageIndex;
Uint8List? cropImage;
UChapDataPreload(this.chapter, this.directory, this.pageUrl, this.isLocale,
this.archiveImage, this.index, this.chapterUrlModel, this.pageIndex,
{this.cropImage});
}
class CustomPopupMenuButton<T> extends StatelessWidget {
final String label;
final String title;
final ValueChanged<T> onSelected;
final T value;
final List<T> list;
final String Function(T) itemText;
const CustomPopupMenuButton(
{super.key,
required this.label,
required this.title,
required this.onSelected,
required this.value,
required this.list,
required this.itemText});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: PopupMenuButton(
popUpAnimationStyle: popupAnimationStyle,
tooltip: "",
offset: Offset.fromDirection(1),
color: Colors.black,
onSelected: onSelected,
itemBuilder: (context) => [
for (var d in list)
PopupMenuItem(
value: d,
child: Row(
children: [
Icon(
Icons.check,
color: d == value ? Colors.white : Colors.transparent,
),
const SizedBox(width: 7),
Text(
itemText(d),
style: const TextStyle(color: Colors.white),
),
],
)),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
label,
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyLarge!
.color!
.withOpacity(0.9)),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
Row(
children: [
Text(title),
const SizedBox(width: 20),
const Icon(Icons.keyboard_arrow_down_outlined)
],
),
],
),
),
),
);
}
}