Merge pull request #560 from Schnitzel5/feature/subtitle-search

added subtitles search
This commit is contained in:
Moustapha Kodjo Amadou 2025-08-26 20:04:10 +01:00 committed by GitHub
commit a061129b86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 617 additions and 3 deletions

View file

@ -52,7 +52,7 @@ android {
applicationId "com.kodjodevf.mangayomi"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion 21
minSdkVersion flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@ -84,4 +84,4 @@ flutter {
dependencies {
implementation(name: 'libmtorrentserver', ext: 'aar')
}
}

View file

@ -468,6 +468,7 @@
"genre_search_source": "Browse in source",
"source_not_added": "Source is not installed!",
"load_own_subtitles": "Load your own subtitles...",
"search_subtitles": "Search subtitles online...",
"extension_notes": "Notes: {notes}",
"unsupported_repo": "You've tried to add an unsupported repository. Please check the discord server for support!",
"end_of_chapter": "End of chapter",

View file

@ -2883,6 +2883,12 @@ abstract class AppLocalizations {
/// **'Load your own subtitles...'**
String get load_own_subtitles;
/// No description provided for @search_subtitles.
///
/// In en, this message translates to:
/// **'Search subtitles online...'**
String get search_subtitles;
/// No description provided for @extension_notes.
///
/// In en, this message translates to:

View file

@ -1480,6 +1480,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get load_own_subtitles => 'تحميل الترجمة الخاصة بك...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1482,6 +1482,9 @@ class AppLocalizationsAs extends AppLocalizations {
@override
String get load_own_subtitles => 'Load your own subtitles...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1491,6 +1491,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get load_own_subtitles => 'Deine eigene Untertiteln laden...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Hinweis: $notes';

View file

@ -1481,6 +1481,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get load_own_subtitles => 'Load your own subtitles...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1498,6 +1498,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get load_own_subtitles => 'Cargar tus propios subtítulos...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1498,6 +1498,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get load_own_subtitles => 'Charger vos propres sous-titres...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1483,6 +1483,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get load_own_subtitles => 'Load your own subtitles...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1487,6 +1487,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get load_own_subtitles => 'Muat subtitle Anda sendiri...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1496,6 +1496,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get load_own_subtitles => 'Carica i tuoi sottotitoli...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1495,6 +1495,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get load_own_subtitles => 'Carregar suas próprias legendas...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1497,6 +1497,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get load_own_subtitles => 'Загрузить свои собственные субтитры...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1481,6 +1481,9 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get load_own_subtitles => 'โหลดคำบรรยายของคุณเอง...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1487,6 +1487,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get load_own_subtitles => 'Kendi altyazılarınızı yükleyin...';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -1453,6 +1453,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get load_own_subtitles => '加载自定义字幕';
@override
String get search_subtitles => 'Search subtitles online...';
@override
String extension_notes(Object notes) {
return 'Notes: $notes';

View file

@ -37,6 +37,7 @@ import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/aniskip.dart';
import 'package:mangayomi/services/fetch_subtitles.dart';
import 'package:mangayomi/services/get_video_list.dart';
import 'package:mangayomi/services/torrent_server.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
@ -52,6 +53,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'widgets/search_subtitles.dart';
bool _isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows;
class AnimePlayerView extends riv.ConsumerStatefulWidget {
@ -1296,7 +1299,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo
),
],
),
const SizedBox(height: 15),
const SizedBox(height: 30),
...videoSubtitleLast.toSet().toList().map((sub) {
final title =
sub.title ??
@ -1322,6 +1325,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo
child: textWidget(title, selected),
);
}),
const SizedBox(height: 30),
GestureDetector(
onTap: () async {
try {
@ -1343,6 +1347,34 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo
},
child: textWidget(context.l10n.load_own_subtitles, false),
),
const SizedBox(height: 30),
GestureDetector(
onTap: () async {
try {
final subtitle =
await subtitlesSearchraggableMenu(
context,
chapter: widget.episode,
)
as ImdbSubtitle?;
if (subtitle != null && context.mounted) {
_player.setSubtitleTrack(
SubtitleTrack.uri(
subtitle.url!,
title: subtitle.language,
language: subtitle.language,
),
);
}
if (!context.mounted) return;
Navigator.pop(context);
} catch (_) {
botToast("Error");
Navigator.pop(context);
}
},
child: textWidget(context.l10n.search_subtitles, false),
),
],
),
);

View file

