Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import '../models/video_item.dart';
|
||||
|
||||
class PreferredLanguageOption {
|
||||
const PreferredLanguageOption({
|
||||
required this.code,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class PreferredLanguageService {
|
||||
PreferredLanguageService._();
|
||||
|
||||
static const List<PreferredLanguageOption> options =
|
||||
<PreferredLanguageOption>[
|
||||
PreferredLanguageOption(code: 'auto', label: 'Auto'),
|
||||
PreferredLanguageOption(code: 'fr', label: 'Français (fr)'),
|
||||
PreferredLanguageOption(code: 'en', label: 'English (en)'),
|
||||
PreferredLanguageOption(code: 'ar', label: 'العربية (ar)'),
|
||||
PreferredLanguageOption(code: 'de', label: 'Deutsch (de)'),
|
||||
PreferredLanguageOption(code: 'es', label: 'Español (es)'),
|
||||
];
|
||||
|
||||
static bool isSupported(String code) {
|
||||
return options.any((option) => option.code == code);
|
||||
}
|
||||
|
||||
static String labelFor(String code) {
|
||||
for (final option in options) {
|
||||
if (option.code == code) {
|
||||
return option.label;
|
||||
}
|
||||
}
|
||||
return 'Auto';
|
||||
}
|
||||
|
||||
static String nativeSearchHint(String code) {
|
||||
return switch (code) {
|
||||
'fr' => 'français',
|
||||
'en' => 'English',
|
||||
'ar' => 'عربي',
|
||||
'de' => 'Deutsch',
|
||||
'es' => 'español',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
static String dubbingSearchHint(String code) {
|
||||
return switch (code) {
|
||||
'fr' => 'VF doublage français version française',
|
||||
'en' => 'English dub dubbed',
|
||||
'ar' => 'مدبلج عربي دبلجة عربية',
|
||||
'de' => 'deutsche Synchronisation Deutsch',
|
||||
'es' => 'doblaje español versión española',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns a positive local boost. This is deliberately a preference,
|
||||
/// never a hard filter: videos in other languages remain eligible.
|
||||
static int scoreVideo(VideoItem video, String preferredLanguage) {
|
||||
if (preferredLanguage == 'auto') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final raw = '${video.title} ${video.description} ${video.author}';
|
||||
final text = _normalize(raw);
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (_hasDubbingMarker(text, preferredLanguage)) {
|
||||
score += 18;
|
||||
score += _popularityBonus(video.viewCount);
|
||||
}
|
||||
|
||||
if (_looksLikeLanguage(text, preferredLanguage)) {
|
||||
score += 12;
|
||||
}
|
||||
|
||||
return score.clamp(0, 32).toInt();
|
||||
}
|
||||
|
||||
static bool isLikelyDubbed(VideoItem video, String preferredLanguage) {
|
||||
if (preferredLanguage == 'auto') {
|
||||
return false;
|
||||
}
|
||||
|
||||
final text = _normalize(
|
||||
'${video.title} ${video.description} ${video.author}',
|
||||
);
|
||||
|
||||
return _hasDubbingMarker(text, preferredLanguage);
|
||||
}
|
||||
|
||||
static List<VideoItem> rankVideos(
|
||||
List<VideoItem> videos,
|
||||
String preferredLanguage,
|
||||
) {
|
||||
if (preferredLanguage == 'auto' || videos.length < 2) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
final indexed = videos.asMap().entries.toList(growable: false);
|
||||
|
||||
final sorted = [...indexed]
|
||||
..sort((a, b) {
|
||||
final aScore = scoreVideo(a.value, preferredLanguage);
|
||||
final bScore = scoreVideo(b.value, preferredLanguage);
|
||||
|
||||
final scoreCompare = bScore.compareTo(aScore);
|
||||
if (scoreCompare != 0) {
|
||||
return scoreCompare;
|
||||
}
|
||||
|
||||
return a.key.compareTo(b.key);
|
||||
});
|
||||
|
||||
return sorted.map((entry) => entry.value).toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
static int _popularityBonus(int? viewCount) {
|
||||
if (viewCount == null || viewCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (viewCount >= 10000000) {
|
||||
return 8;
|
||||
}
|
||||
if (viewCount >= 1000000) {
|
||||
return 6;
|
||||
}
|
||||
if (viewCount >= 100000) {
|
||||
return 4;
|
||||
}
|
||||
if (viewCount >= 10000) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool _hasDubbingMarker(String text, String code) {
|
||||
final markers = switch (code) {
|
||||
'fr' => const <String>[
|
||||
' vf ',
|
||||
'[vf]',
|
||||
'(vf)',
|
||||
'version francaise',
|
||||
'version française',
|
||||
'double en francais',
|
||||
'doublé en français',
|
||||
'doublage francais',
|
||||
'doublage français',
|
||||
'french dub',
|
||||
'french dubbed',
|
||||
'audio francais',
|
||||
'audio français',
|
||||
],
|
||||
'en' => const <String>[
|
||||
'english dub',
|
||||
'english dubbed',
|
||||
'dubbed in english',
|
||||
'english audio',
|
||||
],
|
||||
'ar' => const <String>[
|
||||
'مدبلج عربي',
|
||||
'مدبلجة عربية',
|
||||
'دبلجة عربية',
|
||||
'باللغة العربية',
|
||||
],
|
||||
'de' => const <String>[
|
||||
'deutsche synchronisation',
|
||||
'deutsch synchronisiert',
|
||||
'deutsche synchro',
|
||||
'german dub',
|
||||
],
|
||||
'es' => const <String>[
|
||||
'doblaje espanol',
|
||||
'doblaje español',
|
||||
'version espanola',
|
||||
'versión española',
|
||||
'spanish dub',
|
||||
'audio espanol',
|
||||
'audio español',
|
||||
],
|
||||
_ => const <String>[],
|
||||
};
|
||||
|
||||
return markers.any(text.contains);
|
||||
}
|
||||
|
||||
static bool _looksLikeLanguage(String text, String code) {
|
||||
if (code == 'ar') {
|
||||
final arabic = RegExp(r'[\u0600-\u06FF]');
|
||||
final matches = arabic.allMatches(text).length;
|
||||
return matches >= 5;
|
||||
}
|
||||
|
||||
final words = text
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((word) => word.length >= 2)
|
||||
.toList(growable: false);
|
||||
|
||||
final markers = switch (code) {
|
||||
'fr' => const <String>{
|
||||
'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'dans', 'avec',
|
||||
'pour', 'sur', 'est', 'sont', 'comment', 'pourquoi', 'nouveau',
|
||||
'nouvelle', 'francais', 'français', 'voici', 'tout', 'tous',
|
||||
},
|
||||
'en' => const <String>{
|
||||
'the', 'and', 'with', 'for', 'from', 'this', 'that', 'how', 'why',
|
||||
'what', 'new', 'best', 'review', 'explained', 'english',
|
||||
},
|
||||
'de' => const <String>{
|
||||
'der', 'die', 'das', 'und', 'mit', 'für', 'fur', 'von', 'ist', 'wie',
|
||||
'warum', 'neu', 'neue', 'deutsch', 'erklart', 'erklärt',
|
||||
},
|
||||
'es' => const <String>{
|
||||
'el', 'la', 'los', 'las', 'un', 'una', 'con', 'para', 'por', 'como',
|
||||
'cómo', 'nuevo', 'nueva', 'espanol', 'español', 'explicado',
|
||||
},
|
||||
_ => const <String>{},
|
||||
};
|
||||
|
||||
var hits = 0;
|
||||
for (final word in words) {
|
||||
if (markers.contains(word)) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
|
||||
return hits >= 2;
|
||||
}
|
||||
|
||||
static String _normalize(String value) {
|
||||
return ' ${value.toLowerCase()}'
|
||||
.replaceAll(RegExp(r'[^a-z0-9à-ÿ\u0600-\u06ff\[\]\(\)]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trimRight() +
|
||||
' ';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user