added watch order

This commit is contained in:
Schnitzel5 2025-08-24 20:02:49 +02:00
parent 6101d96c96
commit b57015b682
20 changed files with 446 additions and 0 deletions

View file

@ -536,6 +536,7 @@
"clear_library": "Clear library",
"clear_library_desc": "Choose to clear all manga, anime and/or novel entries",
"clear_library_input": "Type 'manga', 'anime' and/or 'novel' (separated by a comma) to remove all related entries",
"watch_order": "Watch order",
"recommendations": "Recommendations",
"recommendations_similarity": "Similarity:"
}

View file

@ -3291,6 +3291,12 @@ abstract class AppLocalizations {
/// **'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries'**
String get clear_library_input;
/// No description provided for @watch_order.
///
/// In en, this message translates to:
/// **'Watch order'**
String get watch_order;
/// No description provided for @recommendations_similarity.
///
/// In en, this message translates to:

View file

@ -1703,6 +1703,9 @@ class AppLocalizationsAr extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1705,6 +1705,9 @@ class AppLocalizationsAs extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1716,6 +1716,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1704,6 +1704,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1721,6 +1721,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1722,6 +1722,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1706,6 +1706,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1710,6 +1710,9 @@ class AppLocalizationsId extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1719,6 +1719,9 @@ class AppLocalizationsIt extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1718,6 +1718,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1720,6 +1720,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1704,6 +1704,9 @@ class AppLocalizationsTh extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1710,6 +1710,9 @@ class AppLocalizationsTr extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1675,6 +1675,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get clear_library_input =>
'Type \'manga\', \'anime\' and/or \'novel\' (separated by a comma) to remove all related entries';
@override
String get watch_order => 'Watch order';
@override
String get recommendations_similarity => 'Similarity:';
}

View file

@ -1643,6 +1643,32 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
),
),
),
const SizedBox(height: 15),
if (widget.manga!.itemType == ItemType.anime)
SizedBox(
width: context.width(1),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: OutlinedButton.icon(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30.0),
),
),
),
onPressed: () {
context.push(
"/watchOrder",
extra: widget.manga!.name,
);
},
label: Text(l10n.watch_order),
icon: Icon(Icons.arrow_right_alt_outlined),
),
),
),
const SizedBox(height: 15),
if (!context.isTablet)
Column(
children: [

View file

@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/services/fetch_watch_order.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:marquee/marquee.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class WatchOrderScreen extends StatefulWidget {
final String name;
const WatchOrderScreen({super.key, required this.name});
@override
State<WatchOrderScreen> createState() => _WatchOrderScreenState();
}
class _WatchOrderScreenState extends State<WatchOrderScreen> {
String _errorMessage = "";
bool _isLoading = true;
List<WatchOrderSearch>? dataSearch;
List<WatchOrderItem>? data;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_errorMessage = "";
dataSearch = await searchWatchOrder(widget.name);
if (mounted) {
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.watch_order)),
body: Padding(
padding: EdgeInsetsGeometry.all(5),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Builder(
builder: (context) {
if (_errorMessage.isNotEmpty) {
return Center(child: Text(_errorMessage));
}
final isSearch = dataSearch != null && dataSearch!.isNotEmpty;
final isWatchOrder = data != null && data!.isNotEmpty;
if (isSearch || isWatchOrder) {
return SuperListView.builder(
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
itemCount: data?.length ?? dataSearch!.length,
itemBuilder: (context, index) {
final search = !isWatchOrder && isSearch
? dataSearch![index]
: null;
final watchOrder = isWatchOrder ? data![index] : null;
return ListTile(
onTap: () async {
if (isWatchOrder) {
context.push(
'/globalSearch',
extra: (
watchOrder!.nameEnglish ?? watchOrder.name,
ItemType.anime,
),
);
} else {
if (mounted) {
setState(() {
_isLoading = true;
_errorMessage = "";
});
data = await fetchWatchOrder(search!.id);
setState(() {
_isLoading = false;
});
}
}
},
title: Row(
children: [
_thumbnailPreview(
context,
watchOrder?.image ?? search!.image,
),
const SizedBox(width: 15),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(
watchOrder?.name ?? search!.name,
context,
),
if (watchOrder?.nameEnglish != null &&
watchOrder?.nameEnglish !=
watchOrder?.text)
Text(
watchOrder!.nameEnglish!,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.clip,
),
Text(
watchOrder?.text ??
"${search!.type} - ${search.year}",
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.clip,
),
],
),
),
],
),
);
},
);
}
return Center(child: Text(l10n.no_result));
},
),
),
);
}
Widget _buildTitle(String text, BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Make sure that (constraints.maxWidth - (35 + 5)) is strictly positive.
final double availableWidth = constraints.maxWidth - (35 + 5);
final textPainter =
TextPainter(
text: TextSpan(text: text, style: const TextStyle(fontSize: 13)),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(
maxWidth: availableWidth > 0 ? availableWidth : 1.0,
); // - Download icon size (download_page_widget.dart, Widget Build SizedBox width: 35)
final isOverflowing = textPainter.didExceedMaxLines;
if (isOverflowing) {
return SizedBox(
height: 20,
child: Marquee(
text: text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
blankSpace: 40.0,
velocity: 30.0,
pauseAfterRound: const Duration(seconds: 1),
startPadding: 10.0,
),
);
} else {
return Text(
text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
);
}
},
);
}
Widget _thumbnailPreview(BuildContext context, String? imageUrl) {
final imageProvider = CustomExtendedNetworkImageProvider(
toImgUrl(imageUrl ?? ""),
);
return Padding(
padding: const EdgeInsets.all(3),
child: GestureDetector(
onTap: () {
_openImage(context, imageProvider);
},
child: SizedBox(
width: 100,
height: 150,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(5)),
image: DecorationImage(image: imageProvider, fit: BoxFit.cover),
),
),
),
),
);
}
void _openImage(BuildContext context, ImageProvider imageProvider) {
showDialog(
context: context,
builder: (context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
itemCount: 1,
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider,
minScale: PhotoViewComputedScale.contained,
maxScale: 2.0,
);
},
loadingBuilder: (context, event) {
return const ProgressCenter();
},
),
),
],
),
);
},
);
}
}
class SuperPrecalculationPolicy extends ExtentPrecalculationPolicy {
@override
bool shouldPrecalculateExtents(ExtentPrecalculationContext context) {
return context.numberOfItems < 100;
}
}

View file

@ -15,6 +15,7 @@ import 'package:mangayomi/modules/browse/sources/sources_filter_screen.dart';
import 'package:mangayomi/modules/calendar/calendar_screen.dart';
import 'package:mangayomi/modules/manga/detail/widgets/migrate_screen.dart';
import 'package:mangayomi/modules/manga/detail/widgets/recommendation_screen.dart';
import 'package:mangayomi/modules/manga/detail/widgets/watch_order_screen.dart';
import 'package:mangayomi/modules/more/data_and_storage/create_backup.dart';
import 'package:mangayomi/modules/more/data_and_storage/data_and_storage.dart';
import 'package:mangayomi/modules/more/settings/appearance/custom_navigation_settings.dart';
@ -256,6 +257,10 @@ class RouterNotifier extends ChangeNotifier {
algorithmWeights: data.$3,
),
),
_genericRoute<String>(
name: "watchOrder",
builder: (data) => WatchOrderScreen(name: data),
),
];
GoRoute _genericRoute<T>({

View file

@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:math';
import 'package:html/dom.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:mangayomi/utils/extensions/dom_extensions.dart';
Future<List<WatchOrderSearch>> searchWatchOrder(String name) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final url = Uri.parse(
"https://chiaki.site/?/tools/autocomplete_series&term=$name",
);
final res = await http.get(
url,
headers: {
"priority": "u=1, i",
"Referer": "https://chiaki.site/?/tools/watch_order",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
},
);
final data = jsonDecode(res.body) as List?;
return data?.map((e) => WatchOrderSearch.fromJson(e)).toList() ?? [];
} catch (_) {
return [];
}
}
Future<List<WatchOrderItem>> fetchWatchOrder(String id) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final res = await http.get(
Uri.parse("https://chiaki.site/?/tools/watch_order/id/$id"),
headers: {
"priority": "u=1, i",
"Referer": "https://chiaki.site/?/tools/watch_order",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
},
);
final doc = Document.html(res.body);
return doc
.select("table > tbody > tr")
?.map((e) {
final img = e.selectFirst("td > div.wo_avatar_big")?.outerHtml;
final startIdx = img?.indexOf("url('") ?? -1;
final endIdx = img?.indexOf("')", max(0, startIdx)) ?? -1;
return WatchOrderItem(
id: e.attr("data-id") ?? id,
anilistId: e.attr("data-anilist-id") ?? "",
image: startIdx != -1 && endIdx != -1
? "https://chiaki.site/${img?.substring(startIdx + 5, endIdx)}"
: "",
name:
e.selectFirst("td > span.wo_title")?.text ??
"Unknown title",
nameEnglish: e.selectFirst("td > span.uk-text-small")?.text,
text:
e
.selectFirst("td > span.uk-text-muted.uk-text-small")
?.text ??
"",
);
})
.where((e) => e.name != "Unknown title")
.toList() ??
[];
} catch (_) {
return [];
}
}
class WatchOrderSearch {
final String id;
final String image;
final String type;
final String name;
final int year;
WatchOrderSearch({
required this.id,
required this.image,
required this.type,
required this.name,
required this.year,
});
factory WatchOrderSearch.fromJson(Map<String, dynamic> json) {
return WatchOrderSearch(
id: json["id"],
image: "https://chiaki.site/${json["image"]}",
type: json["type"],
name: json["value"],
year: json["year"],
);
}
}
class WatchOrderItem {
final String id;
final String anilistId;
final String image;
final String name;
final String? nameEnglish;
final String text;
WatchOrderItem({
required this.id,
required this.anilistId,
required this.image,
required this.name,
required this.nameEnglish,
required this.text,
});
}