@ -0,0 +1,362 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
import 'package:mangayomi/modules/widgets/error_text.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:mangayomi/services/fetch_subtitles.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class SubtitlesWidgetSearch extends ConsumerStatefulWidget {
final Chapter chapter;
const SubtitlesWidgetSearch({required this.chapter, super.key});
@override
ConsumerState<SubtitlesWidgetSearch> createState() =>
_SubtitlesWidgetSearchState();
}
class _SubtitlesWidgetSearchState extends ConsumerState<SubtitlesWidgetSearch> {
late final _controller = TextEditingController(text: query);
List<ImdbTitle> titles = [];
List<ImdbEpisode>? episodes;
List<ImdbSubtitle>? subtitles;
late String query = widget.chapter.manga.value?.name?.trim() ?? "";
bool hide = false;
bool _isLoading = true;
String? _errorMsg;
@override
initState() {
super.initState();
_init();
}
_init() async {
await Future.delayed(const Duration(microseconds: 100));
try {
titles = await fetchImdbTitles(query);
} catch (e) {
_errorMsg = e.toString();
}
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
child: _isLoading
? SizedBox(
height: context.height(0.3),
child: Padding(
padding: const EdgeInsets.all(20),
child: const ProgressCenter(),
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: SizedBox(
height: context.height(0.8),
child: Column(
mainAxisAlignment: _errorMsg != null
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
if (subtitles != null || episodes != null)
IconButton(
onPressed: () {
setState(() {
if (subtitles != null) {
subtitles = null;
} else if (episodes != null) {
episodes = null;
}
});
},
icon: const Icon(Icons.keyboard_arrow_left),
),
if (_errorMsg != null)
Padding(
padding: const EdgeInsets.all(30),
child: ErrorText(_errorMsg!),
),
if (_errorMsg == null && !hide)
Flexible(child: _showImdbList(context)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextFormField(
onTap: () {
if (Platform.isAndroid || Platform.isIOS) {
setState(() {
hide = true;
});
}
},
controller: _controller,
keyboardType: TextInputType.text,
onChanged: (d) {
setState(() {
query = d;
});
},
onFieldSubmitted: (d) async {
setState(() {
_isLoading = true;
_errorMsg = null;
subtitles = null;
episodes = null;
});
try {
titles = await fetchImdbTitles(query);
} catch (e) {
_errorMsg = e.toString();
hide = false;
}
if (mounted) {
setState(() {
_isLoading = false;
hide = false;
});
}
},
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.transparent,
suffixIcon: query.isEmpty
? null
: IconButton(
onPressed: () {
_controller.clear();
},
icon: const Icon(Icons.clear),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.primaryColor),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: context.primaryColor),
),
border: OutlineInputBorder(
borderSide: BorderSide(color: context.primaryColor),
),
),
),
),
],
),
),
),
);
}
Widget _showImdbList(BuildContext context) {
return SuperListView.separated(
padding: const EdgeInsets.only(top: 20),
itemCount: subtitles?.length ?? episodes?.length ?? titles.length,
itemBuilder: (context, index) {
final isSubtitles = subtitles != null;
final isEpisodes = episodes != null;
return Padding(
padding: const EdgeInsets.only(top: 5),
child: InkWell(
onTap: () async {
if (isSubtitles) {
Navigator.pop(context, subtitles![index]);
} else {
setState(() {
_isLoading = true;
_errorMsg = null;
});
try {
if (isEpisodes) {
subtitles = await fetchImdbSubtitles(episodes![index].id);
} else {
episodes = await fetchImdbEpisodes(titles[index].id);
if (episodes == null || episodes!.isEmpty) {
subtitles = await fetchImdbSubtitles(titles[index].id);
}
}
} catch (e) {
_errorMsg = e.toString();
}
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
},
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isSubtitles && !isEpisodes)
Material(
borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Ink.image(
height: 120,
width: 80,
fit: BoxFit.cover,
image: titles[index].primaryImage != null
? CustomExtendedNetworkImageProvider(
titles[index].primaryImage!,
)
: const AssetImage('assets/transparent.png'),
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: context.width(0.6),
child: Text(
isSubtitles
? "${subtitles![index].name} (${subtitles![index].displayLang}) - ${subtitles![index].format?.toUpperCase() ?? "Unknown"} - ${subtitles![index].encoding ?? "Unknown"}"
: isEpisodes
? "S${episodes![index].season}E${episodes![index].episode}: ${episodes![index].title}"
: titles[index].primaryTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (!isSubtitles && !isEpisodes)
Row(
children: [
const Text(
"Rating : ",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
Text(
titles[index].aggregateRating?.toStringAsFixed(
2,
) ??
"?",
style: const TextStyle(fontSize: 12),
),
],
),
if (!isSubtitles && !isEpisodes)
Row(
children: [
const Text(
"Votes : ",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
Text(
titles[index].voteCount?.toString() ?? "?",
style: const TextStyle(fontSize: 12),
),
],
),
if (!isSubtitles && !isEpisodes)
Row(
children: [
const Text(
"Date : ",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
Text(
"${titles[index].startYear?.toString() ?? "?"} - ${titles[index].endYear?.toString() ?? "?"}",
style: const TextStyle(fontSize: 12),
),
],
),
],
),
],
),
],
),
),
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider();
},
);
}
}
subtitlesSearchraggableMenu(
BuildContext context, {
required Chapter chapter,
}) async {
var padding = MediaQuery.of(context).padding;
return await showDialog(
context: context,
builder: (context) => Scaffold(
backgroundColor: Colors.transparent,
body: SingleChildScrollView(
child: SizedBox(
height: context.height(1) - padding.top - padding.bottom,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(Icons.clear),
),
),
],
),
],
),
),
SubtitlesWidgetSearch(chapter: chapter),
],
),
),
),
),
);
}

