mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-04-20 23:22:07 +00:00
added Anibrain recommendations
This commit is contained in:
parent
22087752f1
commit
c7d1d75045
11 changed files with 927 additions and 18 deletions
|
|
@ -430,6 +430,12 @@
|
|||
"include_sensitive_settings": "Include sensitive settings (e.g., tracker login tokens)",
|
||||
"create": "Create",
|
||||
"downloads_are_limited_to_wifi": "Downloads are limited to Wi-Fi only",
|
||||
"recommendations_similar": "similar",
|
||||
"recommendations_weights": "Recommendation Weights",
|
||||
"recommendations_weights_genre": "Genre Similarity",
|
||||
"recommendations_weights_setting": "Setting Similarity",
|
||||
"recommendations_weights_synopsis": "Story Similarity",
|
||||
"recommendations_weights_theme": "Theme Similarity",
|
||||
"manga_extensions_repo": "Manga extensions repo",
|
||||
"anime_extensions_repo": "Anime extensions repo",
|
||||
"novel_extensions_repo": "Novel extensions repo",
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ class Settings {
|
|||
|
||||
bool? rpcShowCoverImage;
|
||||
|
||||
late AlgorithmWeights? algorithmWeights;
|
||||
|
||||
Settings({
|
||||
this.id = 227,
|
||||
this.updatedAt = 0,
|
||||
|
|
@ -373,6 +375,7 @@ class Settings {
|
|||
this.rpcShowReadingWatchingProgress = true,
|
||||
this.rpcShowTitle = true,
|
||||
this.rpcShowCoverImage = true,
|
||||
this.algorithmWeights,
|
||||
});
|
||||
|
||||
Settings.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -594,6 +597,9 @@ class Settings {
|
|||
rpcShowReadingWatchingProgress = json['rpcShowReadingWatchingProgress'];
|
||||
rpcShowTitle = json['rpcShowTitle'];
|
||||
rpcShowCoverImage = json['rpcShowCoverImage'];
|
||||
algorithmWeights = json['algorithmWeights'] != null
|
||||
? AlgorithmWeights.fromJson(json['algorithmWeights'])
|
||||
: null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
|
@ -732,6 +738,8 @@ class Settings {
|
|||
'rpcShowReadingWatchingProgress': rpcShowReadingWatchingProgress,
|
||||
'rpcShowTitle': rpcShowTitle,
|
||||
'rpcShowCoverImage': rpcShowCoverImage,
|
||||
if (algorithmWeights != null)
|
||||
'algorithmWeights': algorithmWeights!.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1088,6 +1096,35 @@ class PlayerSubtitleSettings {
|
|||
};
|
||||
}
|
||||
|
||||
@embedded
|
||||
class AlgorithmWeights {
|
||||
int? genre;
|
||||
int? setting;
|
||||
int? synopsis;
|
||||
int? theme;
|
||||
|
||||
AlgorithmWeights({
|
||||
this.genre = 30,
|
||||
this.setting = 15,
|
||||
this.synopsis = 40,
|
||||
this.theme = 20,
|
||||
});
|
||||
|
||||
AlgorithmWeights.fromJson(Map<String, dynamic> json) {
|
||||
genre = json['genre'];
|
||||
setting = json['setting'];
|
||||
synopsis = json['synopsis'];
|
||||
theme = json['theme'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'genre': genre,
|
||||
'setting': setting,
|
||||
'synopsis': synopsis,
|
||||
'theme': theme,
|
||||
};
|
||||
}
|
||||
|
||||
enum ColorFilterBlendMode {
|
||||
none,
|
||||
multiply,
|
||||
|
|
|
|||
|
|
@ -124,12 +124,14 @@ class _BrowseScreenState extends ConsumerState<BrowseScreen>
|
|||
} else {
|
||||
context.push(
|
||||
'/globalSearch',
|
||||
extra:
|
||||
switch (_tabList[_tabBarController.index]) {
|
||||
"manga" => ItemType.manga,
|
||||
"anime" => ItemType.anime,
|
||||
_ => ItemType.novel,
|
||||
},
|
||||
extra: (
|
||||
null,
|
||||
switch (_tabList[_tabBarController.index]) {
|
||||
"manga" => ItemType.manga,
|
||||
"anime" => ItemType.anime,
|
||||
_ => ItemType.novel,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,15 +24,16 @@ import 'package:mangayomi/modules/widgets/manga_image_card_widget.dart';
|
|||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class GlobalSearchScreen extends ConsumerStatefulWidget {
|
||||
final String? search;
|
||||
final ItemType itemType;
|
||||
const GlobalSearchScreen({required this.itemType, super.key});
|
||||
const GlobalSearchScreen({this.search, required this.itemType, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<GlobalSearchScreen> createState() => _GlobalSearchScreenState();
|
||||
}
|
||||
|
||||
class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
|
||||
String query = "";
|
||||
String _query = "";
|
||||
final _textEditingController = TextEditingController();
|
||||
late final List<Source> sourceList =
|
||||
ref.read(onlyIncludePinnedSourceStateProvider)
|
||||
|
|
@ -50,8 +51,17 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
|
|||
.and()
|
||||
.itemTypeEqualTo(widget.itemType)
|
||||
.findAllSync();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textEditingController.text = widget.search ?? "";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = _query.isNotEmpty ? _query : widget.search ?? "";
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Container(),
|
||||
|
|
@ -62,27 +72,27 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
|
|||
Navigator.pop(context);
|
||||
},
|
||||
onFieldSubmitted: (value) async {
|
||||
if (!(query == _textEditingController.text)) {
|
||||
if (!(_query == _textEditingController.text)) {
|
||||
setState(() {
|
||||
query = "";
|
||||
_query = "";
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
setState(() {
|
||||
query = value;
|
||||
_query = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuffixPressed: () {
|
||||
_textEditingController.clear();
|
||||
setState(() {
|
||||
query = "";
|
||||
_query = "";
|
||||
});
|
||||
},
|
||||
controller: _textEditingController,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: query.isNotEmpty
|
||||
body: _query.isNotEmpty || widget.search != null
|
||||
? SuperListView.builder(
|
||||
itemCount: sourceList.length,
|
||||
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
|
||||
|
|
@ -90,7 +100,11 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
|
|||
final source = sourceList[index];
|
||||
return SizedBox(
|
||||
height: 260,
|
||||
child: SourceSearchScreen(query: query, source: source),
|
||||
child: SourceSearchScreen(
|
||||
key: ValueKey(query),
|
||||
query: query,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.d
|
|||
import 'package:mangayomi/modules/manga/detail/widgets/tracker_search_widget.dart';
|
||||
import 'package:mangayomi/modules/manga/detail/widgets/tracker_widget.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
|
||||
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
|
||||
import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart';
|
||||
import 'package:mangayomi/modules/more/settings/track/widgets/track_listile.dart';
|
||||
import 'package:mangayomi/modules/widgets/category_selection_dialog.dart';
|
||||
|
|
@ -1694,6 +1695,37 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
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: () {
|
||||
final algorithmWeights = ref.read(
|
||||
algorithmWeightsStateProvider,
|
||||
);
|
||||
context.push(
|
||||
"/recommendations",
|
||||
extra: (
|
||||
widget.manga!.name,
|
||||
widget.manga!.itemType,
|
||||
algorithmWeights,
|
||||
),
|
||||
);
|
||||
},
|
||||
label: Text(l10n.recommendations),
|
||||
icon: Icon(Icons.arrow_right_alt_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!context.isTablet)
|
||||
Column(
|
||||
children: [
|
||||
|
|
|
|||
383
lib/modules/manga/detail/widgets/recommendation_screen.dart
Normal file
383
lib/modules/manga/detail/widgets/recommendation_screen.dart
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mangayomi/models/manga.dart';
|
||||
import 'package:mangayomi/models/settings.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/recommendation.dart';
|
||||
import 'package:mangayomi/utils/constant.dart';
|
||||
import 'package:mangayomi/utils/extensions/build_context_extensions.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 RecommendationScreen extends StatefulWidget {
|
||||
final String name;
|
||||
final ItemType itemType;
|
||||
final AlgorithmWeights algorithmWeights;
|
||||
|
||||
const RecommendationScreen({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.itemType,
|
||||
required this.algorithmWeights,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecommendationScreen> createState() => _RecommendationScreenState();
|
||||
}
|
||||
|
||||
class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||
String _errorMessage = "";
|
||||
bool _isLoading = true;
|
||||
List<RecommendationResult>? data;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
_errorMessage = "";
|
||||
data = await getRecommendations(
|
||||
widget.name,
|
||||
widget.itemType,
|
||||
widget.algorithmWeights,
|
||||
);
|
||||
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.recommendations)),
|
||||
body: Padding(
|
||||
padding: EdgeInsetsGeometry.all(5),
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return Center(child: Text(_errorMessage));
|
||||
}
|
||||
if (data != null && data!.isNotEmpty) {
|
||||
return SuperListView.builder(
|
||||
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
|
||||
itemCount: data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recommendation = data![index];
|
||||
return ListTile(
|
||||
onTap: () => context.push(
|
||||
'/globalSearch',
|
||||
extra: (
|
||||
recommendation.titleEnglish ??
|
||||
recommendation.titleRomaji ??
|
||||
recommendation.titleNative,
|
||||
widget.itemType,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (recommendation.imgURLs.isNotEmpty)
|
||||
_thumbnailPreview(
|
||||
context,
|
||||
recommendation.imgURLs.first,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
recommendation.description != null
|
||||
? Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(
|
||||
recommendation.titleEnglish ??
|
||||
recommendation.titleRomaji ??
|
||||
recommendation.titleNative ??
|
||||
"",
|
||||
context,
|
||||
),
|
||||
Text(
|
||||
recommendation.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
),
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Flexible(
|
||||
child: _buildTitle(
|
||||
recommendation.titleEnglish ??
|
||||
recommendation.titleRomaji ??
|
||||
recommendation.titleNative ??
|
||||
"",
|
||||
context,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
child: recommendation.genres.isEmpty
|
||||
? const SizedBox(height: 15)
|
||||
: context.isTablet
|
||||
? Wrap(
|
||||
children: [
|
||||
for (
|
||||
var i = 0;
|
||||
i < recommendation.genres.length;
|
||||
i++
|
||||
)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 5,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 30,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
backgroundColor: Colors
|
||||
.grey
|
||||
.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
5,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: null,
|
||||
child: Text(
|
||||
recommendation.genres[i],
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: context.isLight
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.start,
|
||||
children: [
|
||||
for (
|
||||
var i = 0;
|
||||
i <
|
||||
recommendation
|
||||
.genres
|
||||
.length;
|
||||
i++
|
||||
)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 5,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 30,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
backgroundColor: Colors
|
||||
.grey
|
||||
.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
5,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () {},
|
||||
child: Text(
|
||||
recommendation
|
||||
.genres[i],
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: context.isLight
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Text(
|
||||
"${recommendation.score}% ${l10n.recommendations_similar}",
|
||||
style: TextStyle(
|
||||
background: Paint()
|
||||
..color = Theme.of(context)
|
||||
.scaffoldBackgroundColor
|
||||
.withValues(alpha: 0.75)
|
||||
..strokeWidth = 30.0
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..style = PaintingStyle.stroke,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:mangayomi/main.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'algorithm_weights_state_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class AlgorithmWeightsState extends _$AlgorithmWeightsState {
|
||||
@override
|
||||
AlgorithmWeights build() {
|
||||
return isar.settings.getSync(227)!.algorithmWeights ?? AlgorithmWeights();
|
||||
}
|
||||
|
||||
void set(AlgorithmWeights value) {
|
||||
final settings = isar.settings.getSync(227)!;
|
||||
state = value;
|
||||
isar.writeTxnSync(
|
||||
() => isar.settings.putSync(
|
||||
settings
|
||||
..algorithmWeights = state
|
||||
..updatedAt = DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setWeights({int? genre, int? setting, int? synopsis, int? theme}) {
|
||||
set(
|
||||
AlgorithmWeights(
|
||||
genre: genre ?? state.genre,
|
||||
setting: setting ?? state.setting,
|
||||
synopsis: synopsis ?? state.synopsis,
|
||||
theme: theme ?? state.theme,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'algorithm_weights_state_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$algorithmWeightsStateHash() =>
|
||||
r'5c20cb9b195a73161b485e082ad024b138c3da9c';
|
||||
|
||||
/// See also [AlgorithmWeightsState].
|
||||
@ProviderFor(AlgorithmWeightsState)
|
||||
final algorithmWeightsStateProvider = AutoDisposeNotifierProvider<
|
||||
AlgorithmWeightsState, AlgorithmWeights>.internal(
|
||||
AlgorithmWeightsState.new,
|
||||
name: r'algorithmWeightsStateProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$algorithmWeightsStateHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AlgorithmWeightsState = AutoDisposeNotifier<AlgorithmWeights>;
|
||||
// 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
|
||||
|
|
@ -1,14 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mangayomi/main.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
|
||||
import 'package:mangayomi/modules/more/settings/general/providers/general_state_provider.dart';
|
||||
import 'package:mangayomi/providers/l10n_providers.dart';
|
||||
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||
|
||||
class GeneralScreen extends ConsumerWidget {
|
||||
class GeneralScreen extends ConsumerStatefulWidget {
|
||||
const GeneralScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<GeneralScreen> createState() => _GeneralStateScreen();
|
||||
}
|
||||
|
||||
class _GeneralStateScreen extends ConsumerState<GeneralScreen> {
|
||||
int _genre = 0;
|
||||
int _setting = 0;
|
||||
int _synopsis = 0;
|
||||
int _theme = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final algorithmWeights = ref.read(algorithmWeightsStateProvider);
|
||||
_genre = algorithmWeights.genre!;
|
||||
_setting = algorithmWeights.setting!;
|
||||
_synopsis = algorithmWeights.synopsis!;
|
||||
_theme = algorithmWeights.theme!;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = l10nLocalizations(context);
|
||||
final enableDiscordRpc = ref.watch(enableDiscordRpcStateProvider);
|
||||
final hideDiscordRpcInIncognito = ref.watch(
|
||||
|
|
@ -24,6 +48,199 @@ class GeneralScreen extends ConsumerWidget {
|
|||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(20.0),
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 3.0, color: context.primaryColor),
|
||||
borderRadius: BorderRadius.all(Radius.circular(5.0)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.recommendations_weights,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
final defaultWeights = AlgorithmWeights();
|
||||
setState(() {
|
||||
_genre = defaultWeights.genre!;
|
||||
_setting = defaultWeights.setting!;
|
||||
_synopsis = defaultWeights.synopsis!;
|
||||
_theme = defaultWeights.theme!;
|
||||
});
|
||||
ref
|
||||
.read(algorithmWeightsStateProvider.notifier)
|
||||
.set(defaultWeights);
|
||||
},
|
||||
label: Text(context.l10n.reset),
|
||||
icon: const Icon(Icons.restore),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.recommendations_weights_genre),
|
||||
Text(
|
||||
(_genre / 100).toStringAsFixed(2),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 5.0,
|
||||
),
|
||||
),
|
||||
child: Slider.adaptive(
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: _genre.toDouble(),
|
||||
onChanged: (value) {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_genre = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) => ref
|
||||
.read(algorithmWeightsStateProvider.notifier)
|
||||
.setWeights(genre: _genre),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.recommendations_weights_setting),
|
||||
Text(
|
||||
(_setting / 100).toStringAsFixed(2),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 5.0,
|
||||
),
|
||||
),
|
||||
child: Slider.adaptive(
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: _setting.toDouble(),
|
||||
onChanged: (value) {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_setting = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) => ref
|
||||
.read(algorithmWeightsStateProvider.notifier)
|
||||
.setWeights(setting: _setting),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.recommendations_weights_synopsis),
|
||||
Text(
|
||||
(_synopsis / 100).toStringAsFixed(2),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 5.0,
|
||||
),
|
||||
),
|
||||
child: Slider.adaptive(
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: _synopsis.toDouble(),
|
||||
onChanged: (value) {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_synopsis = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) => ref
|
||||
.read(algorithmWeightsStateProvider.notifier)
|
||||
.setWeights(synopsis: _synopsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.recommendations_weights_theme),
|
||||
Text(
|
||||
(_theme / 100).toStringAsFixed(2),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 5.0,
|
||||
),
|
||||
),
|
||||
child: Slider.adaptive(
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: _theme.toDouble(),
|
||||
onChanged: (value) {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_theme = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) => ref
|
||||
.read(algorithmWeightsStateProvider.notifier)
|
||||
.setWeights(theme: _theme),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: enableDiscordRpc,
|
||||
title: Text(l10n.enable_discord_rpc),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:bot_toast/bot_toast.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mangayomi/models/manga.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/models/source.dart';
|
||||
import 'package:mangayomi/models/track_preference.dart';
|
||||
import 'package:mangayomi/models/track_search.dart';
|
||||
|
|
@ -12,6 +13,7 @@ import 'package:mangayomi/modules/browse/extension/extension_detail.dart';
|
|||
import 'package:mangayomi/modules/browse/extension/widgets/create_extension.dart';
|
||||
import 'package:mangayomi/modules/browse/sources/sources_filter_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/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';
|
||||
|
|
@ -168,9 +170,9 @@ class RouterNotifier extends ChangeNotifier {
|
|||
name: "extension_detail",
|
||||
builder: (source) => ExtensionDetail(source: source),
|
||||
),
|
||||
_genericRoute<ItemType>(
|
||||
_genericRoute<(String?, ItemType)>(
|
||||
name: "globalSearch",
|
||||
builder: (itemType) => GlobalSearchScreen(itemType: itemType),
|
||||
builder: (data) => GlobalSearchScreen(search: data.$1, itemType: data.$2),
|
||||
),
|
||||
_genericRoute(name: "about", child: const AboutScreen()),
|
||||
_genericRoute(name: "track", child: const TrackScreen()),
|
||||
|
|
@ -222,6 +224,14 @@ class RouterNotifier extends ChangeNotifier {
|
|||
name: "migrate/tracker",
|
||||
builder: (data) => MigrationScreen(manga: data.$1, trackSearch: data.$2),
|
||||
),
|
||||
_genericRoute<(String, ItemType, AlgorithmWeights)>(
|
||||
name: "recommendations",
|
||||
builder: (data) => RecommendationScreen(
|
||||
name: data.$1,
|
||||
itemType: data.$2,
|
||||
algorithmWeights: data.$3,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
GoRoute _genericRoute<T>({
|
||||
|
|
|
|||
146
lib/services/recommendation.dart
Normal file
146
lib/services/recommendation.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import 'dart:convert';
|
||||
import 'package:http_interceptor/http_interceptor.dart';
|
||||
import 'package:mangayomi/models/manga.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/services/http/m_client.dart';
|
||||
|
||||
Future<List<RecommendationResult>?> getRecommendations(
|
||||
String name,
|
||||
ItemType itemType,
|
||||
AlgorithmWeights algorithmWeights,
|
||||
) async {
|
||||
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
|
||||
try {
|
||||
final mediaId = await _getSuggest(http, name, itemType);
|
||||
return _getRecommendation(
|
||||
http,
|
||||
mediaId ?? name,
|
||||
itemType,
|
||||
algorithmWeights,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<RecommendationResult>?> _getRecommendation(
|
||||
InterceptedClient http,
|
||||
String mediaId,
|
||||
ItemType itemType,
|
||||
AlgorithmWeights algorithmWeights,
|
||||
) async {
|
||||
final url =
|
||||
"https://anibrain.ai/api/-/recommender/recs/${itemType != ItemType.anime ? "manga" : "anime"}";
|
||||
final res = await http.get(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
"priority": "u=1, i",
|
||||
"Referer": "https://anibrain.ai/",
|
||||
"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",
|
||||
},
|
||||
params: {
|
||||
"filterCountry": '[]',
|
||||
"filterFormat": '${_fillerType(itemType).map((e) => '"$e"').toList()}',
|
||||
"filterGenre": '{}',
|
||||
"filterTag": '{"max":{},"min":{}}',
|
||||
"filterRelease": '[1930,${DateTime.now().year}]',
|
||||
"filterScore": 0,
|
||||
"algorithmWeights": _algorithmWeights(algorithmWeights),
|
||||
"mediaId": mediaId,
|
||||
"mediaType": _mediaType(itemType),
|
||||
"adult": false,
|
||||
"page": 1,
|
||||
},
|
||||
);
|
||||
final data = json.decode(res.body) as Map<String, dynamic>;
|
||||
return (data["data"] as List?)
|
||||
?.map((e) => RecommendationResult.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<String?> _getSuggest(
|
||||
InterceptedClient http,
|
||||
String name,
|
||||
ItemType itemType,
|
||||
) async {
|
||||
final url =
|
||||
"https://anibrain.ai/api/-/recommender/autosuggest?searchValue=$name&mediaType=${_mediaType(itemType)}&adult=false";
|
||||
final res = await http.get(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
"priority": "u=1, i",
|
||||
"Referer": "https://anibrain.ai/recommender/manga",
|
||||
"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 = json.decode(res.body) as Map<String, dynamic>;
|
||||
final list = (data["data"] as List?)?.map((e) => e["id"]);
|
||||
return list?.firstOrNull;
|
||||
}
|
||||
|
||||
String _algorithmWeights(AlgorithmWeights algorithmWeights) {
|
||||
final genre = ((algorithmWeights.genre ?? 30) / 100).toStringAsFixed(2);
|
||||
final setting = ((algorithmWeights.setting ?? 15) / 100).toStringAsFixed(2);
|
||||
final synopsis = ((algorithmWeights.synopsis ?? 40) / 100).toStringAsFixed(2);
|
||||
final theme = ((algorithmWeights.theme ?? 20) / 100).toStringAsFixed(2);
|
||||
return '{"genre":$genre,"setting":$setting,"synopsis":$synopsis,"theme":$theme}';
|
||||
}
|
||||
|
||||
String _mediaType(ItemType itemType) {
|
||||
return switch (itemType) {
|
||||
ItemType.manga => "MANGA",
|
||||
ItemType.anime => "ANIME",
|
||||
ItemType.novel => "NOVEL",
|
||||
};
|
||||
}
|
||||
|
||||
List<String> _fillerType(ItemType itemType) {
|
||||
return switch (itemType) {
|
||||
ItemType.manga => ["MANGA"],
|
||||
ItemType.anime => ["movie", "ona", "tv"],
|
||||
ItemType.novel => ["NOVEL"],
|
||||
};
|
||||
}
|
||||
|
||||
class RecommendationResult {
|
||||
final String id;
|
||||
final int? anilistId;
|
||||
final int? myanimelistId;
|
||||
final int score;
|
||||
final String? titleRomaji;
|
||||
final String? titleEnglish;
|
||||
final String? titleNative;
|
||||
final String? description;
|
||||
final List<String> imgURLs;
|
||||
final List<String> genres;
|
||||
|
||||
RecommendationResult({
|
||||
required this.id,
|
||||
this.anilistId,
|
||||
this.myanimelistId,
|
||||
required this.score,
|
||||
this.titleRomaji,
|
||||
this.titleEnglish,
|
||||
this.titleNative,
|
||||
this.description,
|
||||
required this.imgURLs,
|
||||
required this.genres,
|
||||
});
|
||||
|
||||
factory RecommendationResult.fromJson(Map<String, dynamic> json) {
|
||||
return RecommendationResult(
|
||||
id: json["id"],
|
||||
anilistId: json["anilistId"],
|
||||
myanimelistId: json["myanimelistId"],
|
||||
score: json["score"],
|
||||
titleRomaji: json["titleRomaji"],
|
||||
titleEnglish: json["titleEnglish"],
|
||||
titleNative: json["titleNative"],
|
||||
description: json["description"],
|
||||
imgURLs: json["imgURLs"]?.cast<String>() ?? [],
|
||||
genres: json["genres"]?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue