reworked local source

- allow multiple local folders
- added support for scanning .epub novels
- added metadata,json support
- scanned entries now appear in browse screen instead of the default library category (can be added to library)
This commit is contained in:
Schnitzel5 2025-09-04 23:02:27 +02:00
parent 60928374a3
commit 05d0ddf0d6
29 changed files with 768 additions and 199 deletions

View file

@ -546,5 +546,10 @@
"watch_order": "Watch order",
"sequels": "Sequels",
"recommendations": "Recommendations",
"recommendations_similarity": "Similarity:"
"recommendations_similarity": "Similarity:",
"local_folder": "Local folders",
"add_local_folder": "Add local folder",
"rescan_local_folder": "Rescan all local folders now",
"export_metadata": "Export metadata",
"exported": "Exported"
}

View file

@ -3350,6 +3350,36 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Similarity:'**
String get recommendations_similarity;
/// No description provided for @local_folder.
///
/// In en, this message translates to:
/// **'Local folders'**
String get local_folder;
/// No description provided for @add_local_folder.
///
/// In en, this message translates to:
/// **'Add local folder'**
String get add_local_folder;
/// No description provided for @rescan_local_folder.
///
/// In en, this message translates to:
/// **'Rescan all local folders now'**
String get rescan_local_folder;
/// No description provided for @export_metadata.
///
/// In en, this message translates to:
/// **'Export metadata'**
String get export_metadata;
/// No description provided for @exported.
///
/// In en, this message translates to:
/// **'Exported'**
String get exported;
}
class _AppLocalizationsDelegate

View file

@ -1733,4 +1733,19 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1735,4 +1735,19 @@ class AppLocalizationsAs extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1746,4 +1746,19 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1734,4 +1734,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1751,6 +1751,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}
/// The translations for Spanish Castilian, as used in Latin America and the Caribbean (`es_419`).

View file

@ -1752,4 +1752,19 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1736,4 +1736,19 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1740,4 +1740,19 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1749,4 +1749,19 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1748,6 +1748,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}
/// The translations for Portuguese, as used in Brazil (`pt_BR`).

View file

@ -1750,4 +1750,19 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1734,4 +1734,19 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1740,4 +1740,19 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -1705,4 +1705,19 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get recommendations_similarity => 'Similarity:';
@override
String get local_folder => 'Local folders';
@override
String get add_local_folder => 'Add local folder';
@override
String get rescan_local_folder => 'Rescan all local folders now';
@override
String get export_metadata => 'Export metadata';
@override
String get exported => 'Exported';
}

View file