View file

@ -0,0 +1,171 @@
import 'dart:convert';
import 'package:mangayomi/services/http/m_client.dart';
Future<List<ImdbTitle>> fetchImdbTitles(String query) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final url = "https://api.imdbapi.dev/search/titles?query=$query";
final res = await http.get(
Uri.parse(url),
headers: {
"Accept": "application/json",
"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>;
return (data["titles"] as List?)
?.map((e) => ImdbTitle.fromJson(e))
.toList() ??
[];
} catch (_) {
return [];
}
}
Future<List<ImdbEpisode>?> fetchImdbEpisodes(String imdbId) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final url = "https://api.imdbapi.dev/titles/$imdbId/episodes";
final res = await http.get(
Uri.parse(url),
headers: {
"Accept": "application/json",
"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>;
return (data["episodes"] as List?)
?.map((e) => ImdbEpisode.fromJson(e))
.toList();
} catch (_) {
return null;
}
}
Future<List<ImdbSubtitle>?> fetchImdbSubtitles(String imdbId) async {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
try {
final url = "https://sub.wyzie.ru/search?id=$imdbId";
final res = await http.get(
Uri.parse(url),
headers: {
"Accept": "application/json",
"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 List?;
return data
?.map((e) => ImdbSubtitle.fromJson(e))
.where((e) => e.url != null)
.toList();
} catch (_) {
return null;
}
}
class ImdbTitle {
final String id;
final String? type;
final String primaryTitle;
final String? originalTitle;
final String? primaryImage;
final int? startYear;
final int? endYear;
final double? aggregateRating;
final int? voteCount;
ImdbTitle({
required this.id,
this.type,
required this.primaryTitle,
this.originalTitle,
this.primaryImage,
this.startYear,
this.endYear,
this.aggregateRating,
this.voteCount,
});
factory ImdbTitle.fromJson(Map<String, dynamic> json) {
return ImdbTitle(
id: json["id"],
type: json["type"],
primaryTitle: json["primaryTitle"] ?? "???",
originalTitle: json["originalTitle"],
primaryImage: json["primaryImage"]?["url"],
startYear: json["startYear"],
endYear: json["endYear"],
aggregateRating: json["rating"]?["aggregateRating"] is int
? (json["rating"]?["aggregateRating"] as int).toDouble()
: json["rating"]?["aggregateRating"],
voteCount: json["rating"]?["voteCount"],
);
}
}
class ImdbEpisode {
final String id;
final String title;
final String? primaryImage;
final String season;
final String episode;
ImdbEpisode({
required this.id,
required this.title,
this.primaryImage,
required this.season,
required this.episode,
});
factory ImdbEpisode.fromJson(Map<String, dynamic> json) {
return ImdbEpisode(
id: json["id"],
title: json["title"] ?? "???",
primaryImage: json["primaryImage"]?["url"],
season: json["season"] ?? "?",
episode: (json["episodeNumber"] as int?)?.toString() ?? "?",
);
}
}
class ImdbSubtitle {
final String id;
final String? url;
final String? flagUrl;
final String? format;
final String? encoding;
final String? displayLang;
final String? language;
final String? name;
final bool isHearingImpaired;
ImdbSubtitle({
required this.id,
this.url,
this.flagUrl,
this.format,
this.encoding,
this.displayLang,
this.language,
this.name,
required this.isHearingImpaired,
});
factory ImdbSubtitle.fromJson(Map<String, dynamic> json) {
return ImdbSubtitle(
id: json["id"],
url: json["url"],
flagUrl: json["flagUrl"],
format: json["format"],
encoding: json["encoding"],
displayLang: json["display"],
language: json["language"],
name: json["media"],
isHearingImpaired: json["isHearingImpaired"] ?? false,
);
}
}