Merge pull request #446 from NBA2K1/main

Refactor code, fix AniList/MAL bugs, add local scan (#148, #349)
This commit is contained in:
Moustapha Kodjo Amadou 2025-05-05 10:20:14 +01:00 committed by GitHub
commit e46975e381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1767 additions and 2301 deletions

View file

@ -153,55 +153,76 @@ class MBridge {
}
///Read values in parsed JSON object and return resut to List<String>
static const $Function jsonPathToList = $Function(_jsonPathToList);
static final $Function jsonPathToList = $Function(
(runtime, thisObj, List<$Value?> args) =>
_jsonPathToStringOrList(runtime, thisObj, true, args),
);
static $Value? _jsonPathToList(_, __, List<$Value?> args) {
String source = args[0]!.$reified;
String expression = args[1]!.$reified;
int type = args[2]!.$reified;
static $Value? _jsonPathToStringOrList(
Object? runtime, // unused by bridge
Object? thisObj, // unused by bridge
bool toList, // new flag: list vs. single-string
List<$Value?> args, // [ sourceJson, jsonPathExpr ]
) {
final source = args[0]!.$reified;
final expression = args[1]!.$reified;
dynamic decoded;
try {
//Check jsonDecode(source) is list value
if (jsonDecode(source) is List) {
List<dynamic> values = [];
final val = jsonDecode(source) as List;
for (var element in val) {
final mMap = element as Map?;
Map<String, dynamic> map = {};
if (mMap != null) {
map = mMap.map((key, value) => MapEntry(key.toString(), value));
}
values.add(map);
}
List<String> list = [];
for (var data in values) {
final jsonRes = JsonPath(expression).read(data);
String val = "";
//Get jsonRes first string value
if (type == 0) {
val = jsonRes.first.value.toString();
}
//Decode jsonRes first map value
else {
val = jsonEncode(jsonRes.first.value);
}
list.add(val);
}
return $List.wrap(list.map((e) => $String(e)).toList());
}
// else jsonDecode(source) is Map value
else {
var map = json.decode(source);
var values = JsonPath(expression).readValues(map);
return $List.wrap(
values.map((e) {
return $String(e == null ? "{}" : json.encode(e));
}).toList(),
);
}
decoded = jsonDecode(source);
} catch (_) {
return $List.wrap([]);
return toList ? $List.wrap(<$Value>[]) : $String('');
}
// Normalize a JSON element (either Map or List) into a Map<String, dynamic>
Map<String, dynamic> normalize(dynamic elt) {
if (elt is Map) {
return elt.map((k, v) => MapEntry(k.toString(), v));
}
return <String, dynamic>{};
}
/// Common JSONPath read logic
List<dynamic> extractList(Map<String, dynamic> dataMap) {
// readValues returns all matches; .read returns Match objects
final matches = JsonPath(expression).read(dataMap);
return matches.map((m) => m.value).toList();
}
if (decoded is List) {
// Branch: JSON root is a list always return a List<$String>
final out = <$Value>[];
for (var elt in decoded) {
final map = normalize(elt);
final extracted = extractList(map);
if (toList) {
// join into JSON strings per element
out.addAll(extracted.map((e) => $String(jsonEncode(e))));
} else if (extracted.isNotEmpty) {
// only first match as string
out.add($String(extracted.first.toString()));
} else {
out.add($String(''));
}
}
return $List.wrap(out);
} else if (decoded is Map) {
// Branch: JSON root is object
final map = normalize(decoded);
final extracted = extractList(map);
if (toList) {
return $List.wrap(
extracted
.map((e) => $String(e == null ? '{}' : jsonEncode(e)))
.toList(),
);
} else {
return $String(extracted.isNotEmpty ? extracted.first.toString() : '');
}
}
// Fallback: neither List nor Map
return toList ? $List.wrap(<$Value>[]) : $String('');
}
///GetMapValue
@ -218,54 +239,10 @@ class MBridge {
}
///Read values in parsed JSON object and return resut to String
static const $Function jsonPathToString = $Function(_jsonPathToString);
static $Value? _jsonPathToString(_, __, List<$Value?> args) {
String source = args[0]!.$reified;
String expression = args[1]!.$reified;
String join = args[2]!.$reified;
try {
List<dynamic> values = [];
//Check jsonDecode(source) is list value
if (jsonDecode(source) is List) {
final val = jsonDecode(source) as List;
for (var element in val) {
final mMap = element as Map?;
Map<String, dynamic> map = {};
if (mMap != null) {
map = mMap.map((key, value) => MapEntry(key.toString(), value));
}
values.add(map);
}
}
// else jsonDecode(source) is Map value
else {
final mMap = jsonDecode(source) as Map?;
Map<String, dynamic> map = {};
if (mMap != null) {
map = mMap.map((key, value) => MapEntry(key.toString(), value));
}
values.add(map);
}
List<String> listRg = [];
for (var data in values) {
final jsonRes = JsonPath(expression).readValues(data);
List list = [];
for (var element in jsonRes) {
list.add(element);
}
//join the list into listRg
listRg.add(list.join(join));
}
return $String(listRg.first);
} catch (_) {
return $String("");
}
}
static final $Function jsonPathToString = $Function(
(runtime, thisObj, List<$Value?> args) =>
_jsonPathToStringOrList(runtime, thisObj, false, args),
);
//Parse a list of dates to millisecondsSinceEpoch
static List parseDates(
@ -348,19 +325,18 @@ class MBridge {
return await FilemoonExtractor().videosFromUrl(url, prefix, suffix);
}
static Map<String, String> decodeHeaders(String? headers) =>
headers == null ? {} : (jsonDecode(headers) as Map).toMapStringString!;
static Future<List<Video>> mp4UploadExtractor(
String url,
String? headers,
String prefix,
String suffix,
) async {
Map<String, String> newHeaders = {};
if (headers != null) {
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
}
return await Mp4uploadExtractor().videosFromUrl(
url,
newHeaders,
decodeHeaders(headers),
prefix: prefix,
suffix: suffix,
);
@ -615,13 +591,8 @@ class MBridge {
String? headers,
String prefix,
) async {
Map<String, String> newHeaders = {};
if (headers != null) {
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
}
return await SendvidExtractor(
newHeaders,
decodeHeaders(headers),
).videosFromUrl(url, prefix: prefix);
}
@ -639,13 +610,9 @@ class MBridge {
String? name,
String prefix,
) async {
Map<String, String> newHeaders = {};
if (headers != null) {
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
}
return await YourUploadExtractor().videosFromUrl(
url,
newHeaders,
decodeHeaders(headers),
prefix: prefix,
name: name ?? "YourUpload",
);
@ -687,15 +654,11 @@ class MBridge {
List<Track>? subtitles,
List<Track>? audios,
) {
Map<String, String> newHeaders = {};
if (headers != null) {
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
}
return Video(
url,
quality,
originalUrl,
headers: newHeaders,
headers: decodeHeaders(headers),
subtitles: subtitles ?? [],
audios: audios ?? [],
);

View file

@ -8,6 +8,7 @@
"open_random_entry": "فتح مدخل عشوائي",
"import": "استيراد",
"filter": "مرشح",
"ignore_filters": "تجاهل مرشح",
"downloaded": "تم التحميل",
"unread": "غير مقروء",
"started": "بدأ",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Zufälligen Eintrag öffnen",
"import": "Importieren",
"filter": "Filter",
"ignore_filters": "Filter ignorieren",
"downloaded": "Heruntergeladen",
"unread": "Ungelesen",
"unwatched": "Nicht angeschaut",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Open random entry",
"import": "Import",
"filter": "Filter",
"ignore_filters": "Ignore Filters",
"downloaded": "Downloaded",
"unread": "Unread",
"unwatched": "Unwatched",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Abrir entrada aleatoria",
"import": "Importar",
"filter": "Filtrar",
"ignore_filters": "Ignorar filtros",
"downloaded": "Descargado",
"unread": "Sin leer",
"started": "Comenzado",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Abrir entrada aleatoria",
"import": "Importar",
"filter": "Filtrar",
"ignore_filters": "Ignorar filtros",
"downloaded": "Descargado",
"unread": "Sin leer",
"started": "Comenzado",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Ouvrir une entrée au hasard",
"import": "Importer",
"filter": "Filtre",
"ignore_filters": "Ignorer les filtres",
"downloaded": "Téléchargé",
"unread": "Non lus",
"started": "Commencé",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Buka Entri Acak",
"import": "Impor",
"filter": "Filter",
"ignore_filters": "Abaikan filter",
"downloaded": "Telah Diunduh",
"unread": "Belum Dibaca",
"started": "Dimulai",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Apri voce casuale",
"import": "Importa",
"filter": "Filtro",
"ignore_filters": "Ignora filtri",
"downloaded": "Scaricato",
"unread": "Non letto",
"started": "Iniziato",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Abrir entrada aleatória",
"import": "Importar",
"filter": "Filtro",
"ignore_filters": "Ignorar filtros",
"downloaded": "Baixados",
"unread": "Não lidos",
"started": "Iniciados",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Abrir entrada aleatória",
"import": "Importar",
"filter": "Filtro",
"ignore_filters": "Ignorar filtros",
"downloaded": "Baixado",
"unread": "Não lido",
"started": "Iniciado",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Открыть случайную запись",
"import": "Импорт",
"filter": "Фильтр",
"ignore_filters": "Игнорировать фильтры",
"downloaded": "Загружено",
"unread": "Непрочитанное",
"started": "Начато",

View file

@ -8,6 +8,10 @@
"open_random_entry": "สุ่มอ่าน",
"import": "นำเข้า",
"filter": "ตัวกรอง",
"ignore_filters": "ไม่สนใจ\nตัวกรอง",
"@ignore_filters": {
"description": "Manual line break added for better rendering of the ignore filters text on mobile."
},
"downloaded": "ดาวน์โหลดแล้ว",
"unread": "ยังไม่อ่าน",
"started": "เริ่มแล้ว",

View file

@ -8,6 +8,7 @@
"open_random_entry": "Rastgele Giriş Aç",
"import": "İçe Aktar",
"filter": "Filtre",
"ignore_filters": "Filtreleri yok say",
"downloaded": "İndirildi",
"unread": "Okunmamış",
"started": "Başladı",

View file

@ -8,6 +8,10 @@
"open_random_entry": "随机打开条目",
"import": "导入",
"filter": "筛选",
"ignore_filters": "忽略\n筛选",
"@ignore_filters": {
"description": "Manual line break added for better rendering of the ignore filters text on mobile."
},
"downloaded": "已下载",
"unread": "未读",
"started": "已开始",

View file

@ -165,6 +165,12 @@ abstract class AppLocalizations {
/// **'Filter'**
String get filter;
/// No description provided for @ignore_filters.
///
/// In en, this message translates to:
/// **'Ignore Filters'**
String get ignore_filters;
/// No description provided for @downloaded.
///
/// In en, this message translates to:

View file

@ -32,6 +32,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get filter => 'مرشح';
@override
String get ignore_filters => 'تجاهل مرشح';
@override
String get downloaded => 'تم التحميل';

View file

@ -32,6 +32,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get filter => 'Filter';
@override
String get ignore_filters => 'Filter ignorieren';
@override
String get downloaded => 'Heruntergeladen';

View file

@ -32,6 +32,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get filter => 'Filter';
@override
String get ignore_filters => 'Ignore Filters';
@override
String get downloaded => 'Downloaded';

View file

@ -32,6 +32,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get filter => 'Filtrar';
@override
String get ignore_filters => 'Ignorar filtros';
@override
String get downloaded => 'Descargado';
@ -1466,6 +1469,9 @@ class AppLocalizationsEs419 extends AppLocalizationsEs {
@override
String get filter => 'Filtrar';
@override
String get ignore_filters => 'Ignorar filtros';
@override
String get downloaded => 'Descargado';

View file

@ -32,6 +32,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get filter => 'Filtre';
@override
String get ignore_filters => 'Ignorer les filtres';
@override
String get downloaded => 'Téléchargé';

View file

@ -32,6 +32,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get filter => 'Filter';
@override
String get ignore_filters => 'Abaikan filter';
@override
String get downloaded => 'Telah Diunduh';

View file

@ -32,6 +32,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get filter => 'Filtro';
@override
String get ignore_filters => 'Ignora filtri';
@override
String get downloaded => 'Scaricato';

View file

@ -32,6 +32,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get filter => 'Filtro';
@override
String get ignore_filters => 'Ignorar filtros';
@override
String get downloaded => 'Baixados';
@ -1466,6 +1469,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt {
@override
String get filter => 'Filtro';
@override
String get ignore_filters => 'Ignorar filtros';
@override
String get downloaded => 'Baixado';

View file

@ -32,6 +32,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get filter => 'Фильтр';
@override
String get ignore_filters => 'Игнорировать фильтры';
@override
String get downloaded => 'Загружено';

View file

@ -32,6 +32,9 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get filter => 'ตัวกรอง';
@override
String get ignore_filters => 'ไม่สนใจ\nตัวกรอง';
@override
String get downloaded => 'ดาวน์โหลดแล้ว';

View file

@ -32,6 +32,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get filter => 'Filtre';
@override
String get ignore_filters => 'Filtreleri yok say';
@override
String get downloaded => 'İndirildi';

View file

@ -32,6 +32,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get filter => '筛选';
@override
String get ignore_filters => '忽略\n筛选';
@override
String get downloaded => '已下载';

View file

@ -22,6 +22,7 @@ import 'package:mangayomi/l10n/generated/app_localizations.dart';
import 'package:mangayomi/src/rust/frb_generated.dart';
import 'package:mangayomi/utils/url_protocol/api.dart';
import 'package:mangayomi/modules/more/settings/appearance/providers/theme_provider.dart';
import 'package:mangayomi/modules/library/providers/file_scanner.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart';
@ -54,10 +55,10 @@ void main(List<String> args) async {
isar = await StorageProvider().initDB(null, inspector: kDebugMode);
runApp(const ProviderScope(child: MyApp()));
unawaited(postLaunchInit()); // Defer non-essential async operations
unawaited(_postLaunchInit()); // Defer non-essential async operations
}
Future<void> postLaunchInit() async {
Future<void> _postLaunchInit() async {
await StorageProvider().requestPermission();
await StorageProvider().deleteBtDirectory();
}
@ -79,6 +80,7 @@ class _MyAppState extends ConsumerState<MyApp> {
super.initState();
initializeDateFormatting();
_initDeepLinks();
unawaited(ref.read(scanLocalLibraryProvider.future));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (ref.read(clearChapterCacheOnAppLaunchStateProvider)) {

View file

@ -95,7 +95,7 @@ enum TrackStatus {
onHold,
dropped,
planToRead,
rereading,
reReading,
watching,
planToWatch,
reWatching,

View file

@ -233,7 +233,7 @@ const _TrackstatusEnumValueMap = {
'onHold': 2,
'dropped': 3,
'planToRead': 4,
'rereading': 5,
'reReading': 5,
'watching': 6,
'planToWatch': 7,
'reWatching': 8,
@ -244,7 +244,7 @@ const _TrackstatusValueEnumMap = {
2: TrackStatus.onHold,
3: TrackStatus.dropped,
4: TrackStatus.planToRead,
5: TrackStatus.rereading,
5: TrackStatus.reReading,
6: TrackStatus.watching,
7: TrackStatus.planToWatch,
8: TrackStatus.reWatching,

View file

@ -1013,6 +1013,21 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
);
}
bool matchesSearchQuery(Manga manga, String query) {
final keywords = query
.toLowerCase()
.split(',')
.map((k) => k.trim())
.where((k) => k.isNotEmpty);
return keywords.any(
(keyword) =>
(manga.name?.toLowerCase().contains(keyword) ?? false) ||
(manga.source?.toLowerCase().contains(keyword) ?? false) ||
(manga.genre?.any((g) => g.toLowerCase().contains(keyword)) ?? false),
);
}
List<Manga> _filterAndSortManga({
required List<Manga> data,
required int downloadFilterType,
@ -1022,133 +1037,126 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
required int sortType,
}) {
List<Manga>? mangas;
mangas =
data
.where((element) {
List list = [];
if (downloadFilterType == 1) {
for (var chap in element.chapters) {
final modelChapDownload =
isar.downloads.filter().idEqualTo(chap.id).findAllSync();
if (modelChapDownload.isNotEmpty &&
modelChapDownload.first.isDownload == true) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (downloadFilterType == 2) {
for (var chap in element.chapters) {
final modelChapDownload =
isar.downloads.filter().idEqualTo(chap.id).findAllSync();
if (!(modelChapDownload.isNotEmpty &&
modelChapDownload.first.isDownload == true)) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where((element) {
List list = [];
if (unreadFilterType == 1 || startedFilterType == 1) {
for (var chap in element.chapters) {
if (!chap.isRead!) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (unreadFilterType == 2 || startedFilterType == 2) {
final searchQuery = _textEditingController.text;
// Skip all filters, just do search
if (searchQuery.isNotEmpty && _ignoreFiltersOnSearch) {
mangas =
data
.where((element) => matchesSearchQuery(element, searchQuery))
.toList();
} else {
// Apply filters + search
mangas =
data
.where((element) {
// Filter by download
List list = [];
for (var chap in element.chapters) {
if (chap.isRead!) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where((element) {
List list = [];
if (bookmarkedFilterType == 1) {
for (var chap in element.chapters) {
if (chap.isBookmarked!) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (bookmarkedFilterType == 2) {
List list = [];
for (var chap in element.chapters) {
if (!chap.isBookmarked!) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where(
(element) =>
_textEditingController.text.isNotEmpty
? _textEditingController.text
.split(",")
.any(
(keyword) =>
element.name!.toLowerCase().contains(
_textEditingController.text.toLowerCase(),
) ||
(element.source != null &&
element.source!.toLowerCase().contains(
_textEditingController.text.toLowerCase(),
)) ||
element.genre!.contains(keyword),
)
: true,
)
.toList();
if (downloadFilterType == 1) {
for (var chap in element.chapters) {
final modelChapDownload =
isar.downloads
.filter()
.idEqualTo(chap.id)
.findAllSync();
if (sortType == 0) {
mangas.sort((a, b) {
return a.name!.compareTo(b.name!);
});
} else if (sortType == 1) {
mangas.sort((a, b) {
return a.lastRead!.compareTo(b.lastRead!);
});
} else if (sortType == 2) {
mangas.sort((a, b) {
return a.lastUpdate?.compareTo(b.lastUpdate ?? 0) ?? 0;
});
} else if (sortType == 3) {
mangas.sort((a, b) {
return a.chapters
.where((element) => !element.isRead!)
.toList()
.length
.compareTo(
b.chapters.where((element) => !element.isRead!).toList().length,
);
});
} else if (sortType == 4) {
mangas.sort((a, b) {
return a.chapters.length.compareTo(b.chapters.length);
});
} else if (sortType == 5) {
mangas.sort((a, b) {
final aChaps = a.chapters;
final bChaps = b.chapters;
return (aChaps.lastOrNull?.dateUpload ?? "").compareTo(
bChaps.lastOrNull?.dateUpload ?? "",
);
});
} else if (sortType == 6) {
mangas.sort((a, b) {
return a.dateAdded?.compareTo(b.dateAdded ?? 0) ?? 0;
});
if (modelChapDownload.isNotEmpty &&
modelChapDownload.first.isDownload == true) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (downloadFilterType == 2) {
for (var chap in element.chapters) {
final modelChapDownload =
isar.downloads
.filter()
.idEqualTo(chap.id)
.findAllSync();
if (!(modelChapDownload.isNotEmpty &&
modelChapDownload.first.isDownload == true)) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where((element) {
// Filter by unread or started
List list = [];
if (unreadFilterType == 1 || startedFilterType == 1) {
for (var chap in element.chapters) {
if (!chap.isRead!) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (unreadFilterType == 2 || startedFilterType == 2) {
List list = [];
for (var chap in element.chapters) {
if (chap.isRead!) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where((element) {
// Filter by bookmarked
List list = [];
if (bookmarkedFilterType == 1) {
for (var chap in element.chapters) {
if (chap.isBookmarked!) {
list.add(true);
}
}
return list.isNotEmpty;
} else if (bookmarkedFilterType == 2) {
List list = [];
for (var chap in element.chapters) {
if (!chap.isBookmarked!) {
list.add(true);
}
}
return list.length == element.chapters.length;
}
return true;
})
.where(
(element) =>
searchQuery.isNotEmpty
? matchesSearchQuery(element, searchQuery)
: true,
)
.toList();
}
// Sorting the data based on selected sort type
mangas.sort((a, b) {
switch (sortType) {
case 0:
return a.name!.compareTo(b.name!);
case 1:
return a.lastRead!.compareTo(b.lastRead!);
case 2:
return a.lastUpdate?.compareTo(b.lastUpdate ?? 0) ?? 0;
case 3:
return a.chapters
.where((e) => !e.isRead!)
.length
.compareTo(b.chapters.where((e) => !e.isRead!).length);
case 4:
return a.chapters.length.compareTo(b.chapters.length);
case 5:
return (a.chapters.lastOrNull?.dateUpload ?? "").compareTo(
b.chapters.lastOrNull?.dateUpload ?? "",
);
case 6:
return a.dateAdded?.compareTo(b.dateAdded ?? 0) ?? 0;
default:
return 0;
}
});
return mangas;
}
@ -1188,7 +1196,9 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
);
final entries =
data.where((e) => !(e.hide ?? false)).toList();
data
.where((e) => !(e.hide ?? false))
.toList();
if (entries.isEmpty) {
return Text(l10n.library_no_category_exist);
}
@ -1462,7 +1472,10 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
final mangaDir = await storageProvider
.getMangaMainDirectory(chapter);
final path = await storageProvider
.getMangaChapterDirectory(chapter);
.getMangaChapterDirectory(
chapter,
mangaMainDirectory: mangaDir,
);
try {
try {
@ -2043,6 +2056,8 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
return l10n.date_added;
}
bool _ignoreFiltersOnSearch = false;
final bool _isMobile = Platform.isIOS || Platform.isAndroid;
PreferredSize _appBar(
bool isNotFiltering,
bool showNumbersOfItems,
@ -2202,6 +2217,31 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
},
icon: const Icon(Icons.search),
),
// Checkbox when searching library to ignore filters
if (_isSearch)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_isMobile
// Adds a line break where spaces exist for better mobile layout.
// Works for languages that use spaces between words.
? l10n.ignore_filters.replaceFirst(' ', '\n')
// Removes manually added line breaks for Thai and Chinese,
// where spaces arent used, to ensure proper desktop rendering.
: l10n.ignore_filters.replaceAll('\n', ''),
textAlign: TextAlign.center,
),
Checkbox(
value: _ignoreFiltersOnSearch,
onChanged: (val) {
setState(() {
_ignoreFiltersOnSearch = val ?? false;
});
},
),
],
),
IconButton(
splashRadius: 20,
onPressed: () {

View file

@ -0,0 +1,340 @@
import 'dart:io'; // For I/O-operations
import 'package:isar/isar.dart'; // Isar database package for local storage
import 'package:mangayomi/main.dart'; // Exposes the global `isar` instance
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
import 'package:mangayomi/models/chapter.dart'; // Has Chapter model with archivePath
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Riverpod state management
import 'package:mangayomi/providers/storage_provider.dart'; // Provides storage directory selection
import 'package:riverpod_annotation/riverpod_annotation.dart'; // Annotations for code generation
part 'file_scanner.g.dart';
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
/// **Folder structure:**
/// ```
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// ```
/// **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
/// ```
@riverpod
Future<void> scanLocalLibrary(Ref ref) async {
// Get /local directory
final localDir = await _getLocalLibrary();
// Don't do anything if /local doesn't exist
if (localDir == null || !await localDir.exists()) return;
final dateNow = DateTime.now().millisecondsSinceEpoch;
// Fetch all existing mangas in library that are in /local (or \local)
final List<Manga> existingMangas =
await isar.mangas
.filter()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
.findAll();
final mangaMap = {for (var m in existingMangas) _getRelativePath(m.link!): m};
// Fetch all chapters for existing mangas
final existingMangaIds = existingMangas.map((m) => m.id);
final existingChapters =
await isar.chapters
.filter()
.anyOf(existingMangaIds, (q, id) => q.mangaIdEqualTo(id))
.findAll();
// Map where the key is manga ID and the value is a set of chapter paths.
final chaptersMap = <int, Set<String>>{};
// Add manga.Ids with all the corresponding relative! paths (Manga/Chapter)
for (var chap in existingChapters) {
String path = _getRelativePath(chap.archivePath!);
// For the given manga ID, add the path to its associated set.
// If there's no entry for the manga ID yet, create a new empty set.
chaptersMap.putIfAbsent(chap.mangaId!, () => <String>{}).add(path);
}
// Collect all chapter paths chaptersMap into a single set for easy lookup.
final existingPaths = chaptersMap.values.expand((s) => s).toSet();
List<Manga> processedMangas = <Manga>[];
final List<List<dynamic>> newChapters = [];
// If newMangas > 0, save all collected Mangas in library first to get a Manga ID
int newMangas = 0;
/// helper function to add chapters to newChapters list
void addNewChapters(List<FileSystemEntity> items, bool imageFolder) {
for (final chapter in items) {
final relPath = _getRelativePath(chapter.path).trim();
// Skip if the relative path is empty (invalid entry).
if (relPath.isEmpty) continue;
if (!existingPaths.contains(relPath)) {
newChapters.add([chapter.path, imageFolder]);
existingPaths.add(relPath);
}
}
}
// Iterate over each sub-directory (each representing a title, Manga or Anime)
await for (final folder in localDir.list()) {
if (folder is! Directory) continue;
final title = p.basename(folder.path); // Anime/Manga title
String relativePath = _getRelativePath(folder.path);
// List all folders and files inside a Manga/Anime title
final children = await folder.list().toList();
final subDirs = children.whereType<Directory>().toList();
final files = children.whereType<File>().toList();
// Determine itemtype
final hasImagesFolders = subDirs.isNotEmpty;
final hasArchives = files.any((f) => _isArchive(f.path));
final hasVideos = files.any((f) => _isVideo(f.path));
late ItemType itemType;
if (hasImagesFolders || hasArchives) {
itemType = ItemType.manga;
} else if (hasVideos) {
itemType = ItemType.anime;
} else {
continue; // nothing to import from this folder
}
// Does Manga/Anime already exist in library?
bool existingManga = mangaMap.containsKey(relativePath);
// Create new Manga entry if it doesn't already exist
Manga manga;
if (existingManga) {
manga = mangaMap[relativePath]!;
} else {
manga = Manga(
favorite: true,
source: 'local',
author: '',
artist: '',
genre: [],
imageUrl: '',
lang: '',
link: folder.path,
name: title,
status: Status.unknown,
description: '',
isLocalArchive: true,
itemType: itemType,
dateAdded: dateNow,
lastUpdate: dateNow,
);
newMangas++;
}
// Detect a single image in item's root and use it as custom cover
final imageFiles = files.where((f) => _isImage(f.path)).toList();
if (imageFiles.length == 1) {
try {
final bytes = await File(imageFiles.first.path).readAsBytes();
final byteList = bytes.toList();
if (manga.customCoverImage != byteList) {
manga.customCoverImage = byteList;
manga.lastUpdate = dateNow;
}
} catch (e) {
BotToast.showText(text: "Error reading cover image: $e");
}
} else if (imageFiles.isEmpty && manga.customCoverImage != null) {
manga.customCoverImage = null;
}
processedMangas.add(manga);
// Scan chapters/episodes
if (hasImagesFolders) {
// Each subdirectory is a chapter
addNewChapters(subDirs, hasImagesFolders);
} // Possible that image folders and archives are mixed in one manga
if (hasArchives) {
// Each .cbz/.zip file is a chapter
final archives = files.where((f) => _isArchive(f.path)).toList();
addNewChapters(archives, false);
}
if (hasVideos) {
// Each .mp4 is an episode
final videos = files.where((f) => _isVideo(f.path)).toList();
addNewChapters(videos, false);
}
}
final changedMangas = <Manga>[];
for (var manga in processedMangas) {
if (manga.lastUpdate == dateNow) {
// Filter out items that haven't been changed
changedMangas.add(manga);
}
}
try {
// Save all new and changed items to the library
await isar.writeTxn(() async => await isar.mangas.putAll(changedMangas));
} catch (e) {
BotToast.showText(
text: "Database write error. Manga/Anime couldn't be saved: $e",
);
}
// If new Mangas have been added (no Id to save Chapters)
if (newMangas > 0) {
// Copy processedMangas
List<Manga> newAddedMangas = processedMangas;
// Fetch all existing mangas in library that are in /local (or \local)
final savedMangas =
await isar.mangas
.filter()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local")
.findAll();
// Save all retrieved Manga objects (now with id) matching the processedMangas list
newAddedMangas =
savedMangas
.where(
(m) => processedMangas.any(
(newManga) =>
_getRelativePath(newManga.link) == _getRelativePath(m.link),
),
)
.toList();
processedMangas.clear();
processedMangas = newAddedMangas;
}
final chaptersToSave = <Chapter>[];
int saveManga = 0; // Just to update the lastUpdate value of not new Mangas
final mangaByName = {for (var m in processedMangas) p.basename(m.link!): m};
// iterate through newChapters elements, which are: ["full_path/to/chapter1", "true"]
for (var pathBool in newChapters) {
final chapterPath = pathBool[0];
// pathBool[0] = first element of list (path)
// dirname = remove last part of path (chapter name), = "full_path/to"
// basename = remove everything except last (manga name) = "to"
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,
);
if (manga.lastUpdate != dateNow) {
manga.lastUpdate = dateNow;
saveManga++;
}
chaptersToSave.add(chap);
}
}
try {
if (saveManga > 0) {
// Just to update the lastUpdate value of not new Mangas
await isar.writeTxn(
() async => await isar.mangas.putAll(processedMangas),
);
}
} catch (e) {
BotToast.showText(text: "Error saving chapter/episode to library: $e");
}
try {
if (chaptersToSave.isNotEmpty) {
await isar.writeTxn(() async {
// insert chapters
await isar.chapters.putAll(chaptersToSave);
// for each one, set its link and save it
for (final chap in chaptersToSave) {
chap.manga.value = processedMangas.firstWhere(
(m) => m.id == chap.mangaId,
);
await chap.manga.save();
}
});
}
} catch (e) {
BotToast.showText(
text: "Database write error. Manga/Anime couldn't be saved: $e",
);
}
}
/// Returns the `/local` directory inside the app's default storage.
Future<Directory?> _getLocalLibrary() async {
try {
final dir = await StorageProvider().getDefaultDirectory();
return dir == null ? null : Directory(p.join(dir.path, 'local'));
} catch (e) {
BotToast.showText(text: "Error getting local library: $e");
return null;
}
}
/// Finds the String 'Mangayomi/local' and extract path after
/// ```
/// "C:\Users\user\Documents\Mangayomi\local\Manga 1\chapter1.zip"
/// becomes:
/// "Manga 1/chapter1.zip"
/// ```
String _getRelativePath(dir) {
String relativePath;
if (dir is Directory) {
relativePath = dir.path;
} else if (dir is String) {
relativePath = dir;
} else {
throw ArgumentError("Input must be a Directory or a String");
}
// Normalize path separators
relativePath = relativePath.replaceAll("\\", "/");
int index = relativePath.indexOf("Mangayomi/local");
if (index != -1) {
return relativePath.substring(index + "Mangayomi/local/".length);
} else {
return relativePath;
}
}
/// Returns if file is an image
bool _isImage(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.jpg' || ext == '.jpeg' || ext == '.png' || ext == '.webp';
}
/// Returns if file is an archive
bool _isArchive(String path) {
final ext = p.extension(path).toLowerCase();
return ext == '.cbz' || ext == '.zip' || ext == '.cbt' || ext == '.tar';
}
/// Returns if file is a video
bool _isVideo(String path) {
final ext = p.extension(path).toLowerCase();
const videoExtensions = {
'.mp4',
'.mov',
'.avi',
'.flv',
'.wmv',
'.mpeg',
'.mkv',
};
return videoExtensions.contains(ext);
}

View file

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_scanner.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$scanLocalLibraryHash() => r'8a80e2582c4abda500034c9e139cdb8be58942e7';
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
///
/// **Folder structure:**
/// ```
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
/// Mangayomi/local/MangaName/Chapter2.cbz
/// Mangayomi/local/AnimeName/Episode1.mp4
/// ```
/// **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
/// ```
///
/// Copied from [scanLocalLibrary].
@ProviderFor(scanLocalLibrary)
final scanLocalLibraryProvider = AutoDisposeFutureProvider<void>.internal(
scanLocalLibrary,
name: r'scanLocalLibraryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$scanLocalLibraryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ScanLocalLibraryRef = AutoDisposeFutureProviderRef<void>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -12,7 +12,7 @@ class LocalArchive {
String? path;
}
enum LocalExtensionType { cbz, zip, cbt, tar }
enum LocalExtensionType { cbz, zip, cbt, tar, folder }
class LocalImage {
String? name;

View file

@ -102,6 +102,29 @@ bool _isArchiveFile(String path) {
}
LocalArchive _extractArchive(String path) {
// Folder of images?
if (Directory(path).existsSync()) {
final dir = Directory(path);
final pages =
dir.listSync().whereType<File>().where((f) => _isImageFile(f.path)).map(
(f) {
return LocalImage()
..image = f.readAsBytesSync()
..name = p.basename(f.path);
},
).toList()
..sort((a, b) => a.name!.compareTo(b.name!));
final localArchive =
LocalArchive()
..path = path
..extensionType = LocalExtensionType.folder
..name = p.basename(path)
..images = pages
..coverImage = pages.first.image;
return localArchive;
}
final localArchive =
LocalArchive()
..path = path
@ -144,6 +167,19 @@ LocalArchive _extractArchive(String path) {
(String, LocalExtensionType, Uint8List, String) _extractArchiveOnly(
String path,
) {
// If it's a directory, just read its images:
if (Directory(path).existsSync()) {
final dir = Directory(path);
final images =
dir
.listSync()
.whereType<File>()
.where((f) => _isImageFile(f.path))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
final cover = images.first.readAsBytesSync();
return (p.basename(path), LocalExtensionType.folder, cover, path);
}
final extensionType = setTypeExtension(
p.extension(path).replaceFirst('.', ''),
);

View file

@ -15,115 +15,69 @@ class TrackState extends _$TrackState {
return track!;
}
Future updateManga() async {
Track? updateTrack;
updateTrack = await switch (track!.syncId) {
1 => switch (itemType) {
ItemType.manga => ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.updateManga(track!),
_ => ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.updateAnime(track!),
},
2 => switch (itemType) {
ItemType.manga => ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.updateLibManga(track!),
_ => ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.updateLibAnime(track!),
},
_ => ref
.read(
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
)
.updateLib(track!, _isManga),
dynamic getNotifier(int syncId) {
return switch (syncId) {
1 => ref.read(
myAnimeListProvider(syncId: syncId, itemType: itemType).notifier,
),
2 => ref.read(
anilistProvider(syncId: syncId, itemType: itemType).notifier,
),
3 => ref.read(kitsuProvider(syncId: syncId, itemType: itemType).notifier),
_ => throw Exception('Unsupported syncId: $syncId'),
};
}
void writeBack(Track t) {
ref
.read(tracksProvider(syncId: track!.syncId!).notifier)
.updateTrackManga(updateTrack, itemType!);
.read(tracksProvider(syncId: t.syncId!).notifier)
.updateTrackManga(t, itemType!);
}
Future updateManga() async {
final syncId = track!.syncId!;
Track updateTrack = await getNotifier(syncId).update(track!, _isManga);
writeBack(updateTrack);
}
int getScoreMaxValue() {
int? maxValue;
if (track!.syncId == 1 || track!.syncId == 3) {
maxValue = 10;
} else if (track!.syncId == 2) {
maxValue =
ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.getScoreValue()
.$1;
final syncId = track!.syncId!;
if (syncId == 2) {
final tracker = getNotifier(syncId);
return tracker.getScoreValue().$1;
} else {
return 10;
}
return maxValue!;
}
String getTextMapper(String numberText) {
if (track!.syncId == 1 || track!.syncId == 3) {
} else if (track!.syncId == 2) {
numberText = ref
.read(anilistProvider(syncId: 2, itemType: itemType).notifier)
.displayScore(int.parse(numberText));
final syncId = track!.syncId!;
if (syncId == 2) {
final tracker = getNotifier(syncId);
return tracker.displayScore(int.parse(numberText));
} else {
return numberText;
}
return numberText;
}
int getScoreStep() {
int? step;
if (track!.syncId == 1 || track!.syncId == 3) {
step = 1;
} else if (track!.syncId == 2) {
step =
ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.getScoreValue()
.$2;
final syncId = track!.syncId!;
if (syncId == 2) {
final tracker = getNotifier(syncId);
return tracker.getScoreValue().$2;
} else {
return 1;
}
return step!;
}
String displayScore(int score) {
String? result;
if (track!.syncId == 1 || track!.syncId == 3) {
result = score.toString();
} else if (track!.syncId == 2) {
result = ref
.read(anilistProvider(syncId: 2, itemType: itemType).notifier)
.displayScore(score);
final syncId = track!.syncId!;
if (syncId == 2) {
final tracker = getNotifier(syncId);
return tracker.displayScore(score);
} else {
return score.toString();
}
return result!;
}
bool get _isManga => itemType == ItemType.manga;
@ -134,7 +88,7 @@ class TrackState extends _$TrackState {
int syncId,
) async {
Track? findManga;
final track = Track(
final newTrack = Track(
mangaId: mangaId,
score: 0,
syncId: syncId,
@ -143,113 +97,28 @@ class TrackState extends _$TrackState {
title: trackSearch.title,
lastChapterRead: 0,
totalChapter: trackSearch.totalChapter,
status: TrackStatus.planToRead,
status: _isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
startedReadingDate: 0,
finishedReadingDate: 0,
);
final tracker = getNotifier(syncId);
if (syncId == 1) {
findManga = await ref
.read(
myAnimeListProvider(syncId: syncId, itemType: itemType).notifier,
)
.findManga(track);
findManga = await tracker.findLibItem(newTrack, _isManga);
} else if (syncId == 2) {
findManga =
_isManga
? await ref
.read(
anilistProvider(
syncId: syncId,
itemType: itemType,
).notifier,
)
.findLibManga(track)
: await ref
.read(
anilistProvider(
syncId: syncId,
itemType: itemType,
).notifier,
)
.findLibAnime(track);
findManga ??=
_isManga
? await ref
.read(
anilistProvider(
syncId: syncId,
itemType: itemType,
).notifier,
)
.addLibManga(track)
: await ref
.read(
anilistProvider(
syncId: syncId,
itemType: itemType,
).notifier,
)
.addLibAnime(track);
findManga = await tracker.findLibItem(newTrack, _isManga);
findManga ??= await tracker.update(newTrack, _isManga);
} else if (syncId == 3) {
findManga = await ref
.read(kitsuProvider(syncId: syncId, itemType: itemType).notifier)
.addLib(track, _isManga);
findManga = await tracker.update(newTrack, _isManga);
}
ref
.read(tracksProvider(syncId: syncId).notifier)
.updateTrackManga(findManga!, itemType!);
writeBack(findManga!);
}
List<TrackStatus> getStatusList() {
List<TrackStatus> statusList = [];
List<TrackStatus> list = [];
if (track!.syncId == 1) {
statusList =
_isManga
? ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.myAnimeListStatusListManga
: ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.myAnimeListStatusListAnime;
} else if (track!.syncId == 2) {
statusList =
_isManga
? ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.aniListStatusListManga
: ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.aniListStatusListAnime;
} else if (track!.syncId == 3) {
statusList = ref
.read(
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
)
.kitsuStatusList(_isManga);
}
final syncId = track!.syncId!;
final tracker = getNotifier(syncId);
List<TrackStatus> statusList = tracker.statusList(_isManga);
for (var element in TrackStatus.values) {
if (statusList.contains(element)) {
list.add(element);
@ -259,82 +128,14 @@ class TrackState extends _$TrackState {
}
Future<Track?> findManga() async {
Track? findManga;
if (track!.syncId == 1) {
findManga = await ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.findManga(track!);
} else if (track!.syncId == 2) {
findManga =
_isManga
? await ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.findLibManga(track!)
: await ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.findLibAnime(track!);
} else if (track!.syncId == 3) {
findManga = await ref
.read(
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
)
.findLibItem(track!, _isManga);
}
return findManga;
final syncId = track!.syncId!;
final tracker = getNotifier(syncId);
return await tracker.findLibItem(track!, _isManga);
}
Future<List<TrackSearch>?> search(String query) async {
List<TrackSearch>? tracks;
if (track!.syncId == 1) {
tracks = await ref
.read(
myAnimeListProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.search(query);
} else if (track!.syncId == 2) {
tracks =
_isManga
? await ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.search(query)
: await ref
.read(
anilistProvider(
syncId: track!.syncId!,
itemType: itemType,
).notifier,
)
.searchAnime(query);
} else if (track!.syncId == 3) {
tracks = await ref
.read(
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
)
.search(query, _isManga);
}
return tracks;
final syncId = track!.syncId!;
final tracker = getNotifier(syncId);
return await tracker.search(query, _isManga);
}
}

View file

@ -28,7 +28,10 @@ class ChapterPageDownload extends ConsumerWidget {
void _sendFile() async {
final storageProvider = StorageProvider();
final mangaDir = await storageProvider.getMangaMainDirectory(chapter);
final path = await storageProvider.getMangaChapterDirectory(chapter);
final path = await storageProvider.getMangaChapterDirectory(
chapter,
mangaMainDirectory: mangaDir,
);
List<XFile> files = [];
@ -57,7 +60,10 @@ class ChapterPageDownload extends ConsumerWidget {
void _deleteFile(int downloadId) async {
final storageProvider = StorageProvider();
final mangaDir = await storageProvider.getMangaMainDirectory(chapter);
final path = await storageProvider.getMangaChapterDirectory(chapter);
final path = await storageProvider.getMangaChapterDirectory(
chapter,
mangaMainDirectory: mangaDir,
);
try {
try {

View file

@ -57,7 +57,10 @@ Future<void> downloadChapter(
final chapterName = chapter.name!.replaceForbiddenCharacters(' ');
final itemType = chapter.manga.value!.itemType;
final chapterDirectory =
(await storageProvider.getMangaChapterDirectory(chapter))!;
(await storageProvider.getMangaChapterDirectory(
chapter,
mangaMainDirectory: mangaMainDirectory,
))!;
await Directory(chapterDirectory.path).create(recursive: true);
Map<String, String> videoHeader = {};
Map<String, String> htmlHeader = {
@ -240,8 +243,8 @@ Future<void> downloadChapter(
if (!cbzFileExist && itemType == ItemType.manga ||
!mp4FileExist && itemType == ItemType.anime ||
!htmlFileExist && itemType == ItemType.novel) {
final mainDirectory = (await storageProvider.getDirectory())!;
for (var index = 0; index < pageUrls.length; index++) {
final mainDirectory = (await storageProvider.getDirectory())!;
if (Platform.isAndroid) {
if (!(await File(p.join(mainDirectory.path, ".nomedia")).exists())) {
await File(p.join(mainDirectory.path, ".nomedia")).create();

View file

@ -0,0 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/services/get_chapter_pages.dart';
class ChapterWithPages {
final Chapter chapter;
final GetChapterPagesModel pages;
ChapterWithPages({required this.chapter, required this.pages});
}
class MangaReaderController extends FamilyAsyncNotifier<ChapterWithPages, int> {
@override
Future<ChapterWithPages> build(int chapterId) async {
final chap = await isar.chapters.get(chapterId);
if (chap == null) {
throw Exception('Chapter #$chapterId not found');
}
final pages = await ref.read(getChapterPagesProvider(chapter: chap).future);
return ChapterWithPages(chapter: chap, pages: pages);
}
}
final mangaReaderProvider =
AsyncNotifierProvider.family<MangaReaderController, ChapterWithPages, int>(
MangaReaderController.new,
);

View file

@ -39,6 +39,7 @@ import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
@ -52,64 +53,62 @@ typedef DoubleClickAnimationListener = void Function();
class MangaReaderView extends ConsumerWidget {
final int chapterId;
MangaReaderView({super.key, required this.chapterId});
late final Chapter chapter = isar.chapters.getSync(chapterId)!;
const MangaReaderView({super.key, required this.chapterId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chapterData = ref.watch(getChapterPagesProvider(chapter: chapter));
final chapterData = ref.watch(mangaReaderProvider(chapterId));
return chapterData.when(
loading: () => scaffoldWith(context, const ProgressCenter()),
error:
(error, _) =>
scaffoldWith(context, Center(child: Text(error.toString()))),
data: (data) {
if (data.pageUrls.isEmpty &&
(chapter.manga.value!.isLocalArchive ?? false) == false) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text(''),
leading: BackButton(
onPressed: () {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
Navigator.pop(context);
},
),
),
body: const Center(child: Text("Error")),
final chapter = data.chapter;
final model = data.pages;
if (model.pageUrls.isEmpty &&
!(chapter.manga.value?.isLocalArchive ?? false)) {
return scaffoldWith(
context,
const Center(child: Text('Error: no pages available')),
restoreUi: true,
);
}
return MangaChapterPageGallery(chapter: chapter, chapterUrlModel: data);
},
error:
(error, stackTrace) => Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text(''),
leading: BackButton(
onPressed: () {
Navigator.pop(context);
},
),
),
body: Center(child: Text(error.toString())),
),
loading: () {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text(''),
leading: BackButton(
onPressed: () {
Navigator.pop(context);
},
),
),
body: const ProgressCenter(),
return MangaChapterPageGallery(
chapter: chapter,
chapterUrlModel: model,
);
},
);
}
Widget scaffoldWith(
BuildContext context,
Widget body, {
bool restoreUi = false,
}) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text(''),
leading: BackButton(
onPressed: () {
if (restoreUi) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
Navigator.of(context).pop();
},
),
),
body: body,
);
}
}
class MangaChapterPageGallery extends ConsumerStatefulWidget {

View file

@ -27,17 +27,173 @@ final navigationItems = {
"/more": "More",
};
class SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const SettingsSection({
super.key,
required this.title,
required this.children,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Text(
title,
style: TextStyle(fontSize: 13, color: context.primaryColor),
),
),
...children,
],
),
);
}
}
class AppearanceScreen extends ConsumerWidget {
const AppearanceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = l10nLocalizations(context);
final dateFormatState = ref.watch(dateFormatStateProvider);
final relativeTimestamps = ref.watch(relativeTimesTampsStateProvider);
final pureBlackDarkMode = ref.watch(pureBlackDarkModeStateProvider);
final isDarkTheme = ref.watch(themeModeStateProvider);
bool followSystemTheme = ref.watch(followSystemThemeStateProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n!.appearance)),
body: SingleChildScrollView(
child: Column(
children: [
SettingsSection(
title: l10n.theme,
children: [
const FollowSystemThemeButton(),
if (!followSystemTheme) const DarkModeButton(),
const ThemeSelector(),
if (isDarkTheme)
SwitchListTile(
title: Text(l10n.pure_black_dark_mode),
value: pureBlackDarkMode,
onChanged: (value) {
ref
.read(pureBlackDarkModeStateProvider.notifier)
.set(value);
},
),
if (!pureBlackDarkMode || !isDarkTheme)
const BlendLevelSlider(),
],
),
SettingsSection(
title: l10n.appearance,
children: [
_buildLanguageTile(context, ref, l10n),
_buildFontTile(context, ref, l10n),
ListTile(
title: Text(l10n.reorder_navigation),
subtitle: Text(
l10n.reorder_navigation_description,
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
onTap: () {
context.push("/customNavigationSettings");
},
),
],
),
SettingsSection(
title: l10n.timestamp,
children: [
_buildRelativeTimestampTile(context, ref, l10n),
_buildDateFormatTile(context, ref, l10n),
],
),
],
),
),
);
}
Widget _buildLanguageTile(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) {
final l10nLocale = ref.watch(l10nLocaleStateProvider);
return ListTile(
title: Text(l10n.app_language),
subtitle: Text(
completeLanguageName(l10nLocale.toLanguageTag()),
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.app_language),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount: AppLocalizations.supportedLocales.length,
itemBuilder: (context, index) {
final locale = AppLocalizations.supportedLocales[index];
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: locale,
groupValue: l10nLocale,
onChanged: (value) {
ref
.read(l10nLocaleStateProvider.notifier)
.setLocale(locale);
Navigator.pop(context);
},
title: Text(completeLanguageName(locale.toLanguageTag())),
);
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(color: context.primaryColor),
),
),
],
),
],
);
},
);
},
);
}
Widget _buildFontTile(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) {
final appFontFamily = ref.watch(appFontFamilyProvider);
final appFontFamilySub =
appFontFamily == null
@ -48,486 +204,278 @@ class AppearanceScreen extends ConsumerWidget {
(element) => element.value().fontFamily! == appFontFamily,
)
.key;
bool followSystemTheme = ref.watch(followSystemThemeStateProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n!.appearance)),
body: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
return ListTile(
title: Text(context.l10n.font),
subtitle: Text(
appFontFamilySub,
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
onTap: () {
String textValue = "";
final controller = ScrollController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(context.l10n.font),
content: StatefulBuilder(
builder: (context, setState) {
return SizedBox(
width: context.width(0.8),
child: Column(
children: [
Text(
l10n.theme,
style: TextStyle(
fontSize: 13,
color: context.primaryColor,
Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 8,
),
),
],
),
),
if (!followSystemTheme) const DarkModeButton(),
const FollowSystemThemeButton(),
const ThemeSelector(),
if (isDarkTheme)
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: SwitchListTile(
title: Text(l10n.pure_black_dark_mode),
value: pureBlackDarkMode,
onChanged: (value) {
ref
.read(pureBlackDarkModeStateProvider.notifier)
.set(value);
},
),
),
if (!pureBlackDarkMode || !isDarkTheme)
const BlendLevelSlider(),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
children: [
Text(
l10n.appearance,
style: TextStyle(
fontSize: 13,
color: context.primaryColor,
),
),
],
),
),
ListTile(
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.app_language),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount:
AppLocalizations.supportedLocales.length,
itemBuilder: (context, index) {
final locale =
AppLocalizations.supportedLocales[index];
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: locale,
groupValue: l10nLocale,
onChanged: (value) {
ref
.read(
l10nLocaleStateProvider.notifier,
)
.setLocale(locale);
Navigator.pop(context);
},
title: Text(
completeLanguageName(
locale.toLanguageTag(),
),
),
);
},
child: TextField(
onChanged: (v) {
setState(() {
textValue = v;
});
},
decoration: InputDecoration(
isDense: true,
filled: false,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.secondaryColor,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.primaryColor,
),
),
border: const OutlineInputBorder(
borderSide: BorderSide(),
),
hintText: l10n.search,
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(
color: context.primaryColor,
),
),
),
],
),
],
);
},
);
},
title: Text(l10n.app_language),
subtitle: Text(
completeLanguageName(l10nLocale.toLanguageTag()),
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
),
ListTile(
onTap: () {
String textValue = "";
final controller = ScrollController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(context.l10n.font),
content: StatefulBuilder(
builder: (context, setState) {
return SizedBox(
width: context.width(0.8),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 8,
),
child: TextField(
onChanged: (v) {
setState(() {
textValue = v;
});
},
decoration: InputDecoration(
isDense: true,
filled: false,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.secondaryColor,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.primaryColor,
),
),
border: const OutlineInputBorder(
borderSide: BorderSide(),
),
hintText: l10n.search,
),
),
),
Builder(
builder: (context) {
List values =
GoogleFonts.asMap().entries
.toList();
values =
values
.where(
(values) => values.key
.toLowerCase()
.contains(
textValue
.toLowerCase(),
),
),
),
Builder(
builder: (context) {
List values = GoogleFonts.asMap().entries.toList();
values =
values
.where(
(values) => values.key
.toLowerCase()
.contains(textValue.toLowerCase()),
)
.toList();
return Flexible(
child: Scrollbar(
interactive: true,
thickness: 12,
radius: const Radius.circular(10),
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(0),
sliver: SuperSliverList.builder(
itemCount: values.length,
itemBuilder: (context, index) {
final value = values[index];
return RadioListTile(
dense: true,
contentPadding:
const EdgeInsets.all(0),
value: value.value().fontFamily,
groupValue: appFontFamily,
onChanged: (value) {
ref
.read(
appFontFamilyProvider
.notifier,
)
.toList();
return Flexible(
child: Scrollbar(
interactive: true,
thickness: 12,
radius: const Radius.circular(10),
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: [
SliverPadding(
padding:
const EdgeInsets.all(0),
sliver: SuperSliverList.builder(
itemCount: values.length,
itemBuilder: (
context,
index,
) {
final value =
values[index];
return RadioListTile(
dense: true,
contentPadding:
const EdgeInsets.all(
0,
),
value:
value
.value()
.fontFamily,
groupValue:
appFontFamily,
onChanged: (value) {
ref
.read(
appFontFamilyProvider
.notifier,
)
.set(value);
Navigator.pop(
context,
);
},
title: Text(
value.key,
),
);
},
),
),
],
),
),
.set(value);
Navigator.pop(context);
},
title: Text(value.key),
);
},
),
],
),
);
},
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
ref
.read(appFontFamilyProvider.notifier)
.set(null);
Navigator.pop(context);
},
child: Text(
l10n.default0,
style: TextStyle(
color: context.primaryColor,
),
),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(
color: context.primaryColor,
),
),
),
],
],
),
),
],
);
},
);
},
title: Text(context.l10n.font),
subtitle: Text(
appFontFamilySub,
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
),
ListTile(
onTap: () {
context.push("/customNavigationSettings");
},
title: Text(l10n.reorder_navigation),
subtitle: Text(
l10n.reorder_navigation_description,
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
children: [
Text(
l10n.timestamp,
style: TextStyle(
fontSize: 13,
color: context.primaryColor,
),
);
},
),
],
),
),
ListTile(
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.relative_timestamp),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount:
relativeTimestampsList(context).length,
itemBuilder: (context, index) {
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: index,
groupValue: relativeTimestamps,
onChanged: (value) {
ref
.read(
relativeTimesTampsStateProvider
.notifier,
)
.set(value!);
Navigator.pop(context);
},
title: Row(
children: [
Text(
relativeTimestampsList(
context,
)[index],
),
],
),
);
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(
color: context.primaryColor,
),
),
),
],
),
],
);
},
);
},
title: Text(l10n.relative_timestamp),
subtitle: Text(
relativeTimestampsList(context)[relativeTimestamps],
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
),
ListTile(
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.date_format),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount: dateFormatsList.length,
itemBuilder: (context, index) {
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: dateFormatsList[index],
groupValue: dateFormatState,
onChanged: (value) {
ref
.read(
dateFormatStateProvider.notifier,
)
.set(value!);
Navigator.pop(context);
},
title: Row(
children: [
Text(
"${dateFormatsList[index]} (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatsList[index], ref: ref)})",
),
],
),
);
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(
color: context.primaryColor,
),
),
),
],
),
],
);
},
);
},
title: Text(l10n.date_format),
subtitle: Text(
"$dateFormatState (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatState, ref: ref)})",
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
),
],
);
},
),
),
],
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
ref.read(appFontFamilyProvider.notifier).set(null);
Navigator.pop(context);
},
child: Text(
l10n.default0,
style: TextStyle(color: context.primaryColor),
),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(color: context.primaryColor),
),
),
],
),
],
);
},
);
},
);
}
Widget _buildRelativeTimestampTile(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) {
final relativeTimestamps = ref.watch(relativeTimesTampsStateProvider);
return ListTile(
title: Text(l10n.relative_timestamp),
subtitle: Text(
relativeTimestampsList(context)[relativeTimestamps],
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.relative_timestamp),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount: relativeTimestampsList(context).length,
itemBuilder: (context, index) {
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: index,
groupValue: relativeTimestamps,
onChanged: (value) {
ref
.read(relativeTimesTampsStateProvider.notifier)
.set(value!);
Navigator.pop(context);
},
title: Row(
children: [
Text(relativeTimestampsList(context)[index]),
],
),
);
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(color: context.primaryColor),
),
),
],
),
],
);
},
);
},
);
}
Widget _buildDateFormatTile(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) {
final dateFormatState = ref.watch(dateFormatStateProvider);
return ListTile(
title: Text(l10n.date_format),
subtitle: Text(
"$dateFormatState (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatState, ref: ref)})",
style: TextStyle(fontSize: 11, color: context.secondaryColor),
),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.date_format),
content: SizedBox(
width: context.width(0.8),
child: SuperListView.builder(
shrinkWrap: true,
itemCount: dateFormatsList.length,
itemBuilder: (context, index) {
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: dateFormatsList[index],
groupValue: dateFormatState,
onChanged: (value) {
ref.read(dateFormatStateProvider.notifier).set(value!);
Navigator.pop(context);
},
title: Row(
children: [
Text(
"${dateFormatsList[index]} (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatsList[index], ref: ref)})",
),
],
),
);
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(color: context.primaryColor),
),
),
],
),
],
);
},
);
},
);
}
}