@ -178,24 +178,34 @@ class _SourcesScreenState extends ConsumerState<SourcesScreen> {
item1.name!.compareTo(item2.name!),
order: GroupedListOrder.ASC,
),
Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
SliverToBoxAdapter(
child: Column(
children: [
Text(
l10n.other,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
children: [
Text(
l10n.other,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
SourceListTile(
source: Source(
name: "local",
lang: "",
itemType: widget.itemType,
),
itemType: widget.itemType,
),
],
),
),
SourceListTile(
source: Source(name: "local", lang: "", itemType: widget.itemType),
itemType: widget.itemType,
),
],
),
);

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'dart:io'; // For I/O-operations
import 'package:epubx/epubx.dart';
import 'package:isar/isar.dart'; // Isar database package for local storage
import 'package:mangayomi/main.dart'; // Exposes the global `isar` instance
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/utils/extensions/others.dart';
import 'package:path/path.dart' as p; // For manipulating file system paths
import 'package:bot_toast/bot_toast.dart'; // For Exceptions
import 'package:mangayomi/models/manga.dart'; // Has Manga model and ItemType enum
@ -18,7 +21,7 @@ class LocalFoldersState extends _$LocalFoldersState {
return isar.settings.getSync(227)!.localFolders ?? [];
}
void setDownloadedOnly(List<String> value) {
void set(List<String> value) {
final settings = isar.settings.getSync(227)!;
state = value;
isar.writeTxnSync(
@ -39,20 +42,19 @@ class LocalFoldersState extends _$LocalFoldersState {
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// Mangayomi/local/NovelName/Chapter1.epub
/// Mangayomi/local/NovelName/Chapter2.html
/// Mangayomi/local/NovelName/NovelName.epub
/// ```
/// **Supported filetypes:** (taken from lib/modules/library/providers/local_archive.dart, line 98)
/// ```
/// Videotypes: mp4, mov, avi, flv, wmv, mpeg, mkv
/// Imagetypes: jpg, jpeg, png, webp
/// Archivetypes: cbz, zip, cbt, tar
/// Other types: epub, html
/// Other types: epub
/// ```
@riverpod
Future<void> scanLocalLibrary(Ref ref) async {
// Get /local directory
final localDir = await _getLocalLibrary();
final localDir = await getLocalLibrary();
await _scanDirectory(ref, localDir);
final customDirs = ref.read(localFoldersStateProvider);
for (final dir in customDirs) {
@ -132,7 +134,7 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
final hasImagesFolders = subDirs.isNotEmpty;
final hasArchives = files.any((f) => _isArchive(f.path));
final hasVideos = files.any((f) => _isVideo(f.path));
final hasEpubs = files.any((f) => _isEpubHtml(f.path));
final hasEpubs = files.any((f) => _isEpub(f.path));
late ItemType itemType;
if (hasImagesFolders || hasArchives) {
itemType = ItemType.manga;
@ -188,6 +190,26 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
} else if (imageFiles.isEmpty && manga.customCoverImage != null) {
manga.customCoverImage = null;
}
final jsonFiles = files.where((f) => _isJson(f.path)).toList();
if (jsonFiles.isNotEmpty) {
try {
final str = await File(jsonFiles.first.path).readAsString();
final data = jsonDecode(str) as Map<String, dynamic>?;
manga.name = data?["name"];
manga.description = data?["description"];
manga.artist = data?["artist"];
manga.author = data?["author"];
manga.genre = data?["genre"]?.cast<String>();
manga.status = data?["status"] != null
? Status.values[data!["status"]]
: Status.unknown;
manga.lastUpdate = dateNow;
} catch (e) {
BotToast.showText(text: "Error reading metadata: $e");
}
}
processedMangas.add(manga);
// Scan chapters/episodes
@ -206,8 +228,8 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
addNewChapters(videos, false);
}
if (hasEpubs) {
// Each .mp4 is an episode
final epubs = files.where((f) => _isEpubHtml(f.path)).toList();
// Each .epub
final epubs = files.where((f) => _isEpub(f.path)).toList();
addNewChapters(epubs, false);
}
}
@ -235,6 +257,8 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
// Fetch all existing mangas in library that are in /local (or \local)
final savedMangas = await isar.mangas
.filter()
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
@ -265,20 +289,51 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
final itemName = p.basename(p.dirname(chapterPath));
final manga = mangaByName[itemName];
if (manga != null) {
final chap = Chapter(
mangaId: manga.id,
name:
pathBool[1] // If Chapter is an image folder or archive/video
? p.basename(chapterPath)
: p.basenameWithoutExtension(chapterPath),
dateUpload: dateNow.toString(),
archivePath: chapterPath,
);
final chapterFile = File(chapterPath);
if (manga.itemType == ItemType.novel) {
final bytes = await chapterFile.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;
manga.customCoverImage = coverImage;
saveManga++;
}
for (var chapter in book.Chapters ?? []) {
chaptersToSave.add(
Chapter(
mangaId: manga.id,
name: chapter.Title is String && chapter.Title.isEmpty
? "Book"
: chapter.Title,
archivePath: chapterPath,
downloadSize: chapterFile.existsSync()
? chapterFile.lengthSync().formattedFileSize()
: null,
)..manga.value = manga,
);
}
} else {
final chap = Chapter(
mangaId: manga.id,
name:
pathBool[1] // If Chapter is an image folder or archive/video
? p.basename(chapterPath)
: p.basenameWithoutExtension(chapterPath),
dateUpload: dateNow.toString(),
archivePath: chapterPath,
downloadSize: chapterFile.existsSync()
? chapterFile.lengthSync().formattedFileSize()
: null,
);
chaptersToSave.add(chap);
}
if (manga.lastUpdate != dateNow) {
manga.lastUpdate = dateNow;
saveManga++;
}
chaptersToSave.add(chap);
}
}
try {
@ -314,7 +369,7 @@ Future<void> _scanDirectory(Ref ref, Directory? dir) async {
}
/// Returns the `/local` directory inside the app's default storage.
Future<Directory?> _getLocalLibrary() async {
Future<Directory?> getLocalLibrary() async {
try {
final dir = await StorageProvider().getDefaultDirectory();
return dir == null ? null : Directory(p.join(dir.path, 'local'));
@ -351,6 +406,12 @@ String _getRelativePath(dir) {
}
}
/// Returns if file is a json
bool _isJson(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.json';
}
/// Returns if file is an image
bool _isImage(String path) {
final ext = p.extension(path).toLowerCase();
@ -379,7 +440,7 @@ bool _isVideo(String path) {
}
/// Returns if file is an epub or html
bool _isEpubHtml(String path) {
bool _isEpub(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.epub' || ext == '.html';
return ext == '.epub';
}

View file

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:draggable_menu/draggable_menu.dart';
@ -32,6 +33,7 @@ import 'package:mangayomi/modules/widgets/custom_draggable_tabbar.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart';
import 'package:mangayomi/utils/utils.dart';
import 'package:mangayomi/utils/cached_network.dart';
@ -633,9 +635,13 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
value: 4,
child: Text(l10n.extension_settings),
),
PopupMenuItem<int>(
value: 5,
child: Text(l10n.export_metadata),
),
];
},
onSelected: (value) {
onSelected: (value) async {
switch (value) {
case 0:
widget.checkForUpdate(true);
@ -679,6 +685,58 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
extra: source,
);
break;
case 5:
try {
final result = await FilePicker.platform
.getDirectoryPath();
if (result != null) {
final client = MClient.init();
final coverFile = File(
p.join(result, "cover.jpg"),
);
final metadataFile = File(
p.join(result, "metadata.json"),
);
final headers =
widget.manga!.isLocalArchive!
? null
: ref.read(
headersProvider(
source: widget.manga!.source!,
lang: widget.manga!.lang!,
sourceId:
widget.manga!.sourceId,
),
);
final imageUrl = toImgUrl(
widget.manga!.customCoverFromTracker ??
widget.manga!.imageUrl ??
"",
);
final res = await client.get(
Uri.parse(imageUrl),
headers: headers,
);
await coverFile.writeAsBytes(
res.bodyBytes,
);
await metadataFile.writeAsString(
jsonEncode({
"name": widget.manga!.name,
"description":
widget.manga!.description,
"artist": widget.manga!.artist,
"author": widget.manga!.author,
"genre": widget.manga!.genre,
"status": widget.manga!.status.index,
}),
);
botToast(l10n.exported);
}
} catch (e) {
botToast("Failed to export metadata: $e");
}
break;
}
},
),
@ -1454,7 +1512,7 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
),
],
),
isLocalArchive ? _action() : _actionFavouriteAndWebview(),
_actionFavouriteAndWebview(),
Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
@ -1875,53 +1933,58 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
child: Row(
children: [
Expanded(child: widget.action!),
Expanded(child: _smartUpdateDays()),
if (!isLocalArchive) Expanded(child: _smartUpdateDays()),
Expanded(
child: widget.itemType == ItemType.novel
? SizedBox.shrink()
: _action(),
),
Expanded(
child: SizedBox(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
onPressed: () async {
final manga = widget.manga!;
if (!isLocalArchive)
Expanded(
child: SizedBox(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
onPressed: () async {
final manga = widget.manga!;
final source = getSource(
widget.manga!.lang!,
widget.manga!.source!,
widget.manga!.sourceId,
);
final url =
"${source!.baseUrl}${widget.manga!.link!.getUrlWithoutDomain}";
final source = getSource(
widget.manga!.lang!,
widget.manga!.source!,
widget.manga!.sourceId,
);
final url =
"${source!.baseUrl}${widget.manga!.link!.getUrlWithoutDomain}";
Map<String, dynamic> data = {
'url': url,
'sourceId': source.id.toString(),
'title': manga.name!,
};
context.push("/mangawebview", extra: data);
},
child: Column(
children: [
Icon(Icons.public, size: 20, color: context.secondaryColor),
const SizedBox(height: 4),
Text(
'WebView',
style: TextStyle(
fontSize: 11,
Map<String, dynamic> data = {
'url': url,
'sourceId': source.id.toString(),
'title': manga.name!,
};
context.push("/mangawebview", extra: data);
},
child: Column(
children: [
Icon(
Icons.public,
size: 20,
color: context.secondaryColor,
),
),
],
const SizedBox(height: 4),
Text(
'WebView',
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
],
),
),
),
),
),
],
),
);

View file

@ -160,37 +160,36 @@ class _MangaDetailsViewState extends ConsumerState<MangaDetailsView> {
},
),
body: MangaDetailView(
titleDescription: isLocalArchive
? Container()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.manga.author!,
style: const TextStyle(fontWeight: FontWeight.w500),
titleDescription: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.manga.author ?? "Unknown",
style: const TextStyle(fontWeight: FontWeight.w500),
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(getMangaStatusIcon(widget.manga.status), size: 14),
const SizedBox(width: 4),
Text(getMangaStatusName(widget.manga.status, context)),
if (!isLocalArchive) const Text(''),
if (!isLocalArchive) Text(widget.manga.source!),
if (!isLocalArchive)
Text(' (${widget.manga.lang!.toUpperCase()})'),
if (!isLocalArchive && !widget.sourceExist)
const Padding(
padding: EdgeInsets.all(3),
child: Icon(
Icons.warning_amber,
color: Colors.deepOrangeAccent,
size: 14,
),
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(getMangaStatusIcon(widget.manga.status), size: 14),
const SizedBox(width: 4),
Text(getMangaStatusName(widget.manga.status, context)),
const Text(''),
Text(widget.manga.source!),
Text(' (${widget.manga.lang!.toUpperCase()})'),
if (!widget.sourceExist)
const Padding(
padding: EdgeInsets.all(3),
child: Icon(
Icons.warning_amber,
color: Colors.deepOrangeAccent,
size: 14,
),
),
],
),
],
),
],
),
],
),
action: widget.manga.favorite!
? SizedBox(
child: ElevatedButton(

View file

@ -22,7 +22,8 @@ Future<dynamic> updateMangaDetail(
}) async {
try {
final manga = isar.mangas.getSync(mangaId!);
if (manga!.chapters.isNotEmpty && isInit) {
if ((manga!.isLocalArchive ?? false) ||
(manga.chapters.isNotEmpty && isInit)) {
return;
}
final source = getSource(manga.lang!, manga.source!, manga.sourceId);

View file

@ -146,6 +146,21 @@ class ChapterListTileWidget extends ConsumerWidget {
),
],
),
if (chapter.downloadSize != null)
Row(
children: [
const Text(''),
Text(
chapter.downloadSize!,
style: TextStyle(
fontSize: 11,
color: context.isLight
? Colors.black.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.3),
),
),
],
),
],
),
trailing:

