diff --git a/lib/screens/anime/watch/subtitles/model/online_subtitle.dart b/lib/screens/anime/watch/subtitles/model/online_subtitle.dart index 4a29c3fa0..51af22913 100644 --- a/lib/screens/anime/watch/subtitles/model/online_subtitle.dart +++ b/lib/screens/anime/watch/subtitles/model/online_subtitle.dart @@ -6,9 +6,16 @@ class OnlineSubtitle { final String encoding; final String label; final String language; + final String languageCode; final String media; final bool isHearingImpaired; final String source; + final String provider; + final int downloads; + final double rating; + final bool isSeasonPack; + final String? uploadDate; + final String? uploader; OnlineSubtitle({ required this.id, @@ -18,9 +25,16 @@ class OnlineSubtitle { required this.encoding, required this.label, required this.language, + required this.languageCode, required this.media, required this.isHearingImpaired, required this.source, + required this.provider, + this.downloads = 0, + this.rating = 0.0, + this.isSeasonPack = false, + this.uploadDate, + this.uploader, }); factory OnlineSubtitle.fromJson(Map json) { @@ -30,11 +44,18 @@ class OnlineSubtitle { flagUrl: json['flagUrl'] ?? '', format: json['format'] ?? '', encoding: json['encoding'] ?? '', - label: json['display'] ?? '', + label: json['display'] ?? json['label'] ?? '', language: json['language'] ?? '', + languageCode: json['languageCode'] ?? json['lang'] ?? '', media: json['media'] ?? '', - isHearingImpaired: json['isHearingImpaired'] ?? false, + isHearingImpaired: json['isHearingImpaired'] ?? json['hearingImpaired'] ?? false, source: json['source'] ?? '', + provider: json['provider'] ?? '', + downloads: json['downloads'] ?? 0, + rating: (json['rating'] ?? 0).toDouble(), + isSeasonPack: json['isSeasonPack'] ?? false, + uploadDate: json['uploadDate'], + uploader: json['uploader'], ); } @@ -47,9 +68,16 @@ class OnlineSubtitle { 'encoding': encoding, 'label': label, 'language': language, + 'languageCode': languageCode, 'media': media, 'isHearingImpaired': isHearingImpaired, 'source': source, + 'provider': provider, + 'downloads': downloads, + 'rating': rating, + 'isSeasonPack': isSeasonPack, + 'uploadDate': uploadDate, + 'uploader': uploader, }; } } diff --git a/lib/screens/anime/watch/subtitles/repository/subtitle_repo.dart b/lib/screens/anime/watch/subtitles/repository/subtitle_repo.dart index 9960eb6cf..e908e5f33 100644 --- a/lib/screens/anime/watch/subtitles/repository/subtitle_repo.dart +++ b/lib/screens/anime/watch/subtitles/repository/subtitle_repo.dart @@ -1,48 +1,649 @@ import 'dart:convert'; import 'package:anymex/screens/anime/watch/subtitles/model/online_subtitle.dart'; +import 'package:anymex/screens/anime/watch/subtitles/utils/language_utils.dart'; +import 'package:anymex/utils/logger.dart'; import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; -class SubtitleRepo { - static const String baseUrl = 'https://sub.wyzie.ru/search'; +enum SubtitleProvider { + wyzie('Wyzie Subs', false, 'https://sub.wyzie.ru'), + opensubtitles('OpenSubtitles', true, 'https://api.opensubtitles.com/api/v1'), + subdl('SubDL', true, 'https://api.subdl.com/api/v1'), + subsro('Subs.ro', true, 'https://subs.ro/api/v1.0'), + jimaku('Jimaku', true, 'https://jimaku.cc/api'); - static Future> searchById(String id) async { - final url = Uri.parse('$baseUrl?id=$id'); - return _fetchSubtitles(url); + const SubtitleProvider(this.displayName, this.requiresApiKey, this.baseUrl); + final String displayName; + final bool requiresApiKey; + final String baseUrl; +} + +class SubtitleSearchParams { + final String? imdbId; + final String? tmdbId; + final String? anilistId; + final String? malId; + final String title; + final String type; + final int? season; + final int? episode; + final List languages; + final bool excludeHearingImpaired; + final Duration timeout; + + SubtitleSearchParams({ + this.imdbId, + this.tmdbId, + this.anilistId, + this.malId, + required this.title, + required this.type, + this.season, + this.episode, + required this.languages, + this.excludeHearingImpaired = false, + this.timeout = const Duration(seconds: 15), + }); +} + +abstract class BaseSubtitleProvider { + SubtitleProvider get providerType; + Future> search(SubtitleSearchParams params); + Future download(String url, {String? languageHint}); +} + +class WyzieProvider implements BaseSubtitleProvider { + @override + SubtitleProvider get providerType => SubtitleProvider.wyzie; + + static const String baseUrl = 'https://sub.wyzie.ru'; + + @override + Future> search(SubtitleSearchParams params) async { + try { + String searchId = ''; + if (params.imdbId != null && params.imdbId!.isNotEmpty) { + searchId = params.imdbId!.startsWith('tt') ? params.imdbId! : 'tt${params.imdbId}'; + } else if (params.tmdbId != null) { + searchId = params.tmdbId!; + } else { + return []; + } + + final queryParams = {'id': searchId}; + + if (params.type == 'episode' && params.episode != null) { + queryParams['season'] = (params.season ?? 1).toString(); + queryParams['episode'] = params.episode.toString(); + } + + if (params.languages.isNotEmpty) { + final iso1Langs = params.languages + .map((lang) => LanguageUtils.toIso6391(lang)) + .where((code) => code != null) + .map((code) { + if (code == 'pt-br') return 'pb'; + if (code == 'zh-tw') return 'zt'; + if (code == 'zh-cn') return 'zh'; + return code!.split('-')[0]; + }) + .toSet() + .join(','); + + if (iso1Langs.isNotEmpty) { + queryParams['language'] = iso1Langs; + } + } + + queryParams['format'] = 'srt'; + + final sourceList = ['opensubtitles', 'subf2m', 'subdl', 'podnapisi', 'gestdown', 'animetosho']; + queryParams['source'] = sourceList.join(','); + + final url = Uri.parse('$baseUrl/search').replace(queryParameters: queryParams); + Logger.d('[Wyzie] Searching: $url'); + + final response = await http.get(url).timeout(params.timeout); + + if (response.statusCode != 200) return []; + + final List data = jsonDecode(response.body); + + return data.map((e) { + final normalizedLang = LanguageUtils.normalizeLanguageCode(e['language'] ?? ''); + return OnlineSubtitle( + id: e['id'] ?? '', + url: e['url'] ?? '', + flagUrl: e['flagUrl'] ?? LanguageUtils.getFlagUrl(e['language'] ?? ''), + format: e['format'] ?? 'srt', + encoding: e['encoding'] ?? 'utf-8', + label: e['display'] ?? e['fileName'] ?? e['media'] ?? 'Unknown', + language: e['language'] ?? '', + languageCode: normalizedLang, + media: e['media'] ?? '', + isHearingImpaired: e['isHearingImpaired'] ?? false, + source: e['source']?.toString() ?? 'wyzie', + provider: providerType.name, + downloads: 0, + rating: 0, + isSeasonPack: false, + ); + }).toList(); + } catch (e) { + Logger.e('[Wyzie] Search failed: $e'); + return []; + } + } + + @override + Future download(String url, {String? languageHint}) async { + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + throw Exception('Download failed: ${response.statusCode}'); + } + return utf8.decode(response.bodyBytes); + } catch (e) { + Logger.e('[Wyzie] Download failed: $e'); + rethrow; + } + } +} + +class OpenSubtitlesProvider implements BaseSubtitleProvider { + @override + SubtitleProvider get providerType => SubtitleProvider.opensubtitles; + + String? _apiKey; + String? _token; + DateTime? _tokenExpiry; + + OpenSubtitlesProvider({String? apiKey}) { + _apiKey = apiKey; + } + + Future _ensureAuthenticated() async { + if (_token != null && _tokenExpiry != null && DateTime.now().isBefore(_tokenExpiry!)) { + return true; + } + + if (_apiKey == null) return false; + + try { + final response = await http.post( + Uri.parse('${providerType.baseUrl}/login'), + headers: { + 'Content-Type': 'application/json', + 'Api-Key': _apiKey!, + }, + body: jsonEncode({}), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _token = data['token']; + _tokenExpiry = DateTime.now().add(const Duration(hours: 23)); + return true; + } + } catch (e) { + Logger.e('[OpenSubtitles] Auth failed: $e'); + } + return false; + } + + @override + Future> search(SubtitleSearchParams params) async { + try { + if (!await _ensureAuthenticated()) return []; + + if (params.imdbId == null) return []; + + final imdbId = params.imdbId!.replaceAll('tt', ''); + + final convertedLangs = params.languages + .map((lang) => LanguageUtils.toIso6391(lang)) + .whereType() + .map((code) => code == 'pt-br' ? 'pt-br' : code.split('-')[0]) + .toList(); + + final queryParams = { + 'imdb_id': imdbId, + 'languages': convertedLangs.join(','), + }; + + if (params.type == 'episode' && params.episode != null) { + queryParams['season_number'] = (params.season ?? 1).toString(); + queryParams['episode_number'] = params.episode.toString(); + } + + if (params.excludeHearingImpaired) { + queryParams['hearing_impaired'] = 'exclude'; + } + + final url = Uri.parse('${providerType.baseUrl}/subtitles').replace(queryParameters: queryParams); + + final response = await http.get( + url, + headers: { + 'Authorization': 'Bearer $_token', + 'Api-Key': _apiKey!, + 'Content-Type': 'application/json', + }, + ).timeout(params.timeout); + + if (response.statusCode != 200) return []; + + final data = jsonDecode(response.body); + final subtitles = data['data'] as List? ?? []; + + return subtitles.map((sub) { + final attrs = sub['attributes']; + final file = attrs['files']?[0] ?? {}; + final langCode = LanguageUtils.normalizeLanguageCode(attrs['language'] ?? ''); + + return OnlineSubtitle( + id: file['file_id']?.toString() ?? sub['id'].toString(), + url: attrs['url'] ?? '', + flagUrl: LanguageUtils.getFlagUrl(attrs['language'] ?? ''), + format: attrs['format'] ?? 'srt', + encoding: 'utf-8', + label: file['file_name']?.replaceAll('.srt', '') ?? attrs['release'] ?? 'Unknown', + language: attrs['language'] ?? '', + languageCode: langCode, + media: '', + isHearingImpaired: attrs['hearing_impaired'] ?? false, + source: 'opensubtitles', + provider: providerType.name, + downloads: int.tryParse(attrs['download_count']?.toString() ?? '0') ?? 0, + rating: double.tryParse(attrs['ratings']?.toString() ?? '0') ?? 0, + isSeasonPack: false, + ); + }).toList(); + } catch (e) { + Logger.e('[OpenSubtitles] Search failed: $e'); + return []; + } + } + + @override + Future download(String url, {String? languageHint}) async { + try { + final downloadResponse = await http.post( + Uri.parse('${providerType.baseUrl}/download'), + headers: { + 'Authorization': 'Bearer $_token', + 'Api-Key': _apiKey!, + 'Content-Type': 'application/json', + }, + body: jsonEncode({'file_id': int.parse(url)}), + ); + + if (downloadResponse.statusCode != 200) { + throw Exception('Download link failed'); + } + + final downloadData = jsonDecode(downloadResponse.body); + final fileResponse = await http.get(Uri.parse(downloadData['link'])); + + if (fileResponse.statusCode != 200) { + throw Exception('File download failed'); + } + + return utf8.decode(fileResponse.bodyBytes); + } catch (e) { + Logger.e('[OpenSubtitles] Download failed: $e'); + rethrow; + } + } +} + +class SubDLProvider implements BaseSubtitleProvider { + @override + SubtitleProvider get providerType => SubtitleProvider.subdl; + + final String? _apiKey; + + SubDLProvider({String? apiKey}) : _apiKey = apiKey; + + @override + Future> search(SubtitleSearchParams params) async { + try { + if (_apiKey == null || _apiKey!.isEmpty) return []; + if (params.imdbId == null) return []; + + final imdbId = params.imdbId!.startsWith('tt') ? params.imdbId! : 'tt${params.imdbId}'; + + final convertedLangs = params.languages + .map((lang) => LanguageUtils.toSubDLLanguage(lang)) + .whereType() + .toList(); + + final queryParams = { + 'api_key': _apiKey!, + 'imdb_id': imdbId, + 'type': params.type, + 'subs_per_page': '30', + }; + + if (convertedLangs.isNotEmpty) { + queryParams['languages'] = convertedLangs.join(','); + } + + if (params.type == 'episode' && params.episode != null) { + queryParams['season_number'] = (params.season ?? 1).toString(); + queryParams['episode_number'] = params.episode.toString(); + } + + final url = Uri.parse('${providerType.baseUrl}/subtitles').replace(queryParameters: queryParams); + + final response = await http.get(url).timeout(params.timeout); + + if (response.statusCode != 200) return []; + + final data = jsonDecode(response.body); + if (data['status'] != true) return []; + + final subtitles = data['subtitles'] as List? ?? []; + + return subtitles.map((sub) { + final urlMatch = RegExp(r'/subtitle/(\d+)-(\d+)\.zip').firstMatch(sub['url'] ?? ''); + final sdId = urlMatch?.group(1); + final subId = urlMatch?.group(2); + final fileId = 'subdl_${sdId}_$subId'; + final langCode = LanguageUtils.normalizeLanguageCode(sub['lang'] ?? ''); + + return OnlineSubtitle( + id: fileId, + url: sub['url'] ?? '', + flagUrl: LanguageUtils.getFlagUrl(sub['lang'] ?? ''), + format: 'srt', + encoding: 'utf-8', + label: sub['release_name'] ?? sub['name'] ?? 'Unknown', + language: sub['lang'] ?? '', + languageCode: langCode, + media: '', + isHearingImpaired: sub['hi'] == 1, + source: 'subdl', + provider: providerType.name, + downloads: int.tryParse(sub['download_count']?.toString() ?? '0') ?? 0, + rating: double.tryParse(sub['rating']?.toString() ?? '0') ?? 0, + isSeasonPack: (params.type == 'episode' && + sub['episode'] == null && + sub['episode_from'] != sub['episode_end']), + ); + }).toList(); + } catch (e) { + Logger.e('[SubDL] Search failed: $e'); + return []; + } + } + + @override + Future download(String url, {String? languageHint}) async { + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + throw Exception('Download failed: ${response.statusCode}'); + } + return utf8.decode(response.bodyBytes); + } catch (e) { + Logger.e('[SubDL] Download failed: $e'); + rethrow; + } + } +} + +class JimakuProvider implements BaseSubtitleProvider { + @override + SubtitleProvider get providerType => SubtitleProvider.jimaku; + + final String? _apiKey; + + JimakuProvider({String? apiKey}) : _apiKey = apiKey; + + @override + Future> search(SubtitleSearchParams params) async { + try { + if (_apiKey == null || _apiKey!.isEmpty) return []; + + String? searchId; + if (params.anilistId != null) { + searchId = 'anilist_id=${params.anilistId}'; + } else if (params.malId != null) { + final anilistId = await _malToAnilist(params.malId!); + if (anilistId != null) { + searchId = 'anilist_id=$anilistId'; + } + } + + if (searchId == null) { + searchId = 'query=${Uri.encodeComponent(params.title)}'; + } + + final url = Uri.parse('${providerType.baseUrl}/entries/search?$searchId'); + + final entriesResponse = await http.get( + url, + headers: {'Authorization': _apiKey!}, + ).timeout(params.timeout); + + if (entriesResponse.statusCode != 200) return []; + + final entries = jsonDecode(entriesResponse.body) as List; + if (entries.isEmpty) return []; + + final entryId = entries.first['id']; + + final filesUrl = Uri.parse('${providerType.baseUrl}/entries/$entryId/files${params.episode != null ? '?episode=${params.episode}' : ''}'); + + final filesResponse = await http.get( + filesUrl, + headers: {'Authorization': _apiKey!}, + ).timeout(params.timeout); + + if (filesResponse.statusCode != 200) return []; + + final files = jsonDecode(filesResponse.body) as List; + + return files.where((f) { + final name = (f['name'] ?? '').toLowerCase(); + return !name.endsWith('.zip') && !name.endsWith('.rar') && !name.endsWith('.7z'); + }).map((file) { + final name = file['name'] ?? ''; + final format = name.split('.').last.toLowerCase(); + final langCode = _detectJimakuLanguage(name); + + return OnlineSubtitle( + id: 'jimaku_${file['url']}', + url: file['url'], + flagUrl: LanguageUtils.getFlagUrl(langCode), + format: format, + encoding: 'utf-8', + label: name.replaceAll('.srt', '').replaceAll('.ass', ''), + language: LanguageUtils.iso2ToDisplay[langCode] ?? 'Japanese', + languageCode: langCode, + media: '', + isHearingImpaired: name.contains(RegExp(r'[\[\(](?:hi|cc|sdh)[\]\)]', caseSensitive: false)), + source: 'jimaku', + provider: providerType.name, + downloads: 0, + rating: 0, + isSeasonPack: false, + ); + }).toList(); + } catch (e) { + Logger.e('[Jimaku] Search failed: $e'); + return []; + } } - static Future> searchByEpisode( - String id, { - required int season, - required int episode, - }) async { - final url = Uri.parse('$baseUrl?id=$id&season=$season&episode=$episode'); - return _fetchSubtitles(url); + Future _malToAnilist(String malId) async { + try { + final query = ''' + query { + Media(idMal: $malId, type: ANIME) { id } + } + '''; + + final response = await http.post( + Uri.parse('https://graphql.anilist.co'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'query': query}), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['data']['Media']['id']?.toString(); + } + } catch (e) { + Logger.e('[Jimaku] MAL to AniList conversion failed: $e'); + } + return null; } - static Future> searchByLanguage( - String id, { - required String language, - }) async { - final url = Uri.parse('$baseUrl?id=$id&language=$language'); - return _fetchSubtitles(url); + String _detectJimakuLanguage(String filename) { + final lower = filename.toLowerCase(); + if (lower.contains('.en.') || lower.contains('[en]')) return 'eng'; + if (lower.contains('.ja.') || lower.contains('[ja]')) return 'jpn'; + return 'jpn'; + } + + @override + Future download(String url, {String? languageHint}) async { + try { + final response = await http.get( + Uri.parse(url), + headers: {'Authorization': _apiKey!}, + ); + if (response.statusCode != 200) { + throw Exception('Download failed: ${response.statusCode}'); + } + return utf8.decode(response.bodyBytes); + } catch (e) { + Logger.e('[Jimaku] Download failed: $e'); + rethrow; + } + } +} + +class SubtitleRepository { + static final SubtitleRepository _instance = SubtitleRepository._internal(); + factory SubtitleRepository() => _instance; + SubtitleRepository._internal(); + + final Map _providers = {}; + final List _enabledProviders = []; + + Future initialize() async { + _registerProvider(WyzieProvider()); + + final prefs = await SharedPreferences.getInstance(); + + for (final provider in SubtitleProvider.values) { + if (provider == SubtitleProvider.wyzie) { + _enabledProviders.add(provider); + continue; + } + + final apiKey = prefs.getString('${provider.name}_api_key'); + if (apiKey != null && apiKey.isNotEmpty) { + switch (provider) { + case SubtitleProvider.opensubtitles: + _registerProvider(OpenSubtitlesProvider(apiKey: apiKey)); + _enabledProviders.add(provider); + break; + case SubtitleProvider.subdl: + _registerProvider(SubDLProvider(apiKey: apiKey)); + _enabledProviders.add(provider); + break; + case SubtitleProvider.jimaku: + _registerProvider(JimakuProvider(apiKey: apiKey)); + _enabledProviders.add(provider); + break; + default: + break; + } + } + } } - static Future> searchByFormat( - String id, { - required String format, - }) async { - final url = Uri.parse('$baseUrl?id=$id&format=$format'); - return _fetchSubtitles(url); + void _registerProvider(BaseSubtitleProvider provider) { + _providers[provider.providerType] = provider; } - static Future> _fetchSubtitles(Uri url) async { - final res = await http.get(url); + List get enabledProviders => List.unmodifiable(_enabledProviders); - if (res.statusCode == 200) { - final List data = jsonDecode(res.body); - return data.map((e) => OnlineSubtitle.fromJson(e)).toList(); + Future setProviderEnabled(SubtitleProvider provider, bool enabled, {String? apiKey}) async { + final prefs = await SharedPreferences.getInstance(); + + if (enabled) { + if (apiKey != null) { + await prefs.setString('${provider.name}_api_key', apiKey); + + switch (provider) { + case SubtitleProvider.opensubtitles: + _registerProvider(OpenSubtitlesProvider(apiKey: apiKey)); + break; + case SubtitleProvider.subdl: + _registerProvider(SubDLProvider(apiKey: apiKey)); + break; + case SubtitleProvider.jimaku: + _registerProvider(JimakuProvider(apiKey: apiKey)); + break; + default: + break; + } + } + + if (!_enabledProviders.contains(provider)) { + _enabledProviders.add(provider); + } } else { - throw Exception('Failed to load subtitles: ${res.statusCode}'); + await prefs.remove('${provider.name}_api_key'); + _enabledProviders.remove(provider); + _providers.remove(provider); + } + } + + Future>> searchAll(SubtitleSearchParams params) async { + final results = >{}; + final futures = []; + + for (final providerType in _enabledProviders) { + final provider = _providers[providerType]; + if (provider == null) continue; + + futures.add(Future(() async { + try { + final subs = await provider.search(params); + results[providerType] = subs; + } catch (e) { + Logger.e('[${providerType.name}] Search failed: $e'); + results[providerType] = []; + } + })); } + + await Future.wait(futures); + return results; + } + + Future> searchFromProvider(SubtitleProvider providerType, SubtitleSearchParams params) async { + final provider = _providers[providerType]; + if (provider == null) return []; + + try { + return await provider.search(params); + } catch (e) { + Logger.e('[${providerType.name}] Search failed: $e'); + return []; + } + } + + Future downloadFromProvider(SubtitleProvider providerType, String url, {String? languageHint}) async { + final provider = _providers[providerType]; + if (provider == null) throw Exception('Provider not found'); + + return await provider.download(url, languageHint: languageHint); } } diff --git a/lib/screens/anime/watch/subtitles/subtitle_view.dart b/lib/screens/anime/watch/subtitles/subtitle_view.dart index b9d243382..0a5c5d807 100644 --- a/lib/screens/anime/watch/subtitles/subtitle_view.dart +++ b/lib/screens/anime/watch/subtitles/subtitle_view.dart @@ -4,6 +4,7 @@ import 'package:anymex/screens/anime/watch/subtitles/model/imdb_item.dart'; import 'package:anymex/screens/anime/watch/subtitles/model/online_subtitle.dart'; import 'package:anymex/screens/anime/watch/subtitles/repository/imdb_repo.dart'; import 'package:anymex/screens/anime/watch/subtitles/repository/subtitle_repo.dart'; +import 'package:anymex/screens/anime/watch/subtitles/utils/language_utils.dart'; import 'package:anymex/utils/logger.dart'; import 'package:anymex/widgets/custom_widgets/anymex_chip.dart'; import 'package:anymex/widgets/custom_widgets/anymex_image.dart'; @@ -43,6 +44,9 @@ class _SubtitleSearchBottomSheetState extends State { final RxInt _selectedSeason = 0.obs; final Rx _selectedEpisode = Rx(null); final RxString _selectedFilter = 'All'.obs; + final Rx _selectedProvider = Rx(null); + final RxMap> _providerResults = + RxMap>({}); final Rx _currentView = SubtitleSearchView.search.obs; @@ -50,9 +54,11 @@ class _SubtitleSearchBottomSheetState extends State { 'All', 'English', 'Spanish', + 'Portuguese', 'French', 'German', - 'Italian', + 'Japanese', + 'Assamese', 'SRT', 'VTT', 'ASS' @@ -112,10 +118,25 @@ class _SubtitleSearchBottomSheetState extends State { Future _searchSubtitles(String imdbId) async { _isLoadingSubtitles.value = true; _subtitles.clear(); + _providerResults.clear(); try { - final data = await SubtitleRepo.searchById(imdbId); - _subtitles.assignAll(data); + final params = SubtitleSearchParams( + imdbId: imdbId, + title: _selectedItem.value?.title ?? '', + type: _episodes.isEmpty ? 'movie' : 'episode', + season: _selectedSeason.value, + episode: _selectedEpisode.value?.episodeNumber, + languages: ['eng', 'spa', 'por', 'fre', 'ger', 'jpn', 'asm'], + excludeHearingImpaired: false, + ); + + final results = await SubtitleRepository().searchAll(params); + _providerResults.value = results; + + // Combine all results + final allSubs = results.values.expand((e) => e).toList(); + _subtitles.assignAll(allSubs); } catch (e) { Logger.e('Subtitle search error: ${e.toString()}'); _showError('Failed to load subtitles: ${e.toString()}'); @@ -128,14 +149,24 @@ class _SubtitleSearchBottomSheetState extends State { String imdbId, int season, int episode) async { _isLoadingSubtitles.value = true; _subtitles.clear(); + _providerResults.clear(); try { - final data = await SubtitleRepo.searchByEpisode( - imdbId, + final params = SubtitleSearchParams( + imdbId: imdbId, + title: _selectedItem.value?.title ?? '', + type: 'episode', season: season, episode: episode, + languages: ['eng', 'spa', 'por', 'fre', 'ger', 'jpn', 'asm'], + excludeHearingImpaired: false, ); - _subtitles.assignAll(data); + + final results = await SubtitleRepository().searchAll(params); + _providerResults.value = results; + + final allSubs = results.values.expand((e) => e).toList(); + _subtitles.assignAll(allSubs); _currentView.value = SubtitleSearchView.subtitles; } catch (e) { Logger.e('Episode subtitle search error: ${e.toString()}'); @@ -159,31 +190,44 @@ class _SubtitleSearchBottomSheetState extends State { List get _filteredSubtitles { final links = widget.controller.externalSubs.value.map((e) => e.file).toList(); + var subs = _subtitles.where((e) => !links.contains(e.url)).toList(); - - if (_selectedFilter.value == 'All') return subs; - - return subs.where((subtitle) { - final filter = _selectedFilter.value.toLowerCase(); - switch (filter) { - case 'english': - return subtitle.language == 'en'; - case 'spanish': - return subtitle.language == 'es'; - case 'french': - return subtitle.language == 'fr'; - case 'german': - return subtitle.language == 'de'; - case 'italian': - return subtitle.language == 'it'; - case 'srt': - case 'vtt': - case 'ass': - return subtitle.format.toLowerCase() == filter; - default: - return true; - } - }).toList(); + + // Filter by provider if selected + if (_selectedProvider.value != null) { + subs = subs.where((e) => e.provider == _selectedProvider.value!.name).toList(); + } + + // Filter by language/format + if (_selectedFilter.value != 'All') { + subs = subs.where((subtitle) { + final filter = _selectedFilter.value.toLowerCase(); + switch (filter) { + case 'english': + return subtitle.languageCode == 'eng'; + case 'spanish': + return subtitle.languageCode == 'spa'; + case 'portuguese': + return subtitle.languageCode == 'por' || subtitle.languageCode == 'pob'; + case 'french': + return subtitle.languageCode == 'fre'; + case 'german': + return subtitle.languageCode == 'ger'; + case 'japanese': + return subtitle.languageCode == 'jpn'; + case 'assamese': + return subtitle.languageCode == 'asm'; + case 'srt': + case 'vtt': + case 'ass': + return subtitle.format.toLowerCase() == filter; + default: + return true; + } + }).toList(); + } + + return subs; } void _closeSheet() { @@ -207,12 +251,16 @@ class _SubtitleSearchBottomSheetState extends State { _currentView.value = SubtitleSearchView.episodes; _selectedEpisode.value = null; _subtitles.clear(); + _providerResults.clear(); _selectedFilter.value = 'All'; + _selectedProvider.value = null; } else { _currentView.value = SubtitleSearchView.search; _selectedItem.value = null; _subtitles.clear(); + _providerResults.clear(); _selectedFilter.value = 'All'; + _selectedProvider.value = null; } break; case SubtitleSearchView.search: @@ -387,6 +435,8 @@ class _SubtitleSearchBottomSheetState extends State { if (_currentView.value == SubtitleSearchView.subtitles && _subtitles.isNotEmpty) ...[ const SizedBox(height: 12), + _buildProviderChips(), + const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -410,6 +460,36 @@ class _SubtitleSearchBottomSheetState extends State { ); } + Widget _buildProviderChips() { + if (_providerResults.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Obx(() => AnymexChip( + label: 'All Providers', + isSelected: _selectedProvider.value == null, + onSelected: (_) => _selectedProvider.value = null, + )), + ..._providerResults.keys.map((provider) { + final count = _providerResults[provider]?.length ?? 0; + if (count == 0) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Obx(() => AnymexChip( + label: '${provider.displayName} ($count)', + isSelected: _selectedProvider.value == provider, + onSelected: (_) => _selectedProvider.value = provider, + )), + ); + }), + ], + ), + ); + } + String _getHeaderTitle() { switch (_currentView.value) { case SubtitleSearchView.search: @@ -784,6 +864,41 @@ class _SubtitleSearchBottomSheetState extends State { ); } + Widget _buildProviderBadge(String providerName, ThemeData theme, ColorScheme colorScheme) { + Color getProviderColor() { + switch (providerName) { + case 'wyzie': + return Colors.purple; + case 'opensubtitles': + return Colors.green; + case 'subdl': + return Colors.blue; + case 'jimaku': + return Colors.orange; + default: + return Colors.grey; + } + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: getProviderColor().withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: getProviderColor().withOpacity(0.5)), + ), + child: Text( + providerName, + style: theme.textTheme.bodySmall?.copyWith( + color: getProviderColor(), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ); + } + Widget _buildSubtitleCard( OnlineSubtitle subtitle, ThemeData theme, ColorScheme colorScheme) { return Container( @@ -827,8 +942,11 @@ class _SubtitleSearchBottomSheetState extends State { color: colorScheme.onSurface, fontWeight: FontWeight.w600, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), + _buildProviderBadge(subtitle.provider, theme, colorScheme), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -837,7 +955,7 @@ class _SubtitleSearchBottomSheetState extends State { borderRadius: BorderRadius.circular(8), ), child: Text( - subtitle.format, + subtitle.format.toUpperCase(), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onPrimaryContainer, fontSize: 10, @@ -853,12 +971,54 @@ class _SubtitleSearchBottomSheetState extends State { Icon(Icons.source, size: 14, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), - Text( - subtitle.source, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + Expanded( + child: Text( + subtitle.source, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), + if (subtitle.downloads > 0) ...[ + const SizedBox(width: 8), + Icon(Icons.download, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + subtitle.downloads.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (subtitle.rating > 0) ...[ + const SizedBox(width: 8), + Icon(Icons.star, size: 14, color: Colors.amber), + const SizedBox(width: 4), + Text( + subtitle.rating.toStringAsFixed(1), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (subtitle.isHearingImpaired) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'HI', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ], ), ], diff --git a/lib/screens/anime/watch/subtitles/utils/language_utils.dart b/lib/screens/anime/watch/subtitles/utils/language_utils.dart new file mode 100644 index 000000000..1c2fbc644 --- /dev/null +++ b/lib/screens/anime/watch/subtitles/utils/language_utils.dart @@ -0,0 +1,145 @@ +class LanguageUtils { + static const Map iso1ToIso2B = { + 'en': 'eng', 'es': 'spa', 'pt': 'por', 'fr': 'fre', 'de': 'ger', + 'it': 'ita', 'ru': 'rus', 'ja': 'jpn', 'ko': 'kor', 'zh': 'chi', + 'ar': 'ara', 'hi': 'hin', 'bn': 'ben', 'ta': 'tam', 'te': 'tel', + 'ml': 'mal', 'as': 'asm', 'th': 'tha', 'vi': 'vie', 'id': 'ind', + 'ms': 'may', 'tl': 'tgl', 'ne': 'nep', 'fa': 'per', 'ur': 'urd', + 'pl': 'pol', 'nl': 'dut', 'tr': 'tur', 'el': 'gre', 'he': 'heb', + 'cs': 'cze', 'hu': 'hun', 'ro': 'rum', 'sv': 'swe', 'no': 'nor', + 'da': 'dan', 'fi': 'fin', 'uk': 'ukr', 'bg': 'bul', 'hr': 'hrv', + 'sr': 'srp', 'sk': 'slo', 'sl': 'slv', 'et': 'est', 'lv': 'lav', + 'lt': 'lit', 'sq': 'alb', 'mk': 'mac', 'bs': 'bos', 'ka': 'geo', + 'hy': 'arm', 'az': 'aze', 'kk': 'kaz', 'uz': 'uzb', 'mn': 'mon', + 'km': 'khm', 'lo': 'lao', 'my': 'bur', 'si': 'sin', 'am': 'amh', + 'sw': 'swa', 'af': 'afr', 'cy': 'wel', 'ga': 'gle', 'gd': 'gla', + 'eu': 'baq', 'ca': 'cat', 'gl': 'glg', 'is': 'ice', 'mt': 'mlt', + }; + + static const Map iso2ToDisplay = { + 'eng': 'English', 'spa': 'Spanish', 'por': 'Portuguese', + 'pob': 'Portuguese (Brazil)', 'fre': 'French', 'ger': 'German', + 'ita': 'Italian', 'jpn': 'Japanese', 'kor': 'Korean', + 'chi': 'Chinese', 'ara': 'Arabic', 'hin': 'Hindi', + 'ben': 'Bengali', 'tam': 'Tamil', 'tel': 'Telugu', + 'mal': 'Malayalam', 'asm': 'Assamese', 'tha': 'Thai', + 'vie': 'Vietnamese', 'ind': 'Indonesian', 'may': 'Malay', + 'tgl': 'Tagalog', 'nep': 'Nepali', 'per': 'Persian', + 'urd': 'Urdu', 'pol': 'Polish', 'dut': 'Dutch', + 'tur': 'Turkish', 'gre': 'Greek', 'heb': 'Hebrew', + 'cze': 'Czech', 'hun': 'Hungarian', 'rum': 'Romanian', + 'swe': 'Swedish', 'nor': 'Norwegian', 'dan': 'Danish', + 'fin': 'Finnish', 'ukr': 'Ukrainian', 'bul': 'Bulgarian', + 'hrv': 'Croatian', 'srp': 'Serbian', 'slo': 'Slovak', + 'slv': 'Slovenian', 'est': 'Estonian', 'lav': 'Latvian', + 'lit': 'Lithuanian', 'alb': 'Albanian', 'mac': 'Macedonian', + 'bos': 'Bosnian', 'geo': 'Georgian', 'arm': 'Armenian', + 'aze': 'Azerbaijani', 'kaz': 'Kazakh', 'uzb': 'Uzbek', + 'mon': 'Mongolian', 'khm': 'Khmer', 'lao': 'Lao', + 'bur': 'Burmese', 'sin': 'Sinhala', 'amh': 'Amharic', + 'swa': 'Swahili', 'afr': 'Afrikaans', 'wel': 'Welsh', + 'gle': 'Irish', 'gla': 'Scottish Gaelic', 'baq': 'Basque', + 'cat': 'Catalan', 'glg': 'Galician', 'ice': 'Icelandic', + 'mlt': 'Maltese', + }; + + static const Map subdlLanguageMap = { + 'eng': 'EN', 'spa': 'ES', 'spn': 'ES', 'fre': 'FR', 'fra': 'FR', + 'ger': 'DE', 'deu': 'DE', 'por': 'PT', 'pob': 'BR_PT', + 'ita': 'IT', 'rus': 'RU', 'jpn': 'JA', 'chi': 'ZH', 'zho': 'ZH', + 'kor': 'KO', 'ara': 'AR', 'dut': 'NL', 'nld': 'NL', 'pol': 'PL', + 'tur': 'TR', 'swe': 'SV', 'nor': 'NO', 'dan': 'DA', 'fin': 'FI', + 'gre': 'EL', 'ell': 'EL', 'heb': 'HE', 'hin': 'HI', 'cze': 'CS', + 'ces': 'CS', 'hun': 'HU', 'rum': 'RO', 'ron': 'RO', 'tha': 'TH', + 'vie': 'VI', 'ind': 'ID', 'ukr': 'UK', 'bul': 'BG', 'hrv': 'HR', + 'srp': 'SR', 'slo': 'SK', 'slk': 'SK', 'slv': 'SL', 'est': 'ET', + 'lav': 'LV', 'lit': 'LT', 'per': 'FA', 'fas': 'FA', 'ben': 'BN', + 'cat': 'CA', 'baq': 'EU', 'eus': 'EU', 'glg': 'GL', 'bos': 'BS', + 'mac': 'MK', 'mkd': 'MK', 'alb': 'SQ', 'sqi': 'SQ', 'bel': 'BE', + 'aze': 'AZ', 'geo': 'KA', 'kat': 'KA', 'mal': 'ML', 'tam': 'TA', + 'tel': 'TE', 'urd': 'UR', 'may': 'MS', 'msa': 'MS', 'tgl': 'TL', + 'ice': 'IS', 'isl': 'IS', 'kur': 'KU', + }; + + static String? toIso6391(String? code) { + if (code == null) return null; + + final lower = code.toLowerCase().trim(); + + if (lower == 'pob' || lower == 'pt-br' || lower == 'ptbr') return 'pt-br'; + if (lower == 'spn') return 'es'; + if (lower == 'fre' || lower == 'fra') return 'fr'; + if (lower == 'ger' || lower == 'deu') return 'de'; + if (lower == 'dut' || lower == 'nld') return 'nl'; + if (lower == 'cze' || lower == 'ces') return 'cs'; + if (lower == 'rum' || lower == 'ron') return 'ro'; + if (lower == 'slo' || lower == 'slk') return 'sk'; + if (lower == 'per' || lower == 'fas') return 'fa'; + if (lower == 'may' || lower == 'msa') return 'ms'; + if (lower == 'ice' || lower == 'isl') return 'is'; + if (lower == 'baq' || lower == 'eus') return 'eu'; + + if (lower.length == 3 && iso1ToIso2B.containsValue(lower)) { + return iso1ToIso2B.entries.firstWhere( + (e) => e.value == lower, + orElse: () => const MapEntry('', ''), + ).key; + } + + if (lower.length == 2 && RegExp(r'^[a-z]{2}$').hasMatch(lower)) { + return lower; + } + + return null; + } + + static String normalizeLanguageCode(String? code) { + if (code == null) return 'und'; + + final lower = code.toLowerCase().trim(); + + if (lower == 'pob' || lower == 'ptbr' || lower == 'pt-br') return 'pob'; + if (lower == 'spn' || lower == 'ea') return 'spn'; + if (lower == 'sx') return 'sat'; + if (lower == 'at') return 'ast'; + if (lower == 'ex') return 'ext'; + if (lower == 'ma') return 'mni'; + if (lower == 'ze') return 'ze'; + if (lower == 'me') return 'mne'; + + if (lower.contains('chinese') && lower.contains('simplified')) return 'zhs'; + if (lower.contains('chinese') && lower.contains('traditional')) return 'zht'; + if (lower == 'zh-cn' || lower == 'zhcn') return 'zhs'; + if (lower == 'zh-tw' || lower == 'zhtw') return 'zht'; + + if (lower.length == 3 && RegExp(r'^[a-z]{3}$').hasMatch(lower)) { + return lower; + } + + if (lower.length == 2 && RegExp(r'^[a-z]{2}$').hasMatch(lower)) { + return iso1ToIso2B[lower] ?? lower; + } + + return 'und'; + } + + static String? toSubDLLanguage(String? code) { + if (code == null) return null; + final normalized = normalizeLanguageCode(code); + return subdlLanguageMap[normalized]; + } + + static String getFlagUrl(String languageCode) { + final code = languageCode.toLowerCase(); + final iso1 = toIso6391(code) ?? code.substring(0, 2).toLowerCase(); + return 'https://flagcdn.com/w40/${iso1.substring(0, 2)}.png'; + } + + static List getSupportedLanguages() { + return iso2ToDisplay.keys.toList(); + } + + static String getLanguageDisplay(String code) { + return iso2ToDisplay[normalizeLanguageCode(code)] ?? code; + } +} diff --git a/lib/screens/anime/watch/subtitles/widgets/provider_settings.dart b/lib/screens/anime/watch/subtitles/widgets/provider_settings.dart new file mode 100644 index 000000000..9076841ee --- /dev/null +++ b/lib/screens/anime/watch/subtitles/widgets/provider_settings.dart @@ -0,0 +1,114 @@ +import 'package:anymex/screens/anime/watch/subtitles/repository/subtitle_repo.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class SubtitleProviderSettings extends StatefulWidget { + const SubtitleProviderSettings({super.key}); + + @override + State createState() => _SubtitleProviderSettingsState(); +} + +class _SubtitleProviderSettingsState extends State { + final SubtitleRepository _repo = SubtitleRepository(); + final Map _apiControllers = {}; + + @override + void initState() { + super.initState(); + for (final provider in SubtitleProvider.values) { + if (provider.requiresApiKey) { + _apiControllers[provider] = TextEditingController(); + } + } + } + + Future _showApiKeyDialog(SubtitleProvider provider) async { + final controller = _apiControllers[provider]!; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('${provider.displayName} API Key'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: 'Enter your API key', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + if (controller.text.isNotEmpty) { + await _repo.setProviderEnabled(provider, true, apiKey: controller.text); + setState(() {}); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Subtitle Providers', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ...SubtitleProvider.values.map((provider) { + final isEnabled = _repo.enabledProviders.contains(provider); + + return ListTile( + leading: Icon( + isEnabled ? Icons.check_circle : Icons.radio_button_unchecked, + color: isEnabled ? Colors.green : null, + ), + title: Text(provider.displayName), + subtitle: provider.requiresApiKey + ? const Text('Requires API key') + : const Text('Free to use'), + trailing: provider == SubtitleProvider.wyzie + ? null + : Switch( + value: isEnabled, + onChanged: (enabled) async { + if (enabled && provider.requiresApiKey) { + await _showApiKeyDialog(provider); + } else if (!enabled) { + await _repo.setProviderEnabled(provider, false); + setState(() {}); + } else { + await _repo.setProviderEnabled(provider, true); + setState(() {}); + } + }, + ), + ); + }), + ], + ); + } + + @override + void dispose() { + for (final controller in _apiControllers.values) { + controller.dispose(); + } + super.dispose(); + } +}