Merge pull request #527 from Schnitzel5/feature/local-epub

added support for local epubs and displaying its images
This commit is contained in:
Moustapha Kodjo Amadou 2025-07-21 09:16:56 +01:00 committed by GitHub
commit 7bb3da36fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 165 additions and 80 deletions

View file

@ -2085,6 +2085,11 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
void _importLocal(BuildContext context, ItemType itemType) {
final l10n = l10nLocalizations(context)!;
final filesText = switch (itemType) {
ItemType.manga => ".zip, .cbz",
ItemType.anime => ".mp4, .mkv, .avi, and more",
ItemType.novel => ".epub",
};
bool isLoading = false;
showDialog(
context: context,
@ -2133,7 +2138,7 @@ void _importLocal(BuildContext context, ItemType itemType) {
children: [
const Icon(Icons.archive_outlined),
Text(
"${l10n.import_files} ( ${itemType == ItemType.manga ? ".zip, .cbz" : ".mp4, .mkv, .avi, and more"} )",
"${l10n.import_files} ( $filesText )",
style: TextStyle(
color: Theme.of(
context,

View file

@ -1,4 +1,6 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:epubx/epubx.dart';
import 'package:file_picker/file_picker.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
@ -19,9 +21,11 @@ Future importArchivesFromFile(
FilePickerResult? result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: itemType == ItemType.manga
? ['cbz', 'zip']
: ['mp4', 'mov', 'avi', 'flv', 'wmv', 'mpeg', 'mkv'],
allowedExtensions: switch (itemType) {
ItemType.manga => ['cbz', 'zip'],
ItemType.anime => ['mp4', 'mov', 'avi', 'flv', 'wmv', 'mpeg', 'mkv'],
ItemType.novel => ['epub'],
},
);
if (result != null) {
final dateNow = DateTime.now().millisecondsSinceEpoch;
@ -57,16 +61,45 @@ Future importArchivesFromFile(
manga.customCoverImage = itemType == ItemType.manga ? data!.$3 : null;
}
isar.writeTxnSync(() {
isar.mangas.putSync(manga);
final chapters = Chapter(
name: itemType == ItemType.manga ? data!.$1 : name,
archivePath: itemType == ItemType.manga ? data!.$4 : file.path,
mangaId: manga.id,
updatedAt: DateTime.now().millisecondsSinceEpoch,
)..manga.value = manga;
isar.chapters.putSync(chapters);
chapters.manga.saveSync();
await isar.writeTxn(() async {
final mangaId = await isar.mangas.put(manga);
final List<Chapter> chapters = [];
if (itemType == ItemType.novel) {
final bytes = await File(file.path!).readAsBytes();
final book = await EpubReader.readBook(bytes);
if (book.Content != null && book.Content!.Images != null) {
final coverImage =
book.Content!.Images!.containsKey("media/file0.png")
? book.Content!.Images!["media/file0.png"]!.Content
: book.Content!.Images!.values.first.Content;
await isar.mangas.put(manga..customCoverImage = coverImage);
}
for (var chapter in book.Chapters ?? []) {
chapters.add(
Chapter(
mangaId: mangaId,
name: chapter.Title is String && chapter.Title.isEmpty
? "Book"
: chapter.Title,
archivePath: file.path,
updatedAt: DateTime.now().millisecondsSinceEpoch,
)..manga.value = manga,
);
}
} else {
chapters.add(
Chapter(
name: itemType == ItemType.manga ? data!.$1 : name,
archivePath: itemType == ItemType.manga ? data!.$4 : file.path,
mangaId: manga.id,
updatedAt: DateTime.now().millisecondsSinceEpoch,
)..manga.value = manga,
);
}
for (final chapter in chapters) {
await isar.chapters.put(chapter);
await chapter.manga.save();
}
});
}
}
@ -80,7 +113,7 @@ String _getName(String path) {
.split("\\")
.last
.replaceAll(
RegExp(r'\.(mp4|mov|avi|flv|wmv|mpeg|mkv|cbz|zip|cbt|tar)'),
RegExp(r'\.(mp4|mov|avi|flv|wmv|mpeg|mkv|cbz|zip|cbt|tar|epub)'),
'',
);
}

View file

@ -7,7 +7,7 @@ part of 'local_archive.dart';
// **************************************************************************
String _$importArchivesFromFileHash() =>
r'e57fafc17833a24bccdd8f945a4c8e6dc50b49c0';
r'4d92aaade0544f76214030364433f91d27570b5a';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:epubx/epubx.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_qjs/quickjs/ffi.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -17,6 +19,7 @@ import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_pr
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/extensions/dom_extensions.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';
@ -25,6 +28,8 @@ 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';
import 'package:html/dom.dart' as dom;
import 'package:flutter/widgets.dart' as widgets;
typedef DoubleClickAnimationListener = void Function();
@ -34,21 +39,17 @@ class NovelReaderView extends ConsumerWidget {
late final Chapter chapter = isar.chapters.getSync(chapterId)!;
@override
Widget build(BuildContext context, WidgetRef ref) {
final htmlContent = ref.watch(getHtmlContentProvider(chapter: chapter));
final result = ref.watch(getHtmlContentProvider(chapter: chapter));
return NovelWebView(chapter: chapter, htmlContent: htmlContent);
return NovelWebView(chapter: chapter, result: result);
}
}
class NovelWebView extends ConsumerStatefulWidget {
const NovelWebView({
super.key,
required this.chapter,
required this.htmlContent,
});
const NovelWebView({super.key, required this.chapter, required this.result});
final Chapter chapter;
final AsyncValue<String> htmlContent;
final AsyncValue<(String, EpubBook?)> result;
@override
ConsumerState createState() {
@ -98,6 +99,7 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
}
late Chapter chapter = widget.chapter;
EpubBook? epubBook;
final StreamController<double> _rebuildDetail =
StreamController<double>.broadcast();
@ -204,8 +206,9 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
children: [
Row(
children: [
widget.htmlContent.when(
data: (htmlContent) {
widget.result.when(
data: (data) {
epubBook = data.$2;
Future.delayed(const Duration(milliseconds: 1000), () {
if (!scrolled && _scrollController.hasClients) {
_scrollController.animateTo(
@ -221,48 +224,48 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
child: Scrollbar(
controller: _scrollController,
interactive: true,
child: SingleChildScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
child: Column(
children: [
HtmlWidget(
htmlContent,
customStylesBuilder: (element) {
switch (backgroundColor) {
case BackgroundColor.black:
return {
'background-color': 'black',
};
default:
return {
'background-color': '#F0F0F0',
};
}
},
onTapUrl: (url) {
context.push(
"/mangawebview",
extra: {'url': url, 'title': url},
);
return true;
},
renderMode: RenderMode.column,
textStyle: TextStyle(
color:
backgroundColor ==
BackgroundColor.white
? Colors.black
: Colors.white,
fontSize: fontSize.toDouble(),
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_isViewFunction();
},
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
HtmlWidget(
data.$1,
customWidgetBuilder: (element) =>
_buildCustomWidgets(element),
customStylesBuilder: (element) {
switch (backgroundColor) {
case BackgroundColor.black:
return {'background-color': 'black'};
default:
return {
'background-color': '#F0F0F0',
};
}
},
onTapUrl: (url) {
context.push(
"/mangawebview",
extra: {'url': url, 'title': url},
);
return true;
},
renderMode: RenderMode.sliverList,
textStyle: TextStyle(
color:
backgroundColor ==
BackgroundColor.white
? Colors.black
: Colors.white,
fontSize: fontSize.toDouble(),
),
Center(
),
SliverToBoxAdapter(
child: Center(
heightFactor: 2,
child: Row(
mainAxisAlignment:
@ -308,8 +311,8 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
],
),
),
],
),
),
],
),
),
),
@ -733,6 +736,26 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
}
}
}
Widget? _buildCustomWidgets(dom.Element element) {
if (element.localName == "img" &&
element.getSrc != null &&
epubBook != null) {
final fileName = element.getSrc!.split("/").last;
final image = epubBook!.Content!.Images!.entries
.firstWhereOrNull((img) => img.key.endsWith(fileName))
?.value
.Content;
return image != null
? widgets.Image(
errorBuilder: (context, error, stackTrace) => Text(""),
fit: BoxFit.scaleDown,
image: MemoryImage(image as Uint8List) as ImageProvider,
)
: null;
}
return null;
}
}
class UChapDataPreload {

View file

@ -6,7 +6,7 @@ part of 'aniskip.dart';
// RiverpodGenerator
// **************************************************************************
String _$aniSkipHash() => r'887869b54e2e151633efd46da83bde845e14f421';
String _$aniSkipHash() => r'2e5d19b025a2207ff64da7bf7908450ea9e5ff8c';
/// See also [AniSkip].
@ProviderFor(AniSkip)

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:epubx/epubx.dart';
import 'package:html/parser.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/models/chapter.dart';
@ -10,10 +11,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
part 'get_html_content.g.dart';
@riverpod
Future<String> getHtmlContent(Ref ref, {required Chapter chapter}) async {
Future<(String, EpubBook?)> getHtmlContent(
Ref ref, {
required Chapter chapter,
}) async {
if (!chapter.manga.isLoaded) {
chapter.manga.loadSync();
}
if (chapter.archivePath != null && chapter.archivePath!.isNotEmpty) {
final htmlFile = File(chapter.archivePath!);
if (await htmlFile.exists()) {
final bytes = await htmlFile.readAsBytes();
final book = await EpubReader.readBook(bytes);
final tempChapter = book.Chapters?.where(
(element) => element.Title!.isNotEmpty
? element.Title == chapter.name
: "Book" == chapter.name,
).firstOrNull;
return (_buildHtml(tempChapter?.HtmlContent ?? "No content"), book);
}
return (_buildHtml("Local epub file not found!"), null);
}
final storageProvider = StorageProvider();
final mangaDirectory = await storageProvider.getMangaMainDirectory(chapter);
final htmlPath = "${mangaDirectory!.path}${chapter.name}.html";
@ -37,7 +55,11 @@ Future<String> getHtmlContent(Ref ref, {required Chapter chapter}) async {
source!,
).getHtmlContent(chapter.manga.value!.name!, chapter.url!);
}
return '''<div id="readerViewContent"><div style="padding: 2em;">${html.substring(1, html.length - 1)}</div></div>'''
return (_buildHtml(html.substring(1, html.length - 1)), null);
}
String _buildHtml(String input) {
return '''<div id="readerViewContent"><div style="padding: 2em;">$input</div></div>'''
.replaceAll("\\n", "")
.replaceAll("\\t", "")
.replaceAll("\\\"", "\"");

View file

@ -6,7 +6,7 @@ part of 'get_html_content.dart';
// RiverpodGenerator
// **************************************************************************
String _$getHtmlContentHash() => r'6bdc17222f959cb5f91b56027d4f98e26571175d';
String _$getHtmlContentHash() => r'19e6959d8fceb065b19c6c6d38cd1b5132a8ba94';
/// Copied from Dart SDK
class _SystemHash {
@ -34,7 +34,7 @@ class _SystemHash {
const getHtmlContentProvider = GetHtmlContentFamily();
/// See also [getHtmlContent].
class GetHtmlContentFamily extends Family<AsyncValue<String>> {
class GetHtmlContentFamily extends Family<AsyncValue<(String, EpubBook?)>> {
/// See also [getHtmlContent].
const GetHtmlContentFamily();
@ -72,7 +72,8 @@ class GetHtmlContentFamily extends Family<AsyncValue<String>> {
}
/// See also [getHtmlContent].
class GetHtmlContentProvider extends AutoDisposeFutureProvider<String> {
class GetHtmlContentProvider
extends AutoDisposeFutureProvider<(String, EpubBook?)> {
/// See also [getHtmlContent].
GetHtmlContentProvider({
required Chapter chapter,
@ -107,7 +108,7 @@ class GetHtmlContentProvider extends AutoDisposeFutureProvider<String> {
@override
Override overrideWith(
FutureOr<String> Function(GetHtmlContentRef provider) create,
FutureOr<(String, EpubBook?)> Function(GetHtmlContentRef provider) create,
) {
return ProviderOverride(
origin: this,
@ -124,7 +125,7 @@ class GetHtmlContentProvider extends AutoDisposeFutureProvider<String> {
}
@override
AutoDisposeFutureProviderElement<String> createElement() {
AutoDisposeFutureProviderElement<(String, EpubBook?)> createElement() {
return _GetHtmlContentProviderElement(this);
}
@ -144,13 +145,14 @@ class GetHtmlContentProvider extends AutoDisposeFutureProvider<String> {
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin GetHtmlContentRef on AutoDisposeFutureProviderRef<String> {
mixin GetHtmlContentRef on AutoDisposeFutureProviderRef<(String, EpubBook?)> {
/// The parameter `chapter` of this provider.
Chapter get chapter;
}
class _GetHtmlContentProviderElement
extends AutoDisposeFutureProviderElement<String> with GetHtmlContentRef {
extends AutoDisposeFutureProviderElement<(String, EpubBook?)>
with GetHtmlContentRef {
_GetHtmlContentProviderElement(super.provider);
@override

View file

@ -6,7 +6,7 @@ part of 'anilist.dart';
// RiverpodGenerator
// **************************************************************************
String _$anilistHash() => r'c786a526fdacc875e4a7e00886b2bda546eafeae';
String _$anilistHash() => r'fafb964252b3a5741e981cb8c2f0f2090b3b86ae';
/// Copied from Dart SDK
class _SystemHash {