View file

@ -165,7 +165,7 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isLocal
!isLocal
? "${source.name}"
: "${context.l10n.local_source} ${source.itemType == ItemType.manga
? context.l10n.manga
@ -278,44 +278,45 @@ class _MangaHomeScreenState extends ConsumerState<MangaHomeScreen> {
},
onSelected: (value) {},
),
PopupMenuButton(
popUpAnimationStyle: popupAnimationStyle,
itemBuilder: (context) {
return [
PopupMenuItem<int>(
value: 0,
child: Text(context.l10n.open_in_browser),
),
PopupMenuItem<int>(
value: 1,
child: Text(context.l10n.settings),
),
];
},
onSelected: (value) async {
if (value == 0) {
final baseUrl = ref.watch(
sourceBaseUrlProvider(source: source),
);
Map<String, dynamic> data = {
'url': baseUrl,
'sourceId': source.id.toString(),
'title': '',
};
context.push("/mangawebview", extra: data);
} else {
final res = await context.push(
'/extension_detail',
extra: source,
);
if (res != null && mounted) {
setState(() {
source = res as Source;
});
if (!isLocal)
PopupMenuButton(
popUpAnimationStyle: popupAnimationStyle,
itemBuilder: (context) {
return [
PopupMenuItem<int>(
value: 0,
child: Text(context.l10n.open_in_browser),
),
PopupMenuItem<int>(
value: 1,
child: Text(context.l10n.settings),
),
];
},
onSelected: (value) async {
if (value == 0) {
final baseUrl = ref.watch(
sourceBaseUrlProvider(source: source),
);
Map<String, dynamic> data = {
'url': baseUrl,
'sourceId': source.id.toString(),
'title': '',
};
context.push("/mangawebview", extra: data);
} else {
final res = await context.push(
'/extension_detail',
extra: source,
);
if (res != null && mounted) {
setState(() {
source = res as Source;
});
}
}
}
},
),
},
),
],
bottom: PreferredSize(
preferredSize: Size.fromHeight(AppBar().preferredSize.height * 0.8),

View file

@ -1,11 +1,12 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/l10n/generated/app_localizations.dart';
import 'package:mangayomi/modules/library/providers/file_scanner.dart';
import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:numberpicker/numberpicker.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class DownloadsScreen extends ConsumerStatefulWidget {
const DownloadsScreen({super.key});
@ -109,17 +110,158 @@ class _DownloadsScreenState extends ConsumerState<DownloadsScreen> {
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
),
SuperListView.builder(
itemCount: localFolders.length,
padding: const EdgeInsets.only(bottom: 100),
itemBuilder: (context, index) {
final folder = localFolders[index];
return Text(folder);
ListTile(
onTap: () async => ref.read(scanLocalLibraryProvider.future),
title: Text(context.l10n.rescan_local_folder),
),
ListTile(
onTap: () async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
final temp = localFolders.toList();
temp.add(result);
ref.read(localFoldersStateProvider.notifier).set(temp);
}
},
title: Text(context.l10n.add_local_folder),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
context.l10n.local_folder,
style: TextStyle(
fontSize: 13,
color: context.primaryColor,
),
),
),
FutureBuilder(
future: getLocalLibrary(),
builder: (context, snapshot) => snapshot.data?.path != null
? _buildLocalFolder(
l10n,
localFolders,
snapshot.data!.path,
isDefault: true,
)
: Container(),
),
...localFolders.map(
(e) => _buildLocalFolder(l10n, localFolders, e),
),
],
),
),
],
),
),
);
}
Widget _buildLocalFolder(
AppLocalizations l10n,
List<String> localFolders,
String folder, {
bool isDefault = false,
}) {
return Padding(
key: Key('folder_${folder.hashCode}'),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
child: Column(
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
elevation: 0,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0),
topRight: Radius.circular(10),
topLeft: Radius.circular(10),
),
),
),
onPressed: null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Icon(Icons.label_outline_rounded),
const SizedBox(width: 10),
Expanded(child: Text(folder)),
],
),
),
if (!isDefault)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(l10n.delete),
content: Text("${l10n.delete} $folder"),
actions: [
Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(l10n.cancel),
),
const SizedBox(width: 15),
TextButton(
onPressed: () {
final temp = localFolders
.toList();
temp.removeAt(
temp.indexOf(folder),
);
ref
.read(
localFoldersStateProvider
.notifier,
)
.set(temp);
Navigator.pop(context);
},
child: Text(l10n.ok),
),
],
),
],
);
},
);
},
);
},
icon: const Icon(Icons.delete_outlined),
),
],
),
],
),
],
),
),
);
}
}