View file

@ -21,70 +21,67 @@ class _CustomNavigationSettingsState
final hideItems = ref.watch(hideItemsStateProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.reorder_navigation)),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
itemCount: navigationOrder.length,
itemBuilder: (context, index) {
final navigation = navigationOrder[index];
return Row(
key: Key('navigation_$navigation'),
children: [
ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: navigationOrder.length,
itemBuilder: (context, index) {
final navigation = navigationOrder[index];
return Row(
key: Key('navigation_$navigation'),
children: [
ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
Expanded(
child: SwitchListTile(
key: Key(navigation),
dense: true,
value: !hideItems.contains(navigation),
onChanged:
[
"/more",
"/browse",
"/history",
].any((element) => element == navigation)
? null
: (value) {
final temp = hideItems.toList();
if (!value && !hideItems.contains(navigation)) {
temp.add(navigation);
} else if (value) {
temp.remove(navigation);
}
ref
.read(hideItemsStateProvider.notifier)
.set(temp);
},
title: Text(navigationItems[navigation]!),
),
Expanded(
child: SwitchListTile(
key: Key(navigation),
dense: true,
value: !hideItems.contains(navigation),
onChanged:
[
"/more",
"/browse",
"/history",
].any((element) => element == navigation)
? null
: (value) {
final temp = hideItems.toList();
if (!value && !hideItems.contains(navigation)) {
temp.add(navigation);
} else if (value) {
temp.remove(navigation);
}
ref
.read(hideItemsStateProvider.notifier)
.set(temp);
},
title: Text(navigationItems[navigation]!),
),
),
],
);
},
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
final draggedItem = navigationOrder[oldIndex];
for (var i = oldIndex; i < newIndex - 1; i++) {
navigationOrder[i] = navigationOrder[i + 1];
}
navigationOrder[newIndex - 1] = draggedItem;
} else {
final draggedItem = navigationOrder[oldIndex];
for (var i = oldIndex; i > newIndex; i--) {
navigationOrder[i] = navigationOrder[i - 1];
}
navigationOrder[newIndex] = draggedItem;
),
],
);
},
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
final draggedItem = navigationOrder[oldIndex];
for (var i = oldIndex; i < newIndex - 1; i++) {
navigationOrder[i] = navigationOrder[i + 1];
}
ref
.read(navigationOrderStateProvider.notifier)
.set(navigationOrder);
},
),
navigationOrder[newIndex - 1] = draggedItem;
} else {
final draggedItem = navigationOrder[oldIndex];
for (var i = oldIndex; i > newIndex; i--) {
navigationOrder[i] = navigationOrder[i - 1];
}
navigationOrder[newIndex] = draggedItem;
}
ref
.read(navigationOrderStateProvider.notifier)
.set(navigationOrder);
},
),
),
);

