added support for local epubs and displaying its images

This commit is contained in:
Schnitzel5 2025-07-20 21:23:26 +02:00
parent 539fd186cb
commit 5b2f1b4f36
8 changed files with 153 additions and 63 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();
@ -98,6 +103,7 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
}
late Chapter chapter = widget.chapter;
EpubBook? epubBook;
final StreamController<double> _rebuildDetail =
StreamController<double>.broadcast();
@ -111,6 +117,13 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
fontSize = initFontSize;
});
});
if (widget.chapter.archivePath != null) {
final htmlFile = File(chapter.archivePath!);
if (htmlFile.existsSync()) {
final bytes = htmlFile.readAsBytesSync();
EpubReader.readBook(bytes).then((book) => epubBook = book);
}
}
}
late bool _isBookmarked = _readerController.getChapterBookmarked();
@ -221,48 +234,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(
htmlContent,
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 +321,8 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
],
),
),
],
),
),
],
),
),
),
@ -733,6 +746,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';
@ -14,6 +15,20 @@ Future<String> 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");
}
return _buildHtml("Local epub file not found!");
}
final storageProvider = StorageProvider();
final mangaDirectory = await storageProvider.getMangaMainDirectory(chapter);
final htmlPath = "${mangaDirectory!.path}${chapter.name}.html";
@ -37,7 +52,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));
}
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'828774796ea69f55cdde0106644bc06e42c2b5db';
/// Copied from Dart SDK
class _SystemHash {

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 {