View file

@ -328,58 +328,69 @@ Future<void> pushToMangaReaderDetail({
bool addToFavourite = false,
}) async {
int? mangaId;
if (archiveId == null) {
final manga =
mangaM ??
Manga(
imageUrl: getManga!.imageUrl,
name: getManga.name!.trim().trimLeft().trimRight(),
genre: getManga.genre?.map((e) => e.toString()).toList() ?? [],
author: getManga.author ?? "",
status: getManga.status ?? Status.unknown,
description: getManga.description ?? "",
link: getManga.link,
source: source,
lang: lang,
lastUpdate: 0,
itemType: itemType ?? ItemType.manga,
artist: getManga.artist ?? '',
sourceId: sourceId,
);
final empty = isar.mangas
.filter()
.langEqualTo(lang)
.nameEqualTo(manga.name)
.sourceEqualTo(manga.source)
.isEmptySync();
if (empty) {
isar.writeTxnSync(() {
isar.mangas.putSync(
manga..updatedAt = DateTime.now().millisecondsSinceEpoch,
);
});
} else {
isar.writeTxnSync(() {
isar.mangas.putSync(manga);
});
}
mangaId = isar.mangas
.filter()
.isLocalArchiveEqualTo(true)
.sourceEqualTo("local")
.nameEqualTo(getManga?.name)
.findFirstSync()
?.id;
mangaId = isar.mangas
.filter()
.langEqualTo(lang)
.nameEqualTo(manga.name)
.sourceEqualTo(manga.source)
.findAllSync()
.firstWhere(
(element) =>
element.sourceId == null ? true : element.sourceId == sourceId,
)
.id!;
} else {
mangaId = archiveId;
if (mangaId == null) {
if (archiveId == null) {
final manga =
mangaM ??
Manga(
imageUrl: getManga!.imageUrl,
name: getManga.name!.trim().trimLeft().trimRight(),
genre: getManga.genre?.map((e) => e.toString()).toList() ?? [],
author: getManga.author ?? "",
status: getManga.status ?? Status.unknown,
description: getManga.description ?? "",
link: getManga.link,
source: source,
lang: lang,
lastUpdate: 0,
itemType: itemType ?? ItemType.manga,
artist: getManga.artist ?? '',
sourceId: sourceId,
);
final empty = isar.mangas
.filter()
.langEqualTo(lang)
.nameEqualTo(manga.name)
.sourceEqualTo(manga.source)
.isEmptySync();
if (empty) {
isar.writeTxnSync(() {
isar.mangas.putSync(
manga..updatedAt = DateTime.now().millisecondsSinceEpoch,
);
});
} else {
isar.writeTxnSync(() {
isar.mangas.putSync(manga);
});
}
mangaId = isar.mangas
.filter()
.langEqualTo(lang)
.nameEqualTo(manga.name)
.sourceEqualTo(manga.source)
.findAllSync()
.firstWhere(
(element) =>
element.sourceId == null ? true : element.sourceId == sourceId,
)
.id!;
} else {
mangaId = archiveId;
}
}
final mang = isar.mangas.getSync(mangaId);
if (mang!.sourceId == null) {
if (mang!.sourceId == null && !(mang.isLocalArchive ?? false)) {
isar.writeTxnSync(() {
isar.mangas.putSync(mang..sourceId = sourceId);
});

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
@ -20,6 +22,7 @@ Future<MPages?> getLatestUpdates(
final result =
(await isar.mangas
.filter()
.itemTypeEqualTo(source.itemType)
.group(
(q) => q
.sourceEqualTo("local")
@ -29,7 +32,7 @@ Future<MPages?> getLatestUpdates(
.linkContains("Mangayomi\\local"),
)
.sortByDateAddedDesc()
.offset(page * 50)
.offset(max(0, page - 1) * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
@ -20,6 +22,7 @@ Future<MPages?> getPopular(
final result =
(await isar.mangas
.filter()
.itemTypeEqualTo(source.itemType)
.group(
(q) => q
.sourceEqualTo("local")
@ -29,7 +32,7 @@ Future<MPages?> getPopular(
.linkContains("Mangayomi\\local"),
)
.sortByName()
.offset(page * 50)
.offset(max(0, page - 1) * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/lib.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
@ -22,6 +24,7 @@ Future<MPages?> search(
final result =
(await isar.mangas
.filter()
.itemTypeEqualTo(source.itemType)
.group(
(q) => q
.sourceEqualTo("local")
@ -31,7 +34,7 @@ Future<MPages?> search(
.linkContains("Mangayomi\\local"),
)
.nameContains(query, caseSensitive: false)
.offset(page * 50)
.offset(max(0, page - 1) * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))

View file

@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
@ -14,6 +16,16 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
extension FileFormatter on num {
String formattedFileSize({bool base1024 = true}) {
final base = base1024 ? 1024 : 1000;
if (this <= 0) return "0";
final units = ["B", "kB", "MB", "GB", "TB"];
int digitGroups = (log(this) / log(base)).round();
return "${NumberFormat("#,##0.#").format(this / pow(base, digitGroups))} ${units[digitGroups]}";
}
}
extension LetExtension<T> on T {
R let<R>(R Function(T) block) {
return block(this);