View file

@ -8,9 +8,7 @@ class OAuth {
OAuth.fromJson(Map<String, dynamic> json) {
tokenType = json['token_type'];
expiresIn =
(json['expires_in'] as int) * 1000 +
DateTime.now().millisecondsSinceEpoch;
expiresIn = json['expires_in'] as int;
accessToken = json['access_token'];
refreshToken = json['refresh_token'];
}

View file

@ -97,22 +97,25 @@ class StorageProvider {
dir!.path,
'downloads',
itemTypePath,
'${manga.source} (${manga.lang!.toUpperCase()})',
manga.name!.replaceForbiddenCharacters('_'),
'${manga.source} (${manga.lang!.toUpperCase()})'.trim(),
manga.name!.replaceForbiddenCharacters('_').trim(),
),
);
}
Future<Directory?> getMangaChapterDirectory(Chapter chapter) async {
final basedir = await getMangaMainDirectory(chapter);
Future<Directory?> getMangaChapterDirectory(
Chapter chapter, {
Directory? mangaMainDirectory,
}) async {
final basedir = mangaMainDirectory ?? await getMangaMainDirectory(chapter);
String scanlator =
chapter.scanlator?.isNotEmpty ?? false
? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_"
? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_".trim()
: "";
return Directory(
path.join(
basedir!.path,
scanlator + chapter.name!.replaceForbiddenCharacters('_'),
scanlator + chapter.name!.replaceForbiddenCharacters('_').trim(),
),
);
}

View file

@ -93,530 +93,132 @@ class RouterNotifier extends ChangeNotifier {
ShellRoute(
builder: (context, state, child) => MainScreen(child: child),
routes: [
GoRoute(
_genericRoute<String?>(
name: "MangaLibrary",
path: '/MangaLibrary',
builder: (context, state) {
final presetInput = state.extra as String?;
return LibraryScreen(
itemType: ItemType.manga,
presetInput: presetInput,
);
},
pageBuilder: (context, state) {
final presetInput = state.extra as String?;
return transitionPage(
key: state.pageKey,
child: LibraryScreen(
itemType: ItemType.manga,
presetInput: presetInput,
),
);
},
builder:
(id) => LibraryScreen(itemType: ItemType.manga, presetInput: id),
),
GoRoute(
_genericRoute<String?>(
name: "AnimeLibrary",
path: '/AnimeLibrary',
builder: (context, state) {
final presetInput = state.extra as String?;
return LibraryScreen(
itemType: ItemType.anime,
presetInput: presetInput,
);
},
pageBuilder: (context, state) {
final presetInput = state.extra as String?;
return transitionPage(
key: state.pageKey,
child: LibraryScreen(
itemType: ItemType.anime,
presetInput: presetInput,
),
);
},
builder:
(id) => LibraryScreen(itemType: ItemType.anime, presetInput: id),
),
GoRoute(
_genericRoute<String?>(
name: "NovelLibrary",
path: '/NovelLibrary',
builder: (context, state) {
final presetInput = state.extra as String?;
return LibraryScreen(
itemType: ItemType.novel,
presetInput: presetInput,
);
},
pageBuilder: (context, state) {
final presetInput = state.extra as String?;
return transitionPage(
key: state.pageKey,
child: LibraryScreen(
itemType: ItemType.novel,
presetInput: presetInput,
),
);
},
),
GoRoute(
name: "history",
path: '/history',
builder: (context, state) => const HistoryScreen(),
pageBuilder:
(context, state) => transitionPage(
key: state.pageKey,
child: const HistoryScreen(),
),
),
GoRoute(
name: "updates",
path: '/updates',
builder: (context, state) => const UpdatesScreen(),
pageBuilder:
(context, state) => transitionPage(
key: state.pageKey,
child: const UpdatesScreen(),
),
),
GoRoute(
name: "browse",
path: '/browse',
builder: (context, state) => const BrowseScreen(),
pageBuilder:
(context, state) => transitionPage(
key: state.pageKey,
child: const BrowseScreen(),
),
),
GoRoute(
name: "more",
path: '/more',
builder: (context, state) => const MoreScreen(),
pageBuilder:
(context, state) =>
transitionPage(key: state.pageKey, child: const MoreScreen()),
builder:
(id) => LibraryScreen(itemType: ItemType.novel, presetInput: id),
),
_genericRoute(name: "history", child: const HistoryScreen()),
_genericRoute(name: "updates", child: const UpdatesScreen()),
_genericRoute(name: "browse", child: const BrowseScreen()),
_genericRoute(name: "more", child: const MoreScreen()),
],
),
GoRoute(
path: "/mangaHome",
_genericRoute<(Source?, bool)>(
name: "mangaHome",
builder: (context, state) {
final source = state.extra as (Source?, bool);
return MangaHomeScreen(source: source.$1!, isLatest: source.$2);
},
pageBuilder: (context, state) {
final source = state.extra as (Source?, bool);
return transitionPage(
key: state.pageKey,
child: MangaHomeScreen(source: source.$1!, isLatest: source.$2),
);
},
builder: (id) => MangaHomeScreen(source: id.$1!, isLatest: id.$2),
),
GoRoute(
path: '/manga-reader/detail',
builder: (context, state) {
int mangaId = state.extra as int;
return MangaReaderDetail(mangaId: mangaId);
},
pageBuilder: (context, state) {
int mangaId = state.extra as int;
return transitionPage(
key: state.pageKey,
child: MangaReaderDetail(mangaId: mangaId),
);
},
_genericRoute<int>(
path: "/manga-reader/detail",
builder: (id) => MangaReaderDetail(mangaId: id),
),
GoRoute(
path: "/mangaReaderView",
_genericRoute<int>(
name: "mangaReaderView",
builder: (context, state) {
final chapterId = state.extra as int;
return MangaReaderView(chapterId: chapterId);
},
pageBuilder: (context, state) {
final chapterId = state.extra as int;
return transitionPage(
key: state.pageKey,
child: MangaReaderView(chapterId: chapterId),
);
},
builder: (id) => MangaReaderView(chapterId: id),
),
GoRoute(
path: "/animePlayerView",
_genericRoute<int>(
name: "animePlayerView",
builder: (context, state) {
final episodeId = state.extra as int;
return AnimePlayerView(episodeId: episodeId);
},
pageBuilder: (context, state) {
final episodeId = state.extra as int;
return transitionPage(
key: state.pageKey,
child: AnimePlayerView(episodeId: episodeId),
);
},
builder: (id) => AnimePlayerView(episodeId: id),
),
GoRoute(
path: "/novelReaderView",
_genericRoute<int>(
name: "novelReaderView",
builder: (context, state) {
final chapterId = state.extra as int;
return NovelReaderView(chapterId: chapterId);
},
pageBuilder: (context, state) {
final chapterId = state.extra as int;
return transitionPage(
key: state.pageKey,
child: NovelReaderView(chapterId: chapterId),
);
},
builder: (id) => NovelReaderView(chapterId: id),
),
GoRoute(
path: "/ExtensionLang",
_genericRoute<ItemType>(
name: "ExtensionLang",
builder: (context, state) {
final itemType = state.extra as ItemType;
return ExtensionsLang(itemType: itemType);
},
pageBuilder: (context, state) {
final itemType = state.extra as ItemType;
return transitionPage(
key: state.pageKey,
child: ExtensionsLang(itemType: itemType),
);
},
builder: (itemType) => ExtensionsLang(itemType: itemType),
),
GoRoute(
path: "/settings",
name: "settings",
builder: (context, state) {
return const SettingsScreen();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const SettingsScreen(),
);
},
),
GoRoute(
path: "/appearance",
name: "appearance",
builder: (context, state) {
return const AppearanceScreen();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const AppearanceScreen(),
);
},
),
GoRoute(
path: "/extension_detail",
_genericRoute(name: "settings", child: const SettingsScreen()),
_genericRoute(name: "appearance", child: const AppearanceScreen()),
_genericRoute<Source>(
name: "extension_detail",
builder: (context, state) {
final source = state.extra as Source;
return ExtensionDetail(source: source);
},
pageBuilder: (context, state) {
final source = state.extra as Source;
return transitionPage(
key: state.pageKey,
child: ExtensionDetail(source: source),
);
},
builder: (source) => ExtensionDetail(source: source),
),
GoRoute(
path: "/globalSearch",
_genericRoute<ItemType>(
name: "globalSearch",
builder: (context, state) {
final itemType = state.extra as ItemType;
return GlobalSearchScreen(itemType: itemType);
},
pageBuilder: (context, state) {
final itemType = state.extra as ItemType;
return transitionPage(
key: state.pageKey,
child: GlobalSearchScreen(itemType: itemType),
);
},
builder: (itemType) => GlobalSearchScreen(itemType: itemType),
),
GoRoute(
path: "/about",
name: "about",
builder: (context, state) {
return const AboutScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const AboutScreen());
},
),
GoRoute(
path: "/track",
name: "track",
builder: (context, state) {
return const TrackScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const TrackScreen());
},
),
GoRoute(
path: "/sync",
name: "sync",
builder: (context, state) {
return const SyncScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const SyncScreen());
},
),
GoRoute(
path: "/sourceFilter",
_genericRoute(name: "about", child: const AboutScreen()),
_genericRoute(name: "track", child: const TrackScreen()),
_genericRoute(name: "sync", child: const SyncScreen()),
_genericRoute<ItemType>(
name: "sourceFilter",
builder: (context, state) {
final itemType = state.extra as ItemType;
return SourcesFilterScreen(itemType: itemType);
},
pageBuilder: (context, state) {
final itemType = state.extra as ItemType;
return transitionPage(
key: state.pageKey,
child: SourcesFilterScreen(itemType: itemType),
);
},
builder: (itemType) => SourcesFilterScreen(itemType: itemType),
),
GoRoute(
path: "/downloadQueue",
name: "downloadQueue",
builder: (context, state) {
return const DownloadQueueScreen();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const DownloadQueueScreen(),
);
},
),
GoRoute(
path: "/mangawebview",
_genericRoute(name: "downloadQueue", child: const DownloadQueueScreen()),
_genericRoute<Map<String, dynamic>>(
name: "mangawebview",
builder: (context, state) {
final data = state.extra as Map<String, dynamic>;
return MangaWebView(url: data["url"]!, title: data['title']!);
},
pageBuilder: (context, state) {
final data = state.extra as Map<String, dynamic>;
return transitionPage(
key: state.pageKey,
child: MangaWebView(url: data["url"]!, title: data['title']!),
);
},
builder: (data) => MangaWebView(url: data["url"]!, title: data['title']!),
),
GoRoute(
path: "/categories",
_genericRoute<(bool, int)>(
name: "categories",
builder: (context, state) {
final data = state.extra as (bool, int);
return CategoriesScreen(data: data);
},
pageBuilder: (context, state) {
final data = state.extra as (bool, int);
return transitionPage(
key: state.pageKey,
child: CategoriesScreen(data: data),
);
},
builder: (data) => CategoriesScreen(data: data),
),
GoRoute(
path: "/statistics",
name: "statistics",
builder: (context, state) {
return StatisticsScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: StatisticsScreen());
},
),
GoRoute(
path: "/general",
name: "general",
builder: (context, state) {
return const GeneralScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const GeneralScreen());
},
),
GoRoute(
path: "/readerMode",
name: "readerMode",
builder: (context, state) {
return const ReaderScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const ReaderScreen());
},
),
GoRoute(
path: "/browseS",
name: "browseS",
builder: (context, state) {
return const BrowseSScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const BrowseSScreen());
},
),
GoRoute(
path: "/SourceRepositories",
_genericRoute(name: "statistics", child: const StatisticsScreen()),
_genericRoute(name: "general", child: const GeneralScreen()),
_genericRoute(name: "readerMode", child: const ReaderScreen()),
_genericRoute(name: "browseS", child: const BrowseSScreen()),
_genericRoute<ItemType>(
name: "SourceRepositories",
builder: (context, state) {
final itemType = state.extra as ItemType;
return SourceRepositories(itemType: itemType);
},
pageBuilder: (context, state) {
final itemType = state.extra as ItemType;
return transitionPage(
key: state.pageKey,
child: SourceRepositories(itemType: itemType),
);
},
builder: (itemType) => SourceRepositories(itemType: itemType),
),
GoRoute(
path: "/downloads",
name: "downloads",
builder: (context, state) {
return const DownloadsScreen();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const DownloadsScreen(),
);
},
),
GoRoute(
path: "/dataAndStorage",
name: "dataAndStorage",
builder: (context, state) {
return const DataAndStorage();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const DataAndStorage(),
);
},
),
GoRoute(
path: "/manageTrackers",
name: "manageTrackers",
builder: (context, state) {
return const ManageTrackersScreen();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const ManageTrackersScreen(),
);
},
),
GoRoute(
path: "/trackingDetail",
_genericRoute(name: "downloads", child: const DownloadsScreen()),
_genericRoute(name: "dataAndStorage", child: const DataAndStorage()),
_genericRoute(name: "manageTrackers", child: const ManageTrackersScreen()),
_genericRoute<TrackPreference>(
name: "trackingDetail",
builder: (context, state) {
final trackerPref = state.extra as TrackPreference;
return TrackingDetail(trackerPref: trackerPref);
},
pageBuilder: (context, state) {
final trackerPref = state.extra as TrackPreference;
return transitionPage(
key: state.pageKey,
child: TrackingDetail(trackerPref: trackerPref),
);
},
builder: (trackerPref) => TrackingDetail(trackerPref: trackerPref),
),
GoRoute(
path: "/playerMode",
name: "playerMode",
builder: (context, state) {
return const PlayerScreen();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const PlayerScreen());
},
),
GoRoute(
path: "/codeEditor",
_genericRoute(name: "playerMode", child: const PlayerScreen()),
_genericRoute<int>(
name: "codeEditor",
builder: (context, state) {
final sourceId = state.extra as int?;
return CodeEditorPage(sourceId: sourceId);
},
pageBuilder: (context, state) {
final sourceId = state.extra as int?;
return transitionPage(
key: state.pageKey,
child: CodeEditorPage(sourceId: sourceId),
);
},
builder: (sourceId) => CodeEditorPage(sourceId: sourceId),
),
GoRoute(
path: "/createExtension",
name: "createExtension",
builder: (context, state) {
return const CreateExtension();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const CreateExtension(),
);
},
),
GoRoute(
path: "/createBackup",
name: "createBackup",
builder: (context, state) {
return const CreateBackup();
},
pageBuilder: (context, state) {
return transitionPage(key: state.pageKey, child: const CreateBackup());
},
),
GoRoute(
path: "/customNavigationSettings",
_genericRoute(name: "createExtension", child: const CreateExtension()),
_genericRoute(name: "createBackup", child: const CreateBackup()),
_genericRoute(
name: "customNavigationSettings",
builder: (context, state) {
return const CustomNavigationSettings();
},
pageBuilder: (context, state) {
return transitionPage(
key: state.pageKey,
child: const CustomNavigationSettings(),
);
},
child: const CustomNavigationSettings(),
),
GoRoute(
path: "/migrate",
_genericRoute<Manga>(
name: "migrate",
builder: (context, state) {
final manga = state.extra as Manga;
return MigrationScreen(manga: manga);
},
pageBuilder: (context, state) {
final manga = state.extra as Manga;
return transitionPage(
key: state.pageKey,
child: MigrationScreen(manga: manga),
);
},
builder: (manga) => MigrationScreen(manga: manga),
),
];
GoRoute _genericRoute<T>({
String? name,
String? path,
Widget Function(T extra)? builder,
Widget? child,
}) {
return GoRoute(
path: path ?? (name != null ? "/$name" : "/"),
name: name,
builder: (context, state) {
if (builder != null) {
final id = state.extra as T;
return builder(id);
} else {
return child!;
}
},
pageBuilder: (context, state) {
final pageChild = builder != null ? builder(state.extra as T) : child!;
return transitionPage(key: state.pageKey, child: pageChild);
},
);
}
}
Page transitionPage({required LocalKey key, required child}) {

View file

@ -50,8 +50,11 @@ Future<GetChapterPagesModel> getChapterPages(
.firstOrNull;
final incognitoMode = ref.watch(incognitoModeStateProvider);
final storageProvider = StorageProvider();
path = await storageProvider.getMangaChapterDirectory(chapter);
final mangaDirectory = await storageProvider.getMangaMainDirectory(chapter);
path = await storageProvider.getMangaChapterDirectory(
chapter,
mangaMainDirectory: mangaDirectory,
);
List<Uint8List?> archiveImages = [];
final isLocalArchive = (chapter.archivePath ?? '').isNotEmpty;

View file

@ -15,6 +15,8 @@ import 'package:mangayomi/services/http/rhttp/rhttp.dart' as rhttp;
class MClient {
MClient();
static final defaultClient = IOClient(HttpClient());
static final Map<rhttp.ClientSettings, Client> rhttpPool = {};
static Client httpClient({
Map<String, dynamic>? reqcopyWith,
rhttp.ClientSettings? settings,
@ -41,10 +43,12 @@ class MClient {
verifyCertificates: reqcopyWith?["verifyCertificates"] ?? false,
),
);
return rhttp.RhttpCompatibleClient.createSync(settings: settings);
return rhttpPool.putIfAbsent(settings, () {
return rhttp.RhttpCompatibleClient.createSync(settings: settings);
});
} catch (_) {}
}
return IOClient(HttpClient());
return defaultClient;
}
static InterceptedClient init({
@ -66,12 +70,11 @@ class MClient {
static Map<String, String> getCookiesPref(String url) {
final cookiesList = isar.settings.getSync(227)!.cookiesList ?? [];
if (cookiesList.isEmpty) return {};
final host = Uri.parse(url).host;
final cookies =
cookiesList
.firstWhere(
(element) =>
element.host == Uri.parse(url).host ||
Uri.parse(url).host.contains(element.host!),
(element) => element.host == host || host.contains(element.host!),
orElse: () => MCookie(cookie: ""),
)
.cookie!;
@ -93,56 +96,51 @@ class MClient {
.split(RegExp('(?<=)(,)(?=[^;]+?=)'))
.where((cookie) => cookie.isNotEmpty)
.toList();
} else {
if (!Platform.isLinux) {
cookies =
(await flutter_inappwebview.CookieManager.instance(
webViewEnvironment: webViewEnvironment,
).getCookies(
url: flutter_inappwebview.WebUri(url),
webViewController: webViewController,
)).map((e) => "${e.name}=${e.value}").toList();
}
} else if (!Platform.isLinux) {
cookies =
(await flutter_inappwebview.CookieManager.instance(
webViewEnvironment: webViewEnvironment,
).getCookies(
url: flutter_inappwebview.WebUri(url),
webViewController: webViewController,
)).map((e) => "${e.name}=${e.value}").toList();
}
if (cookies.isNotEmpty) {
final host = Uri.parse(url).host;
final newCookie = cookies.join("; ");
final settings = isar.settings.getSync(227);
List<MCookie>? cookieList = [];
for (var cookie in settings!.cookiesList ?? []) {
if (cookie.host != host || (!host.contains(cookie.host))) {
cookieList.add(cookie);
}
}
cookieList.add(
final settings = await isar.settings.get(227);
final existingCookies = settings!.cookiesList ?? [];
final filteredCookies = removeCookiesForHost(existingCookies, host);
filteredCookies.add(
MCookie()
..host = host
..cookie = newCookie,
);
isar.writeTxnSync(
() => isar.settings.putSync(settings..cookiesList = cookieList),
await isar.writeTxn(
() => isar.settings.put(settings..cookiesList = filteredCookies),
);
}
if (ua.isNotEmpty) {
final settings = isar.settings.getSync(227);
isar.writeTxnSync(() => isar.settings.putSync(settings!..userAgent = ua));
final settings = await isar.settings.get(227);
await isar.writeTxn(() => isar.settings.put(settings!..userAgent = ua));
}
}
static void deleteAllCookies(String url) {
final cookiesList = isar.settings.getSync(227)!.cookiesList ?? [];
List<MCookie>? cookieList = [];
for (var cookie in cookiesList) {
if (!(cookie.host == Uri.parse(url).host ||
Uri.parse(url).host.contains(cookie.host!))) {
cookieList.add(cookie);
}
}
isar.writeTxnSync(
() => isar.settings.putSync(
isar.settings.getSync(227)!..cookiesList = cookieList,
),
);
static List<MCookie> removeCookiesForHost(
List<MCookie> allCookies,
String host,
) {
return allCookies
.where((cookie) => cookie.host != host && !host.contains(cookie.host!))
.toList();
}
static Future<void> deleteAllCookies(String url) async {
final settings = await isar.settings.get(227);
final oldCookies = settings!.cookiesList ?? [];
final host = Uri.parse(url).host;
settings.cookiesList = removeCookiesForHost(oldCookies, host);
await isar.writeTxn(() => isar.settings.put(settings));
}
}
@ -154,7 +152,8 @@ class MCookieManager extends InterceptorContract {
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
final cookie = MClient.getCookiesPref(request.url.toString());
if (cookie.isNotEmpty) {
final userAgent = isar.settings.getSync(227)!.userAgent!;
final settings = await isar.settings.get(227);
final userAgent = settings!.userAgent!;
if (request.headers[HttpHeaders.cookieHeader] == null) {
request.headers.addAll(cookie);
}
@ -211,12 +210,7 @@ class LoggerInterceptor extends InterceptorContract {
required BaseResponse response,
}) async {
if (showCloudFlareError) {
final cloudflare =
[403, 503].contains(response.statusCode) &&
[
"cloudflare-nginx",
"cloudflare",
].contains(response.headers["server"]);
final cloudflare = isCloudflare(response);
final content =
"----- Response -----\n${response.request?.method}: ${response.request?.url}, statusCode: ${response.statusCode} ${cloudflare ? "Failed to bypass Cloudflare" : ""}";
if (kDebugMode) {
@ -238,6 +232,11 @@ class LoggerInterceptor extends InterceptorContract {
}
}
bool isCloudflare(BaseResponse response) {
return [403, 503].contains(response.statusCode) &&
["cloudflare-nginx", "cloudflare"].contains(response.headers["server"]);
}
class ResolveCloudFlareChallenge extends RetryPolicy {
bool showCloudFlareError;
ResolveCloudFlareChallenge(this.showCloudFlareError);
@ -249,11 +248,8 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
flutter_inappwebview.HeadlessInAppWebView? headlessWebView;
int time = 0;
bool timeOut = false;
final cloudflare =
[403, 503].contains(response.statusCode) &&
["cloudflare-nginx", "cloudflare"].contains(response.headers["server"]);
if (cloudflare) {
bool isCloudFlare = true;
bool isCloudFlare = isCloudflare(response);
if (isCloudFlare) {
headlessWebView = flutter_inappwebview.HeadlessInAppWebView(
webViewEnvironment: webViewEnvironment,
initialUrlRequest: flutter_inappwebview.URLRequest(
@ -270,10 +266,7 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
}
await Future.doWhile(() async {
if (timeOut == true) {
return false;
}
if (isCloudFlare) {
if (!timeOut && isCloudFlare) {
try {
isCloudFlare = await controller.platform.evaluateJavascript(
source:
@ -282,9 +275,10 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
} catch (_) {
isCloudFlare = false;
}
return true;
}
return false;
if (isCloudFlare) await Future.delayed(Duration(milliseconds: 300));
return isCloudFlare;
});
if (!timeOut) {
final ua =

View file

@ -16,15 +16,15 @@ part 'anilist.g.dart';
@riverpod
class Anilist extends _$Anilist {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
final String _clientId =
(Platform.isWindows || Platform.isLinux) ? '13587' : '13588';
static final _isDesktop = Platform.isWindows || Platform.isLinux;
final String _clientId = _isDesktop ? '13587' : '13588';
static const String _baseApiUrl = "https://graphql.anilist.co/";
final String _redirectUri =
(Platform.isWindows || Platform.isLinux)
_isDesktop
? 'http://localhost:43824/success?code=1337'
: 'mangayomi://success?code=1337';
final String _clientSecret =
(Platform.isWindows || Platform.isLinux)
_isDesktop
? 'tJA13cAR2tCCXrJCwwvmwEDbWRoIaahFiJTXToHd'
: 'G2fFUiGtgFd60D0lCkhgGKvMmrCfDmZXADQIzWXr';
@ -33,11 +33,10 @@ class Anilist extends _$Anilist {
Future<bool?> login() async {
final callbackUrlScheme =
(Platform.isWindows || Platform.isLinux)
? 'http://localhost:43824'
: 'mangayomi';
_isDesktop ? 'http://localhost:43824' : 'mangayomi';
final loginUrl =
'https://anilist.co/api/v2/oauth/authorize?client_id=$_clientId&redirect_uri=$_redirectUri&response_type=code';
'https://anilist.co/api/v2/oauth/authorize?client_id=$_clientId'
'&redirect_uri=$_redirectUri&response_type=code';
try {
final uri = await FlutterWebAuth2.authenticate(
@ -57,7 +56,11 @@ class Anilist extends _$Anilist {
},
);
final res = jsonDecode(response.body) as Map<String, dynamic>;
final aLOAuth = OAuth.fromJson(res);
final aLOAuth = OAuth.fromJson(res)
..expiresIn =
DateTime.now()
.add(Duration(seconds: res['expires_in']))
.millisecondsSinceEpoch;
final currenUser = await _getCurrentUser(aLOAuth.accessToken!);
ref
.read(tracksProvider(syncId: syncId).notifier)
@ -76,84 +79,19 @@ class Anilist extends _$Anilist {
}
}
Future<Track> addLibManga(Track track) async {
final accessToken = await _getAccesToken();
Future<Track> update(Track track, bool isManga) async {
final isNew = track.libraryId == null;
final opName = isNew ? 'AddEntry' : 'UpdateEntry';
final idVarName = isNew ? 'mediaId' : 'id';
final idVarValue = isNew ? track.mediaId : track.libraryId!;
const query = '''
mutation AddManga(\$mangaId: Int, \$progress: Int, \$status: MediaListStatus) {
SaveMediaListEntry(mediaId: \$mangaId, progress: \$progress, status: \$status) {
id
status
}
}
''';
final body = {
"query": query,
"variables": {
"mangaId": track.mediaId,
"progress": track.lastChapterRead,
"status": toAniListStatusManga(track.status),
},
};
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
final data = json.decode(response.body);
track.libraryId = data['data']['SaveMediaListEntry']['id'];
return track;
}
Future<Track> addLibAnime(Track track) async {
final accessToken = await _getAccesToken();
const query = '''
mutation AddAnime(\$animeId: Int, \$progress: Int, \$status: MediaListStatus) {
SaveMediaListEntry(mediaId: \$animeId, progress: \$progress, status: \$status) {
id
status
}
}
''';
final body = {
"query": query,
"variables": {
"animeId": track.mediaId,
"progress": track.lastChapterRead,
"status": toAniListStatusAnime(track.status),
},
};
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
final data = json.decode(response.body);
track.libraryId = data['data']['SaveMediaListEntry']['id'];
return track;
}
Future<Track> updateLibManga(Track track) async {
final accessToken = await _getAccesToken();
const query = '''
mutation UpdateManga(\$listId: Int, \$progress: Int, \$status: MediaListStatus, \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput) {
final document = '''
mutation $opName(\$$idVarName: Int!, \$progress: Int!, \$status: MediaListStatus${isNew ? '' : ', \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput'} ) {
SaveMediaListEntry(
id: \$listId,
${isNew ? 'mediaId' : 'id'}: \$$idVarName,
progress: \$progress,
status: \$status,
scoreRaw: \$score,
startedAt: \$startedAt,
completedAt: \$completedAt,
${!isNew ? 'scoreRaw: \$score, startedAt: \$startedAt, completedAt: \$completedAt,' : ''}
) {
id
status
@ -162,115 +100,47 @@ class Anilist extends _$Anilist {
}
''';
final body = {
"query": query,
"variables": {
"listId": track.libraryId,
"progress": track.lastChapterRead,
"status": toAniListStatusManga(track.status),
"score": track.score!,
"startedAt": createDate(track.startedReadingDate!),
"completedAt": createDate(track.finishedReadingDate!),
},
final vars = {
idVarName: idVarValue,
'progress': track.lastChapterRead,
'status': toAniListStatus(track.status, isManga),
if (!isNew) 'score': track.score!,
if (!isNew) 'startedAt': createDate(track.startedReadingDate!),
if (!isNew) 'completedAt': createDate(track.finishedReadingDate!),
};
await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
final data = await _executeGraphQL(document, vars);
final entry = data['SaveMediaListEntry'] as Map<String, dynamic>;
track.libraryId = entry['id'] as int;
return track;
}
Future<Track> updateLibAnime(Track track) async {
final accessToken = await _getAccesToken();
const query = '''
mutation UpdateAnime(\$listId: Int, \$progress: Int, \$status: MediaListStatus, \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput) {
SaveMediaListEntry(
id: \$listId,
progress: \$progress,
status: \$status,
scoreRaw: \$score,
startedAt: \$startedAt,
completedAt: \$completedAt,
) {
id
status
progress
}
}
''';
final body = {
"query": query,
"variables": {
"listId": track.libraryId,
"progress": track.lastChapterRead,
"status": toAniListStatusAnime(track.status),
"score": track.score!,
"startedAt": createDate(track.startedReadingDate!),
"completedAt": createDate(track.finishedReadingDate!),
},
};
await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
return track;
}
Future<List<TrackSearch>> search(String search) async {
final accessToken = await _getAccesToken();
const query = '''
Future<List<TrackSearch>> search(String search, bool isManga) async {
final type = isManga ? "MANGA" : "ANIME";
final contentUnit = isManga ? "chapters" : "episodes";
final query = '''
query Search(\$query: String) {
Page(perPage: 50) {
media(search: \$query, type: MANGA, format_not_in: [NOVEL]) {
media(search: \$query, type: $type, format_not_in: [NOVEL]) {
id
title {
userPreferred
}
coverImage {
large
}
title { userPreferred }
coverImage { large }
format
status
chapters
$contentUnit
description
startDate {
year
month
day
}
startDate { year month day }
}
}
}
''';
final body = {
"query": query,
"variables": {"query": search},
};
final vars = {"query": search};
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
final data = json.decode(response.body);
final data = await _executeGraphQL(query, vars);
final entries = List<Map<String, dynamic>>.from(
data['data']['Page']['media'],
data['Page']['media'] as List,
);
return entries
.map(
@ -280,7 +150,7 @@ class Anilist extends _$Anilist {
trackingUrl: "",
mediaId: jsonRes['id'],
summary: jsonRes['description'] ?? "",
totalChapter: jsonRes['chapters'] ?? 0,
totalChapter: jsonRes[contentUnit] ?? 0,
coverUrl: jsonRes['coverImage']['large'] ?? "",
title: jsonRes['title']['userPreferred'],
startDate:
@ -295,229 +165,77 @@ class Anilist extends _$Anilist {
.toList();
}
Future<List<TrackSearch>> searchAnime(String search) async {
final accessToken = await _getAccesToken();
const query = '''
query Search(\$query: String) {
Page(perPage: 50) {
media(search: \$query, type: ANIME) {
id
title {
userPreferred
}
coverImage {
large
}
format
status
episodes
description
startDate {
year
month
day
}
}
}
}
''';
final body = {
"query": query,
"variables": {"query": search},
};
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
Future<Track?> findLibItem(Track track, bool isManga) async {
final userId = int.parse(
ref.watch(tracksProvider(syncId: syncId))!.username!,
);
final type = isManga ? "MANGA" : "ANIME";
final typeVar = isManga ? "manga_id" : "anime_id";
final contentUnit = isManga ? "chapters" : "episodes";
final data = json.decode(response.body);
final entries = List<Map<String, dynamic>>.from(
data['data']['Page']['media'],
);
return entries
.map(
(jsonRes) => TrackSearch(
libraryId: jsonRes['id'],
syncId: syncId,
trackingUrl: "",
mediaId: jsonRes['id'],
summary: jsonRes['description'] ?? "",
totalChapter: jsonRes['episodes'] ?? 0,
coverUrl: jsonRes['coverImage']['large'] ?? "",
title: jsonRes['title']['userPreferred'],
startDate:
jsonRes["start_date"] ??
DateTime.fromMillisecondsSinceEpoch(
parseDate(jsonRes, 'startDate'),
).toString(),
publishingType: "",
publishingStatus: jsonRes['status'],
),
)
.toList();
}
Future<Track?> findLibManga(Track track) async {
final userId = ref.watch(tracksProvider(syncId: syncId))!.username;
final accessToken = await _getAccesToken();
const query = '''
query(\$id: Int!, \$manga_id: Int!) {
final query = '''
query(\$id: Int!, \$$typeVar: Int!) {
Page {
mediaList(userId: \$id, type: MANGA, mediaId: \$manga_id) {
mediaList(userId: \$id, type: $type, mediaId: \$$typeVar) {
id
status
scoreRaw: score(format: POINT_100)
progress
startedAt {
year
month
day
}
completedAt {
year
month
day
}
startedAt { year month day }
completedAt { year month day }
media {
id
title {
userPreferred
}
coverImage {
large
}
title { userPreferred }
coverImage { large }
format
status
chapters
$contentUnit
description
startDate {
year
month
day
}
startDate { year month day }
}
}
}
}
''';
final body = {
"query": query,
"variables": {"id": int.parse(userId!), "manga_id": track.mediaId},
};
final vars = {"id": userId, typeVar: track.mediaId};
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
body: json.encode(body),
);
final data = json.decode(response.body);
final data = await _executeGraphQL(query, vars);
final entries = List<Map<String, dynamic>>.from(
data['data']['Page']['mediaList'],
data['Page']['mediaList'] as List,
);
if (entries.isNotEmpty) {
final jsonRes = entries.first;
track.libraryId = jsonRes['id'];
track.syncId = syncId;
track.mediaId = jsonRes['media']['id'];
track.status = _getALTrackStatusManga(jsonRes['status']);
track.title = jsonRes['media']['title']['userPreferred'] ?? '';
track.score = jsonRes['scoreRaw'] ?? 0;
track.lastChapterRead = jsonRes['progress'] ?? 0;
track.startedReadingDate = parseDate(jsonRes, 'startedAt');
track.finishedReadingDate = parseDate(jsonRes, 'completedAt');
track.totalChapter = jsonRes['media']["chapters"] ?? 0;
}
return entries.isNotEmpty ? track : null;
if (entries.isEmpty) return null;
final jsonRes = entries.first;
return track
..libraryId = jsonRes['id'] as int
..syncId = syncId
..mediaId = jsonRes['media']['id'] as int
..status = _getALTrackStatus(jsonRes['status'], isManga)
..title = jsonRes['media']['title']['userPreferred'] ?? ''
..score = jsonRes['scoreRaw'] as int?
..lastChapterRead = jsonRes['progress'] as int? ?? 0
..startedReadingDate = parseDate(jsonRes, 'startedAt')
..finishedReadingDate = parseDate(jsonRes, 'completedAt')
..totalChapter = jsonRes['media'][contentUnit] as int? ?? 0;
}
Future<Track?> findLibAnime(Track track) async {
final userId = ref.watch(tracksProvider(syncId: syncId))!.username;
final accessToken = await _getAccesToken();
const query = '''
query(\$id: Int!, \$anime_id: Int!) {
Page {
mediaList(userId: \$id, type: ANIME, mediaId: \$anime_id) {
id
status
scoreRaw: score(format: POINT_100)
progress
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
id
title {
userPreferred
}
coverImage {
large
}
format
status
episodes
description
startDate {
year
month
day
}
}
}
}
}
''';
final body = {
"query": query,
"variables": {"id": int.parse(userId!), "anime_id": track.mediaId},
};
Future<Map<String, dynamic>> _executeGraphQL(
String document,
Map<String, dynamic> variables,
) async {
final response = await http.post(
Uri.parse(_baseApiUrl),
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
'Authorization': 'Bearer ${await _getAccessToken()}',
},
body: json.encode(body),
body: jsonEncode({'query': document, 'variables': variables}),
);
final data = json.decode(response.body);
final entries = List<Map<String, dynamic>>.from(
data['data']['Page']['mediaList'],
);
if (entries.isNotEmpty) {
final jsonRes = entries.first;
track.libraryId = jsonRes['id'];
track.syncId = syncId;
track.mediaId = jsonRes['media']['id'];
track.status = _getALTrackStatusAnime(jsonRes['status']);
track.title = jsonRes['media']['title']['userPreferred'] ?? '';
track.score = jsonRes['scoreRaw'] ?? 0;
track.lastChapterRead = jsonRes['progress'] ?? 0;
track.startedReadingDate = parseDate(jsonRes, 'startedAt');
track.finishedReadingDate = parseDate(jsonRes, 'completedAt');
track.totalChapter = jsonRes['media']["episodes"] ?? 0;
}
return entries.isNotEmpty ? track : null;
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return decoded['data'] as Map<String, dynamic>;
}
Future<(String, String)> _getCurrentUser(String accessToken) async {
@ -551,7 +269,7 @@ class Anilist extends _$Anilist {
);
}
Future<String> _getAccesToken() async {
Future<String> _getAccessToken() async {
final track = ref.watch(tracksProvider(syncId: syncId));
final mALOAuth = OAuth.fromJson(
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
@ -590,63 +308,35 @@ class Anilist extends _$Anilist {
};
}
TrackStatus _getALTrackStatusManga(String status) {
TrackStatus _getALTrackStatus(String status, bool isManga) {
return switch (status) {
"CURRENT" => TrackStatus.reading,
"CURRENT" => isManga ? TrackStatus.reading : TrackStatus.watching,
"COMPLETED" => TrackStatus.completed,
"PAUSED" => TrackStatus.onHold,
"DROPPED" => TrackStatus.dropped,
"PLANNING" => TrackStatus.planToRead,
_ => TrackStatus.rereading,
"PLANNING" => isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
_ => isManga ? TrackStatus.reReading : TrackStatus.reWatching,
};
}
TrackStatus _getALTrackStatusAnime(String status) {
return switch (status) {
"CURRENT" => TrackStatus.watching,
"COMPLETED" => TrackStatus.completed,
"PAUSED" => TrackStatus.onHold,
"DROPPED" => TrackStatus.dropped,
"PLANNING" => TrackStatus.planToWatch,
_ => TrackStatus.reWatching,
};
}
List<TrackStatus> aniListStatusListManga = [
TrackStatus.reading,
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,
TrackStatus.dropped,
TrackStatus.planToRead,
TrackStatus.rereading,
];
List<TrackStatus> aniListStatusListAnime = [
TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,
TrackStatus.dropped,
TrackStatus.planToWatch,
TrackStatus.reWatching,
isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
isManga ? TrackStatus.reReading : TrackStatus.reWatching,
];
String? toAniListStatusManga(TrackStatus status) {
String? toAniListStatus(TrackStatus status, bool isManga) {
return switch (status) {
TrackStatus.reading => "CURRENT",
TrackStatus.reading when isManga => "CURRENT",
TrackStatus.watching when !isManga => "CURRENT",
TrackStatus.completed => "COMPLETED",
TrackStatus.onHold => "PAUSED",
TrackStatus.dropped => "DROPPED",
TrackStatus.planToRead => "PLANNING",
_ => "REPEATING",
};
}
String? toAniListStatusAnime(TrackStatus status) {
return switch (status) {
TrackStatus.watching => "CURRENT",
TrackStatus.completed => "COMPLETED",
TrackStatus.onHold => "PAUSED",
TrackStatus.dropped => "DROPPED",
TrackStatus.planToWatch => "PLANNING",
TrackStatus.planToRead when isManga => "PLANNING",
TrackStatus.planToWatch when !isManga => "PLANNING",
_ => "REPEATING",
};
}

View file

@ -19,9 +19,10 @@ class Kitsu extends _$Kitsu {
'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd';
final String _clientSecret =
'54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151';
final String _baseUrl = 'https://kitsu.io/api/edge/';
final String _loginUrl = 'https://kitsu.io/api/oauth/token';
final String _algoliaKeyUrl = 'https://kitsu.io/api/edge/algolia-keys/media/';
final String _baseUrl = 'https://kitsu.app/api/edge/';
final String _loginUrl = 'https://kitsu.app/api/oauth/token';
final String _algoliaKeyUrl =
'https://kitsu.app/api/edge/algolia-keys/media/';
final String _algoliaUrl =
'https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/query/';
final String _algoliaAppId = 'AWQO5J657S';
@ -31,7 +32,7 @@ class Kitsu extends _$Kitsu {
'${isManga ? 'chapter' : 'episode'}Count%22%2C%22posterImage%22%2C%22'
'startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D';
String _mediaUrl(String type, int id) => 'https://kitsu.io/$type/$id';
String _mediaUrl(String type, int id) => 'https://kitsu.app/$type/$id';
@override
void build({required int syncId, ItemType? itemType}) {}
@ -53,7 +54,11 @@ class Kitsu extends _$Kitsu {
final res =
jsonDecode(await response.stream.bytesToString())
as Map<String, dynamic>;
final aKOAuth = OAuth.fromJson(res);
final aKOAuth = OAuth.fromJson(res)
..expiresIn =
DateTime.now()
.add(Duration(seconds: res['expires_in']))
.millisecondsSinceEpoch;
final currentUser = await _getCurrentUser(aKOAuth.accessToken!);
ref
.read(tracksProvider(syncId: syncId).notifier)
@ -72,68 +77,54 @@ class Kitsu extends _$Kitsu {
}
}
Future<Track?> addLib(Track track, bool isManga) async {
final userId = _getUserId();
final accessToken = _getAccessToken();
var data = jsonEncode({
'data': {
'type': 'libraryEntries',
'attributes': {
'status': toKitsuStatus(track.status, isManga),
'progress': track.lastChapterRead,
},
'relationships': {
'user': {
'data': {'id': userId, 'type': 'users'},
},
'media': {
'data': {'id': track.mediaId, 'type': isManga ? 'manga' : 'anime'},
},
},
},
});
var response = await http.post(
Uri.parse('${_baseUrl}library-entries'),
headers: {
'Content-Type': 'application/vnd.api+json',
'Authorization': 'Bearer $accessToken',
},
body: data,
Future<Track> update(Track track, bool isManga) async {
final isNew = track.libraryId == null;
final String? userId = isNew ? _getUserId() : null;
final type = isManga ? 'manga' : 'anime';
final url = Uri.parse(
'${_baseUrl}library-entries${isNew ? "" : "/${track.libraryId}"}',
);
if (response.statusCode != 200) {
return await findLibItem(track, true);
}
var jsonData = jsonDecode(response.body) as Map<String, dynamic>;
track.libraryId = int.parse(jsonData['data']['id']);
return track;
}
Future<Track> updateLib(Track track, bool isManga) async {
final accessToken = _getAccessToken();
final data = jsonEncode({
final headers = {
"Content-Type": "application/vnd.api+json",
'Authorization': 'Bearer ${_getAccessToken()}',
};
final payload = jsonEncode({
"data": {
"type": "libraryEntries",
"id": track.libraryId,
if (!isNew) "id": track.libraryId,
"attributes": {
"status": toKitsuStatus(track.status, isManga),
"progress": track.lastChapterRead,
"ratingTwenty": _toKitsuScore(track.score!),
"startedAt": _convertDate(track.startedReadingDate!),
"finishedAt": _convertDate(track.finishedReadingDate!),
if (!isNew) "ratingTwenty": _toKitsuScore(track.score!),
if (!isNew) "startedAt": _convertDate(track.startedReadingDate!),
if (!isNew) "finishedAt": _convertDate(track.finishedReadingDate!),
},
if (isNew)
"relationships": {
'user': {
'data': {'id': userId, 'type': 'users'},
},
'media': {
'data': {'id': track.mediaId, 'type': type},
},
},
},
});
await http.patch(
Uri.parse('${_baseUrl}library-entries/${track.libraryId}'),
headers: {
"Content-Type": "application/vnd.api+json",
'Authorization': 'Bearer $accessToken',
},
body: data,
);
if (isNew) {
final response = await http.post(url, headers: headers, body: payload);
if (response.statusCode != 200) {
final found = await findLibItem(track, isManga);
if (found == null) {
throw Exception('Could not add $type entry for ${track.mediaId}');
}
track.libraryId = found.libraryId;
return track;
}
final jsonData = jsonDecode(response.body) as Map<String, dynamic>;
track.libraryId = int.parse(jsonData['data']['id']);
} else {
await http.patch(url, headers: headers, body: payload);
}
return track;
}
@ -141,7 +132,7 @@ class Kitsu extends _$Kitsu {
final accessToken = _getAccessToken();
final url = Uri.parse(_algoliaKeyUrl);
final algoliaKeyResponse = await makeGetRequest(url, accessToken);
final algoliaKeyResponse = await _makeGetRequest(url, accessToken);
final key = json.decode(algoliaKeyResponse.body)["media"]["key"];
final response = await http.post(
Uri.parse(_algoliaUrl),
@ -190,7 +181,7 @@ class Kitsu extends _$Kitsu {
final url = Uri.parse(
'${_baseUrl}library-entries?filter[${type}_id]=${track.libraryId}&filter[user_id]=$userId&include=$type',
);
Response response = await makeGetRequest(url, accessToken);
Response response = await _makeGetRequest(url, accessToken);
if (response.statusCode == 200) {
final parsed = parseTrackResponse(response, track, type);
if (parsed != null) return parsed;
@ -198,15 +189,14 @@ class Kitsu extends _$Kitsu {
return await getItem(track, type);
}
Future<Response> makeGetRequest(Uri url, String accessToken) async {
final response = await http.get(
Future<Response> _makeGetRequest(Uri url, String accessToken) async {
return await http.get(
url,
headers: {
"Content-Type": "application/json",
'Authorization': 'Bearer $accessToken',
},
);
return response;
}
Future<Track?> getItem(Track track, String type) async {
@ -214,7 +204,7 @@ class Kitsu extends _$Kitsu {
final url = Uri.parse(
'${_baseUrl}library-entries?filter[id]=${track.mediaId}&include=$type',
);
Response response = await makeGetRequest(url, accessToken);
Response response = await _makeGetRequest(url, accessToken);
if (response.statusCode == 200) {
return parseTrackResponse(response, track, type);
}
@ -233,23 +223,23 @@ class Kitsu extends _$Kitsu {
final included = jsonResponse['included'][0]["attributes"];
final id = int.parse(obj["id"]);
final totalChapter = type == 'manga' ? "chapterCount" : "episodeCount";
track.mediaId = id;
track.libraryId = id;
track.syncId = syncId;
track.trackingUrl = _mediaUrl(type, id);
track.totalChapter = included[totalChapter] ?? 0;
track.status = getKitsuTrackStatus(attributes["status"], type);
track.score = ((attributes["ratingTwenty"] ?? 0) / 2).toInt();
track.title = included["canonicalTitle"];
track.lastChapterRead = attributes["progress"];
track.startedReadingDate = _parseDate(attributes["startedAt"]);
track.finishedReadingDate = _parseDate(attributes["finishedAt"]);
return track;
return track
..mediaId = id
..libraryId = id
..syncId = syncId
..trackingUrl = _mediaUrl(type, id)
..totalChapter = included[totalChapter] ?? 0
..status = getKitsuTrackStatus(attributes["status"], type)
..score = ((attributes["ratingTwenty"] ?? 0) / 2).toInt()
..title = included["canonicalTitle"]
..lastChapterRead = attributes["progress"]
..startedReadingDate = _parseDate(attributes["startedAt"])
..finishedReadingDate = _parseDate(attributes["finishedAt"]);
}
Future<(String, String)> _getCurrentUser(String accessToken) async {
final url = Uri.parse('${_baseUrl}users?filter[self]=true');
Response response = await makeGetRequest(url, accessToken);
Response response = await _makeGetRequest(url, accessToken);
final data = json.decode(response.body)['data'][0];
return (
data['id'].toString(),
@ -286,7 +276,7 @@ class Kitsu extends _$Kitsu {
};
}
List<TrackStatus> kitsuStatusList(bool isManga) => [
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,

View file

@ -20,8 +20,9 @@ class MyAnimeList extends _$MyAnimeList {
String baseOAuthUrl = 'https://myanimelist.net/v1/oauth2';
String baseApiUrl = 'https://api.myanimelist.net/v2';
String codeVerifier = "";
static final isDesktop = (Platform.isWindows || Platform.isLinux);
String clientId =
(Platform.isWindows || Platform.isLinux)
isDesktop
? '39e9be346b4e7dbcc59a98357e2f8472'
: '0c9100ccd443ddb441a319a881180f7f';
@ -30,9 +31,7 @@ class MyAnimeList extends _$MyAnimeList {
Future<bool?> login() async {
final callbackUrlScheme =
(Platform.isWindows || Platform.isLinux)
? 'http://localhost:43824'
: 'mangayomi';
isDesktop ? 'http://localhost:43824' : 'mangayomi';
final loginUrl = _authUrl();
try {
@ -66,7 +65,7 @@ class MyAnimeList extends _$MyAnimeList {
}
}
Future<String> _getAccesToken() async {
Future<String> _getAccessToken() async {
final track = ref.watch(tracksProvider(syncId: syncId));
final mALOAuth = OAuth.fromJson(
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
@ -101,86 +100,54 @@ class MyAnimeList extends _$MyAnimeList {
return mALOAuth.accessToken!;
}
Future<List<TrackSearch>> search(String query) async {
final accessToken = await _getAccesToken();
Future<List<TrackSearch>> search(String query, isManga) async {
final accessToken = await _getAccessToken();
final url = Uri.parse(
itemType == ItemType.manga ? '$baseApiUrl/manga' : '$baseApiUrl/anime',
'$baseApiUrl/${isManga ? "manga" : "anime"}',
).replace(queryParameters: {'q': query.trim(), 'nsfw': 'true'});
final result = await http.get(
url,
headers: {'Authorization': 'Bearer $accessToken'},
);
final result = await _makeGetRequest(url, accessToken);
final res = jsonDecode(result.body) as Map<String, dynamic>;
List<int> mangaIds =
res['data'] == null
? []
: (res['data'] as List).map((e) => e['node']["id"] as int).toList();
List<TrackSearch> trackSearchResult = [];
for (var mangaId in mangaIds) {
final trackSearch =
itemType == ItemType.manga
? await getMangaDetails(mangaId, accessToken)
: await getAnimeDetails(mangaId, accessToken);
trackSearchResult.add(trackSearch);
}
final trackSearchResult = await Future.wait(
mangaIds.map((id) => getDetails(id, accessToken, isManga)),
);
return trackSearchResult
.where((element) => !element.publishingType!.contains("novel"))
.toList();
}
Future<TrackSearch> getMangaDetails(int id, String accessToken) async {
final url = Uri.parse('$baseApiUrl/manga/$id').replace(
Future<TrackSearch> getDetails(
int id,
String accessToken,
bool isManga,
) async {
final item = isManga ? "manga" : "anime";
final contentUnit = isManga ? "num_chapters" : "num_episodes";
final url = Uri.parse('$baseApiUrl/$item/$id').replace(
queryParameters: {
'fields':
'id,title,synopsis,num_chapters,main_picture,status,media_type,start_date',
'id,title,synopsis,$contentUnit,main_picture,status,media_type,start_date',
},
);
final result = await http.get(
url,
headers: {'Authorization': 'Bearer $accessToken'},
);
final result = await _makeGetRequest(url, accessToken);
final res = jsonDecode(result.body) as Map<String, dynamic>;
return TrackSearch(
mediaId: res["id"],
summary: res["synopsis"] ?? "",
totalChapter: res["num_chapters"],
totalChapter: res[contentUnit],
coverUrl: res["main_picture"]["large"] ?? "",
title: res["title"],
startDate: res["start_date"] ?? "",
publishingType: res["media_type"].toString().replaceAll("_", " "),
publishingStatus: res["status"].toString().replaceAll("_", " "),
trackingUrl: "https://myanimelist.net/manga/${res["id"]}",
);
}
Future<TrackSearch> getAnimeDetails(int id, String accessToken) async {
final url = Uri.parse('$baseApiUrl/anime/$id').replace(
queryParameters: {
'fields':
'id,title,synopsis,num_episodes,main_picture,status,media_type,start_date',
},
);
final result = await http.get(
url,
headers: {'Authorization': 'Bearer $accessToken'},
);
final res = jsonDecode(result.body) as Map<String, dynamic>;
return TrackSearch(
mediaId: res["id"],
summary: res["synopsis"] ?? "",
totalChapter: res["num_episodes"],
coverUrl: res["main_picture"]["large"] ?? "",
title: res["title"],
startDate: res["start_date"] ?? "",
publishingType: res["media_type"].toString().replaceAll("_", " "),
publishingStatus: res["status"].toString().replaceAll("_", " "),
trackingUrl: "https://myanimelist.net/anime/${res["id"]}",
trackingUrl: "https://myanimelist.net/$item/${res["id"]}",
);
}
@ -207,61 +174,38 @@ class MyAnimeList extends _$MyAnimeList {
return '$baseOAuthUrl/authorize?client_id=$clientId&code_challenge=$codeVerifier&response_type=code';
}
TrackStatus _getMALTrackStatusManga(String status) {
TrackStatus _getMALTrackStatus(String status, bool isManga) {
return switch (status) {
"reading" => TrackStatus.reading,
"reading" when isManga => TrackStatus.reading,
"watching" when !isManga => TrackStatus.watching,
"completed" => TrackStatus.completed,
"on_hold" => TrackStatus.onHold,
"dropped" => TrackStatus.dropped,
"plan_to_read" => TrackStatus.planToRead,
_ => TrackStatus.rereading,
"plan_to_read" when isManga => TrackStatus.planToRead,
"plan_to_watch" when !isManga => TrackStatus.planToWatch,
_ => isManga ? TrackStatus.reReading : TrackStatus.planToWatch,
};
}
TrackStatus _getMALTrackStatusAnime(String status) {
return switch (status) {
"watching" => TrackStatus.watching,
"completed" => TrackStatus.completed,
"on_hold" => TrackStatus.onHold,
"dropped" => TrackStatus.dropped,
_ => TrackStatus.planToWatch,
};
}
List<TrackStatus> myAnimeListStatusListManga = [
TrackStatus.reading,
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,
TrackStatus.dropped,
TrackStatus.planToRead,
TrackStatus.rereading,
];
List<TrackStatus> myAnimeListStatusListAnime = [
TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,
TrackStatus.dropped,
TrackStatus.planToWatch,
isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
if (isManga) TrackStatus.reReading,
];
String? toMyAnimeListStatusManga(TrackStatus status) {
String? toMyAnimeListStatus(TrackStatus status, bool isManga) {
return switch (status) {
TrackStatus.reading => "reading",
TrackStatus.reading when isManga => "reading",
TrackStatus.watching when !isManga => "watching",
TrackStatus.completed => "completed",
TrackStatus.onHold => "on_hold",
TrackStatus.dropped => "dropped",
TrackStatus.planToRead => "plan_to_read",
_ => "reading",
};
}
String? toMyAnimeListStatusAnime(TrackStatus status) {
return switch (status) {
TrackStatus.watching => "watching",
TrackStatus.completed => "completed",
TrackStatus.onHold => "on_hold",
TrackStatus.dropped => "dropped",
_ => "plan_to_watch",
TrackStatus.planToRead when isManga => "plan_to_read",
TrackStatus.planToWatch when !isManga => "plan_to_watch",
_ => isManga ? "reading" : "plan_to_watch",
};
}
@ -280,70 +224,43 @@ class MyAnimeList extends _$MyAnimeList {
}
Future<String> _getUserName(String accessToken) async {
final response = await http.get(
final response = await _makeGetRequest(
Uri.parse('$baseApiUrl/users/@me'),
headers: {'Authorization': 'Bearer $accessToken'},
accessToken,
);
return jsonDecode(response.body)['name'];
}
Future<Track> findManga(Track track) async {
final accessToken = await _getAccesToken();
final uri = Uri.parse(
itemType == ItemType.manga
? '$baseApiUrl/manga/${track.mediaId}'
: '$baseApiUrl/anime/${track.mediaId}',
).replace(
Future<Track> findLibItem(Track track, bool isManga) async {
final type = isManga ? "manga" : "anime";
final contentUnit = isManga ? 'num_chapters' : 'num_episodes';
final accessToken = await _getAccessToken();
final uri = Uri.parse('$baseApiUrl/$type/${track.mediaId}').replace(
queryParameters: {
'fields':
itemType == ItemType.manga
? 'num_chapters,my_list_status{start_date,finish_date}'
: 'num_episodes,my_list_status{start_date,finish_date}',
'fields': '$contentUnit,my_list_status{start_date,finish_date}',
},
);
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
);
final response = await _makeGetRequest(uri, accessToken);
final mJson = jsonDecode(response.body);
track.totalChapter =
itemType == ItemType.manga
? mJson['num_chapters'] ?? 0
: mJson['num_episodes'] ?? 0;
track.totalChapter = mJson[contentUnit] ?? 0;
if (mJson['my_list_status'] != null) {
track =
itemType == ItemType.manga
? _parseMangaItem(mJson["my_list_status"], track)
: _parseAnimeItem(mJson["my_list_status"], track);
track = _parseItem(mJson["my_list_status"], track, isManga);
} else {
track =
itemType == ItemType.manga
? await updateManga(track)
: await updateAnime(track);
track = await update(track, isManga);
}
return track;
}
Track _parseMangaItem(Map<String, dynamic> mJson, Track track) {
bool isRereading = mJson["is_rereading"] ?? false;
Track _parseItem(Map<String, dynamic> mJson, Track track, bool isManga) {
bool isRepeating =
mJson[isManga ? "is_rereading" : "is_rewatching"] ?? false;
track.status =
isRereading
? TrackStatus.rereading
: _getMALTrackStatusManga(mJson["status"]);
track.lastChapterRead = int.parse(mJson["num_chapters_read"].toString());
track.score = int.parse(mJson["score"].toString());
track.startedReadingDate = _parseDate(mJson["start_date"]);
track.finishedReadingDate = _parseDate(mJson["finish_date"]);
return track;
}
Track _parseAnimeItem(Map<String, dynamic> mJson, Track track) {
bool isReWatching = mJson["is_rewatching"] ?? false;
track.status =
isReWatching
? TrackStatus.reWatching
: _getMALTrackStatusAnime(mJson["status"]);
track.lastChapterRead = int.parse(mJson["num_episodes_watched"].toString());
isRepeating
? (isManga ? TrackStatus.reReading : TrackStatus.reWatching)
: _getMALTrackStatus(mJson["status"], isManga);
track.lastChapterRead = int.parse(
mJson[isManga ? "num_chapters_read" : "num_episodes_watched"].toString(),
);
track.score = int.parse(mJson["score"].toString());
track.startedReadingDate = _parseDate(mJson["start_date"]);
track.finishedReadingDate = _parseDate(mJson["finish_date"]);
@ -357,14 +274,20 @@ class MyAnimeList extends _$MyAnimeList {
return date.millisecondsSinceEpoch;
}
Future<Track> updateAnime(Track track) async {
final accessToken = await _getAccesToken();
Future<Track> update(Track track, bool isManga) async {
final accessToken = await _getAccessToken();
final formBody = {
'status':
(toMyAnimeListStatusAnime(track.status) ?? 'watching').toString(),
'is_rewatching': (track.status == TrackStatus.reWatching).toString(),
(toMyAnimeListStatus(track.status, isManga) ??
(isManga ? 'reading' : 'watching'))
.toString(),
isManga ? 'is_rereading' : 'is_rewatching':
(track.status ==
(isManga ? TrackStatus.reReading : TrackStatus.reWatching))
.toString(),
'score': track.score.toString(),
'num_watched_episodes': track.lastChapterRead.toString(),
isManga ? 'num_chapters_read' : 'num_watched_episodes':
track.lastChapterRead.toString(),
if (track.startedReadingDate != null)
'start_date': _convertToIsoDate(track.startedReadingDate),
if (track.finishedReadingDate != null)
@ -372,36 +295,22 @@ class MyAnimeList extends _$MyAnimeList {
};
final request = Request(
'PUT',
Uri.parse('$baseApiUrl/anime/${track.mediaId}/my_list_status'),
Uri.parse(
'$baseApiUrl/${isManga ? "manga" : "anime"}'
'/${track.mediaId}/my_list_status',
),
);
request.bodyFields = formBody;
request.headers.addAll({'Authorization': 'Bearer $accessToken'});
final response = await Client().send(request);
final mJson = jsonDecode(await response.stream.bytesToString());
return _parseAnimeItem(mJson, track);
return _parseItem(mJson, track, isManga);
}
Future<Track> updateManga(Track track) async {
final accessToken = await _getAccesToken();
final formBody = {
'status':
(toMyAnimeListStatusManga(track.status) ?? 'reading').toString(),
'is_rereading': (track.status == TrackStatus.rereading).toString(),
'score': track.score.toString(),
'num_chapters_read': track.lastChapterRead.toString(),
if (track.startedReadingDate != null)
'start_date': _convertToIsoDate(track.startedReadingDate),
if (track.finishedReadingDate != null)
'finish_date': _convertToIsoDate(track.finishedReadingDate),
};
final request = Request(
'PUT',
Uri.parse('$baseApiUrl/manga/${track.mediaId}/my_list_status'),
Future<Response> _makeGetRequest(Uri url, String accessToken) async {
return await http.get(
url,
headers: {'Authorization': 'Bearer $accessToken'},
);
request.bodyFields = formBody;
request.headers.addAll({'Authorization': 'Bearer $accessToken'});
final response = await Client().send(request);
final mJson = jsonDecode(await response.stream.bytesToString());
return _parseMangaItem(mJson, track);
}
}

View file

@ -40,7 +40,7 @@ String getTrackStatus(TrackStatus status, BuildContext context) {
TrackStatus.onHold => l10n.on_hold,
TrackStatus.dropped => l10n.dropped,
TrackStatus.planToRead => l10n.plan_to_read,
TrackStatus.rereading => l10n.re_reading,
TrackStatus.reReading => l10n.re_reading,
};
}
@ -49,7 +49,7 @@ TrackStatus toTrackStatus(TrackStatus status, ItemType itemType, int syncId) {
? switch (status) {
TrackStatus.reading => TrackStatus.watching,
TrackStatus.planToRead => TrackStatus.planToWatch,
TrackStatus.rereading => TrackStatus.reWatching,
TrackStatus.reReading => TrackStatus.reWatching,
_ => status,
}
: status;