1617 lines
33 KiB
Dart
1617 lines
33 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math';
|
||
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import '../models/radar_recommendation.dart';
|
||
import '../models/video_item.dart';
|
||
import 'hidden_channels_service.dart';
|
||
import 'library_service.dart';
|
||
import 'youtube_service.dart';
|
||
|
||
class RadarService {
|
||
RadarService._();
|
||
|
||
static final RadarService instance = RadarService._();
|
||
|
||
static const String _dismissedKey =
|
||
'youtube_personal.radar.dismissed.v1';
|
||
static const String _reducedAuthorsKey =
|
||
'youtube_personal.radar.reduced_authors.v1';
|
||
|
||
static const Duration _dismissDuration =
|
||
Duration(days: 30);
|
||
|
||
final SharedPreferencesAsync _preferences =
|
||
SharedPreferencesAsync();
|
||
|
||
final YoutubeService _youtube = YoutubeService();
|
||
|
||
final Map<String, _CachedRadar>
|
||
_cache = <String, _CachedRadar>{};
|
||
|
||
int _shuffleCounter = 0;
|
||
RadarFeed? _lastFeed;
|
||
|
||
RadarFeed? get lastFeed => _lastFeed;
|
||
|
||
Future<RadarFeed> generate({
|
||
required RadarSessionMode mode,
|
||
bool force = false,
|
||
String interest = 'Tous',
|
||
}) async {
|
||
final history =
|
||
LibraryService.instance.history.value;
|
||
final favorites =
|
||
LibraryService.instance.favorites.value;
|
||
|
||
if (history.isEmpty && favorites.isEmpty) {
|
||
final emptyFeed = RadarFeed(
|
||
mode: mode,
|
||
generatedAt: DateTime.now(),
|
||
profileTopics: const [],
|
||
primary: const [],
|
||
continueTopic: const [],
|
||
discovery: const [],
|
||
categories:
|
||
const <String, List<RadarRecommendation>>{},
|
||
);
|
||
|
||
_lastFeed = emptyFeed;
|
||
return emptyFeed;
|
||
}
|
||
|
||
await HiddenChannelsService.instance.ensureLoaded();
|
||
|
||
final dismissed =
|
||
await _readDismissed();
|
||
final reducedAuthors =
|
||
await _readReducedAuthors();
|
||
|
||
final fingerprint = _fingerprint(
|
||
history,
|
||
favorites,
|
||
dismissed,
|
||
reducedAuthors,
|
||
HiddenChannelsService.instance.revision.value,
|
||
);
|
||
|
||
final normalizedInterest =
|
||
_normalizeInterest(interest);
|
||
|
||
final cacheKey =
|
||
'${mode.name}|$normalizedInterest';
|
||
|
||
final cached = _cache[cacheKey];
|
||
|
||
if (!force &&
|
||
cached != null &&
|
||
cached.fingerprint == fingerprint) {
|
||
_lastFeed = cached.feed;
|
||
return cached.feed;
|
||
}
|
||
|
||
if (force) {
|
||
_shuffleCounter++;
|
||
}
|
||
|
||
final profile = _buildProfile(
|
||
history,
|
||
favorites,
|
||
);
|
||
|
||
final queries = _buildQueries(
|
||
profile,
|
||
mode,
|
||
interest: normalizedInterest,
|
||
);
|
||
|
||
final candidateById =
|
||
<String, VideoItem>{};
|
||
|
||
final results = await Future.wait(
|
||
queries.map(
|
||
(query) async {
|
||
try {
|
||
return await _youtube.searchVideos(query);
|
||
} catch (_) {
|
||
return const <VideoItem>[];
|
||
}
|
||
},
|
||
),
|
||
);
|
||
|
||
for (final list in results) {
|
||
for (final video in list) {
|
||
candidateById.putIfAbsent(
|
||
video.id,
|
||
() => video,
|
||
);
|
||
}
|
||
}
|
||
|
||
final seenVideoIds = <String>{
|
||
...history.map((video) => video.id),
|
||
...favorites.map((video) => video.id),
|
||
};
|
||
|
||
final now = DateTime.now();
|
||
|
||
final scoredCandidates =
|
||
candidateById.values
|
||
.where(
|
||
(video) =>
|
||
!seenVideoIds.contains(video.id) &&
|
||
!HiddenChannelsService.instance.isHiddenVideo(video) &&
|
||
!_isDismissed(
|
||
video.id,
|
||
dismissed,
|
||
now,
|
||
),
|
||
)
|
||
.map(
|
||
(video) => _score(
|
||
video: video,
|
||
profile: profile,
|
||
mode: mode,
|
||
reducedAuthors: reducedAuthors,
|
||
),
|
||
)
|
||
.where(
|
||
(item) => item.score >= 18,
|
||
)
|
||
.toList();
|
||
|
||
final matchingInterest =
|
||
normalizedInterest == 'Tous'
|
||
? scoredCandidates
|
||
: scoredCandidates
|
||
.where(
|
||
(item) =>
|
||
_classifyVideo(
|
||
item.video,
|
||
) ==
|
||
normalizedInterest,
|
||
)
|
||
.toList();
|
||
|
||
final candidates =
|
||
normalizedInterest != 'Tous' &&
|
||
matchingInterest.length >= 4
|
||
? matchingInterest
|
||
: scoredCandidates;
|
||
|
||
final random = Random(
|
||
DateTime.now().day +
|
||
mode.index * 7919 +
|
||
_shuffleCounter * 104729,
|
||
);
|
||
|
||
final jitterByVideo = <String, int>{
|
||
for (final item in candidates)
|
||
item.video.id: random.nextInt(5),
|
||
};
|
||
|
||
candidates.sort(
|
||
(a, b) {
|
||
final aJitter =
|
||
jitterByVideo[a.video.id] ?? 0;
|
||
final bJitter =
|
||
jitterByVideo[b.video.id] ?? 0;
|
||
|
||
return (b.score + bJitter)
|
||
.compareTo(a.score + aJitter);
|
||
},
|
||
);
|
||
|
||
final diversified =
|
||
_diversify(candidates);
|
||
|
||
final primary =
|
||
<RadarRecommendation>[];
|
||
final continueTopic =
|
||
<RadarRecommendation>[];
|
||
final discovery =
|
||
<RadarRecommendation>[];
|
||
|
||
final used = <String>{};
|
||
|
||
void takeInto(
|
||
List<RadarRecommendation> target,
|
||
Iterable<RadarRecommendation> source,
|
||
int limit,
|
||
) {
|
||
for (final item in source) {
|
||
if (target.length >= limit) {
|
||
return;
|
||
}
|
||
|
||
if (used.add(item.video.id)) {
|
||
target.add(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
takeInto(
|
||
primary,
|
||
diversified,
|
||
8,
|
||
);
|
||
|
||
takeInto(
|
||
continueTopic,
|
||
diversified.where(
|
||
(item) =>
|
||
item.lane ==
|
||
RadarLane.continueTopic,
|
||
),
|
||
10,
|
||
);
|
||
|
||
takeInto(
|
||
discovery,
|
||
diversified.where(
|
||
(item) =>
|
||
item.lane ==
|
||
RadarLane.discovery,
|
||
),
|
||
10,
|
||
);
|
||
|
||
// Si un lane est trop petit, on le complète sans dupliquer
|
||
// les cartes déjà utilisées.
|
||
if (continueTopic.length < 4) {
|
||
takeInto(
|
||
continueTopic,
|
||
diversified.where(
|
||
(item) =>
|
||
item.matchedTerms.isNotEmpty &&
|
||
!item.isNewAuthor,
|
||
),
|
||
8,
|
||
);
|
||
}
|
||
|
||
if (discovery.length < 4) {
|
||
takeInto(
|
||
discovery,
|
||
diversified.where(
|
||
(item) => item.isNewAuthor,
|
||
),
|
||
8,
|
||
);
|
||
}
|
||
|
||
final categories =
|
||
_buildCategories(diversified);
|
||
|
||
final feed = RadarFeed(
|
||
mode: mode,
|
||
generatedAt: now,
|
||
profileTopics:
|
||
profile.topTerms.take(6).toList(
|
||
growable: false,
|
||
),
|
||
primary:
|
||
List.unmodifiable(primary),
|
||
continueTopic:
|
||
List.unmodifiable(continueTopic),
|
||
discovery:
|
||
List.unmodifiable(discovery),
|
||
categories: categories,
|
||
);
|
||
|
||
_lastFeed = feed;
|
||
|
||
_cache[cacheKey] = _CachedRadar(
|
||
fingerprint: fingerprint,
|
||
feed: feed,
|
||
);
|
||
|
||
return feed;
|
||
}
|
||
|
||
Future<void> dismissVideo(
|
||
String videoId,
|
||
) async {
|
||
final dismissed =
|
||
await _readDismissed();
|
||
|
||
dismissed[videoId] =
|
||
DateTime.now().millisecondsSinceEpoch;
|
||
|
||
await _preferences.setString(
|
||
_dismissedKey,
|
||
jsonEncode(dismissed),
|
||
);
|
||
|
||
_cache.clear();
|
||
}
|
||
|
||
Future<void> reduceAuthor(
|
||
String author,
|
||
) async {
|
||
final normalized =
|
||
_normalizeAuthor(author);
|
||
|
||
if (normalized.isEmpty) {
|
||
return;
|
||
}
|
||
|
||
final authors =
|
||
await _readReducedAuthors();
|
||
|
||
authors[normalized] =
|
||
(authors[normalized] ?? 0) + 1;
|
||
|
||
await _preferences.setString(
|
||
_reducedAuthorsKey,
|
||
jsonEncode(authors),
|
||
);
|
||
|
||
_cache.clear();
|
||
}
|
||
|
||
Future<void> resetFeedback() async {
|
||
await _preferences.remove(
|
||
_dismissedKey,
|
||
);
|
||
await _preferences.remove(
|
||
_reducedAuthorsKey,
|
||
);
|
||
_cache.clear();
|
||
}
|
||
|
||
_RadarProfile _buildProfile(
|
||
List<VideoItem> history,
|
||
List<VideoItem> favorites,
|
||
) {
|
||
final topicWeights =
|
||
<String, double>{};
|
||
final favoriteWeights =
|
||
<String, double>{};
|
||
final authorWeights =
|
||
<String, double>{};
|
||
|
||
void addTerms(
|
||
VideoItem video,
|
||
double weight, {
|
||
Map<String, double>? target,
|
||
}) {
|
||
final destination =
|
||
target ?? topicWeights;
|
||
|
||
for (final term in _terms(
|
||
video.title,
|
||
)) {
|
||
destination.update(
|
||
term,
|
||
(value) => value + weight,
|
||
ifAbsent: () => weight,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (var index = 0;
|
||
index < history.length &&
|
||
index < 30;
|
||
index++) {
|
||
final recency =
|
||
max(0.25, 1.0 - index * 0.035);
|
||
|
||
addTerms(
|
||
history[index],
|
||
recency,
|
||
);
|
||
|
||
final author = _normalizeAuthor(
|
||
history[index].author,
|
||
);
|
||
|
||
if (author.isNotEmpty) {
|
||
authorWeights.update(
|
||
author,
|
||
(value) => value + recency,
|
||
ifAbsent: () => recency,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (var index = 0;
|
||
index < favorites.length &&
|
||
index < 20;
|
||
index++) {
|
||
final weight =
|
||
max(0.7, 1.35 - index * 0.025);
|
||
|
||
addTerms(
|
||
favorites[index],
|
||
weight,
|
||
);
|
||
|
||
addTerms(
|
||
favorites[index],
|
||
weight,
|
||
target: favoriteWeights,
|
||
);
|
||
|
||
final author = _normalizeAuthor(
|
||
favorites[index].author,
|
||
);
|
||
|
||
if (author.isNotEmpty) {
|
||
authorWeights.update(
|
||
author,
|
||
(value) => value + weight * 1.35,
|
||
ifAbsent: () => weight * 1.35,
|
||
);
|
||
}
|
||
}
|
||
|
||
final sortedTerms =
|
||
topicWeights.entries.toList()
|
||
..sort(
|
||
(a, b) =>
|
||
b.value.compareTo(a.value),
|
||
);
|
||
|
||
final recent = history.isNotEmpty
|
||
? history.first
|
||
: favorites.first;
|
||
|
||
final recentTerms =
|
||
_terms(recent.title).toSet();
|
||
|
||
return _RadarProfile(
|
||
topicWeights: topicWeights,
|
||
favoriteWeights: favoriteWeights,
|
||
authorWeights: authorWeights,
|
||
knownAuthors:
|
||
authorWeights.keys.toSet(),
|
||
recentAuthor:
|
||
_normalizeAuthor(recent.author),
|
||
recentTerms: recentTerms,
|
||
topTerms: sortedTerms
|
||
.map((entry) => entry.key)
|
||
.toList(growable: false),
|
||
history: history,
|
||
favorites: favorites,
|
||
);
|
||
}
|
||
|
||
List<String> _buildQueries(
|
||
_RadarProfile profile,
|
||
RadarSessionMode mode, {
|
||
String interest = 'Tous',
|
||
}) {
|
||
final queries = <String>[];
|
||
|
||
String applyMode(
|
||
String query,
|
||
) {
|
||
switch (mode) {
|
||
case RadarSessionMode.learn:
|
||
return '$query tutorial explained';
|
||
case RadarSessionMode.discovery:
|
||
return '$query découverte nouvelles chaînes';
|
||
case RadarSessionMode.relax:
|
||
return '$query relaxing';
|
||
case RadarSessionMode.short:
|
||
return '$query court';
|
||
case RadarSessionMode.long:
|
||
return '$query long format';
|
||
case RadarSessionMode.normal:
|
||
return query;
|
||
}
|
||
}
|
||
|
||
if (interest != 'Tous') {
|
||
final interestQuery =
|
||
_interestSearchQueries[interest];
|
||
|
||
if (interestQuery != null &&
|
||
interestQuery.isNotEmpty) {
|
||
queries.add(
|
||
applyMode(interestQuery),
|
||
);
|
||
}
|
||
}
|
||
|
||
final seeds = <VideoItem>[
|
||
...profile.history.take(3),
|
||
...profile.favorites.take(2),
|
||
];
|
||
|
||
for (final video in seeds) {
|
||
final words = _terms(video.title)
|
||
.take(4)
|
||
.toList();
|
||
|
||
if (words.isEmpty) {
|
||
continue;
|
||
}
|
||
|
||
var query = words.join(' ');
|
||
|
||
if (mode ==
|
||
RadarSessionMode.discovery &&
|
||
profile.topTerms.length >= 2) {
|
||
query =
|
||
'${profile.topTerms.take(2).join(' ')} $query';
|
||
}
|
||
|
||
queries.add(
|
||
applyMode(query),
|
||
);
|
||
|
||
if (queries.length >= 4) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (queries.length < 4 &&
|
||
profile.topTerms.isNotEmpty) {
|
||
queries.add(
|
||
applyMode(
|
||
profile.topTerms
|
||
.take(3)
|
||
.join(' '),
|
||
),
|
||
);
|
||
}
|
||
|
||
return queries
|
||
.where(
|
||
(query) => query.trim().isNotEmpty,
|
||
)
|
||
.toSet()
|
||
.take(4)
|
||
.toList(growable: false);
|
||
}
|
||
|
||
RadarRecommendation _score({
|
||
required VideoItem video,
|
||
required _RadarProfile profile,
|
||
required RadarSessionMode mode,
|
||
required Map<String, int>
|
||
reducedAuthors,
|
||
}) {
|
||
final terms =
|
||
_terms(video.title).toSet();
|
||
|
||
final matchedTerms =
|
||
terms
|
||
.where(
|
||
profile.topicWeights
|
||
.containsKey,
|
||
)
|
||
.toList()
|
||
..sort(
|
||
(a, b) =>
|
||
(profile.topicWeights[b] ?? 0)
|
||
.compareTo(
|
||
profile.topicWeights[a] ?? 0,
|
||
),
|
||
);
|
||
|
||
final topicRaw = matchedTerms.fold<double>(
|
||
0,
|
||
(sum, term) =>
|
||
sum +
|
||
(profile.topicWeights[term] ?? 0),
|
||
);
|
||
|
||
final maxTopicWeight =
|
||
profile.topicWeights.values.isEmpty
|
||
? 1.0
|
||
: profile.topicWeights.values
|
||
.reduce(max);
|
||
|
||
final topicPoints =
|
||
min(
|
||
35,
|
||
(topicRaw /
|
||
maxTopicWeight *
|
||
13)
|
||
.round(),
|
||
);
|
||
|
||
final author =
|
||
_normalizeAuthor(video.author);
|
||
|
||
final authorRaw =
|
||
profile.authorWeights[author] ?? 0;
|
||
|
||
final maxAuthorWeight =
|
||
profile.authorWeights.values.isEmpty
|
||
? 1.0
|
||
: profile.authorWeights.values
|
||
.reduce(max);
|
||
|
||
final authorPoints =
|
||
min(
|
||
20,
|
||
(authorRaw /
|
||
maxAuthorWeight *
|
||
20)
|
||
.round(),
|
||
);
|
||
|
||
final favoriteRaw = terms.fold<double>(
|
||
0,
|
||
(sum, term) =>
|
||
sum +
|
||
(profile.favoriteWeights[term] ??
|
||
0),
|
||
);
|
||
|
||
final maxFavoriteWeight =
|
||
profile.favoriteWeights.values.isEmpty
|
||
? 1.0
|
||
: profile.favoriteWeights.values
|
||
.reduce(max);
|
||
|
||
final favoritePoints =
|
||
min(
|
||
15,
|
||
(favoriteRaw /
|
||
maxFavoriteWeight *
|
||
10)
|
||
.round(),
|
||
);
|
||
|
||
final isNewAuthor =
|
||
author.isNotEmpty &&
|
||
!profile.knownAuthors.contains(author);
|
||
|
||
final discoveryPoints =
|
||
isNewAuthor ? 8 : 0;
|
||
|
||
final durationPoints =
|
||
_durationScore(
|
||
video.duration,
|
||
mode,
|
||
);
|
||
|
||
final modePoints =
|
||
_modeTextScore(
|
||
terms,
|
||
mode,
|
||
);
|
||
|
||
final currentTopicPoints =
|
||
terms
|
||
.intersection(
|
||
profile.recentTerms,
|
||
)
|
||
.isNotEmpty
|
||
? 8
|
||
: 0;
|
||
|
||
final reducedPenalty =
|
||
min(
|
||
30,
|
||
(reducedAuthors[author] ?? 0) *
|
||
12,
|
||
);
|
||
|
||
var total = 12 +
|
||
topicPoints +
|
||
authorPoints +
|
||
favoritePoints +
|
||
discoveryPoints +
|
||
durationPoints +
|
||
modePoints +
|
||
currentTopicPoints -
|
||
reducedPenalty;
|
||
|
||
if (mode ==
|
||
RadarSessionMode.discovery &&
|
||
isNewAuthor) {
|
||
total += 10;
|
||
}
|
||
|
||
total = total.clamp(1, 99).toInt();
|
||
|
||
final reasons = <String>[];
|
||
|
||
if (currentTopicPoints > 0) {
|
||
reasons.add(
|
||
'Proche de ce que tu regardes maintenant',
|
||
);
|
||
}
|
||
|
||
if (matchedTerms.isNotEmpty) {
|
||
reasons.add(
|
||
'Thèmes communs : '
|
||
'${matchedTerms.take(3).join(', ')}',
|
||
);
|
||
}
|
||
|
||
if (authorPoints >= 8) {
|
||
reasons.add(
|
||
'Chaîne proche de tes habitudes',
|
||
);
|
||
}
|
||
|
||
if (favoritePoints >= 5) {
|
||
reasons.add(
|
||
'Sujet proche de tes favoris',
|
||
);
|
||
}
|
||
|
||
if (isNewAuthor) {
|
||
reasons.add(
|
||
'Nouvelle chaîne pour toi',
|
||
);
|
||
}
|
||
|
||
if (durationPoints >= 7) {
|
||
reasons.add(
|
||
_durationReason(mode),
|
||
);
|
||
}
|
||
|
||
if (reducedPenalty > 0) {
|
||
reasons.add(
|
||
'Score réduit selon ton feedback précédent',
|
||
);
|
||
}
|
||
|
||
if (reasons.isEmpty) {
|
||
reasons.add(
|
||
'Proposition issue du mélange de ton Radar',
|
||
);
|
||
}
|
||
|
||
RadarLane lane =
|
||
RadarLane.forYou;
|
||
|
||
if (isNewAuthor &&
|
||
matchedTerms.isNotEmpty) {
|
||
lane = RadarLane.discovery;
|
||
} else if (currentTopicPoints > 0 ||
|
||
author ==
|
||
profile.recentAuthor) {
|
||
lane =
|
||
RadarLane.continueTopic;
|
||
}
|
||
|
||
return RadarRecommendation(
|
||
video: video,
|
||
score: total,
|
||
reasons:
|
||
List.unmodifiable(reasons),
|
||
breakdown: Map.unmodifiable(
|
||
<String, int>{
|
||
'Sujet': topicPoints,
|
||
'Habitudes': authorPoints,
|
||
'Favoris': favoritePoints,
|
||
'Découverte':
|
||
discoveryPoints,
|
||
'Session':
|
||
durationPoints +
|
||
modePoints +
|
||
currentTopicPoints,
|
||
if (reducedPenalty > 0)
|
||
'Feedback': -reducedPenalty,
|
||
},
|
||
),
|
||
lane: lane,
|
||
matchedTerms:
|
||
matchedTerms.take(5).toList(
|
||
growable: false,
|
||
),
|
||
isNewAuthor: isNewAuthor,
|
||
);
|
||
}
|
||
|
||
int _durationScore(
|
||
Duration? duration,
|
||
RadarSessionMode mode,
|
||
) {
|
||
if (duration == null) {
|
||
return 0;
|
||
}
|
||
|
||
final minutes =
|
||
duration.inMinutes;
|
||
|
||
switch (mode) {
|
||
case RadarSessionMode.short:
|
||
if (minutes <= 8) {
|
||
return 15;
|
||
}
|
||
if (minutes <= 15) {
|
||
return 9;
|
||
}
|
||
return -8;
|
||
|
||
case RadarSessionMode.long:
|
||
if (minutes >= 30) {
|
||
return 15;
|
||
}
|
||
if (minutes >= 20) {
|
||
return 9;
|
||
}
|
||
return -5;
|
||
|
||
case RadarSessionMode.relax:
|
||
if (minutes >= 8 &&
|
||
minutes <= 45) {
|
||
return 7;
|
||
}
|
||
return 2;
|
||
|
||
case RadarSessionMode.learn:
|
||
if (minutes >= 6 &&
|
||
minutes <= 60) {
|
||
return 5;
|
||
}
|
||
return 1;
|
||
|
||
case RadarSessionMode.discovery:
|
||
case RadarSessionMode.normal:
|
||
return 3;
|
||
}
|
||
}
|
||
|
||
int _modeTextScore(
|
||
Set<String> terms,
|
||
RadarSessionMode mode,
|
||
) {
|
||
switch (mode) {
|
||
case RadarSessionMode.learn:
|
||
const learningTerms = <String>{
|
||
'tutorial',
|
||
'guide',
|
||
'explained',
|
||
'learn',
|
||
'course',
|
||
'cours',
|
||
'tuto',
|
||
'شرح',
|
||
'تعلم',
|
||
};
|
||
|
||
return terms
|
||
.intersection(
|
||
learningTerms,
|
||
)
|
||
.isNotEmpty
|
||
? 12
|
||
: 0;
|
||
|
||
case RadarSessionMode.discovery:
|
||
return 2;
|
||
|
||
case RadarSessionMode.relax:
|
||
const relaxTerms = <String>{
|
||
'relax',
|
||
'relaxing',
|
||
'chill',
|
||
'calm',
|
||
'music',
|
||
'musique',
|
||
'ambient',
|
||
};
|
||
|
||
return terms
|
||
.intersection(
|
||
relaxTerms,
|
||
)
|
||
.isNotEmpty
|
||
? 10
|
||
: 0;
|
||
|
||
case RadarSessionMode.short:
|
||
case RadarSessionMode.long:
|
||
case RadarSessionMode.normal:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
String _durationReason(
|
||
RadarSessionMode mode,
|
||
) {
|
||
switch (mode) {
|
||
case RadarSessionMode.short:
|
||
return 'Durée adaptée au mode Courtes vidéos';
|
||
case RadarSessionMode.long:
|
||
return 'Long format adapté à cette session';
|
||
case RadarSessionMode.learn:
|
||
return 'Format adapté à une session d’apprentissage';
|
||
case RadarSessionMode.relax:
|
||
return 'Durée adaptée au mode Détente';
|
||
case RadarSessionMode.discovery:
|
||
case RadarSessionMode.normal:
|
||
return 'Durée adaptée à cette session';
|
||
}
|
||
}
|
||
|
||
Map<String, List<RadarRecommendation>>
|
||
_buildCategories(
|
||
List<RadarRecommendation> recommendations,
|
||
) {
|
||
final buckets =
|
||
<String, List<RadarRecommendation>>{};
|
||
|
||
// Use a larger candidate pool than the three headline lanes so category
|
||
// browsing feels like a real second way to explore the Radar.
|
||
for (final item
|
||
in recommendations.take(60)) {
|
||
final category =
|
||
_classifyVideo(item.video);
|
||
|
||
buckets.putIfAbsent(
|
||
category,
|
||
() => <RadarRecommendation>[],
|
||
);
|
||
|
||
final list = buckets[category]!;
|
||
|
||
if (list.length < 12) {
|
||
list.add(item);
|
||
}
|
||
}
|
||
|
||
final entries =
|
||
buckets.entries
|
||
.where(
|
||
(entry) =>
|
||
entry.value.isNotEmpty,
|
||
)
|
||
.toList()
|
||
..sort(
|
||
(a, b) {
|
||
final aScore = a.value.fold<int>(
|
||
0,
|
||
(sum, item) =>
|
||
sum + item.score,
|
||
);
|
||
final bScore = b.value.fold<int>(
|
||
0,
|
||
(sum, item) =>
|
||
sum + item.score,
|
||
);
|
||
|
||
final countCompare =
|
||
b.value.length.compareTo(
|
||
a.value.length,
|
||
);
|
||
|
||
if (countCompare != 0) {
|
||
return countCompare;
|
||
}
|
||
|
||
return bScore.compareTo(aScore);
|
||
},
|
||
);
|
||
|
||
return Map.unmodifiable(
|
||
<String, List<RadarRecommendation>>{
|
||
for (final entry in entries)
|
||
entry.key:
|
||
List.unmodifiable(entry.value),
|
||
},
|
||
);
|
||
}
|
||
|
||
String _classifyVideo(
|
||
VideoItem video,
|
||
) {
|
||
final haystack =
|
||
'${video.title} '
|
||
'${video.author} '
|
||
'${video.description}'
|
||
.toLowerCase();
|
||
|
||
var bestCategory = 'Divers';
|
||
var bestScore = 0;
|
||
|
||
for (final entry
|
||
in _categoryKeywords.entries) {
|
||
var score = 0;
|
||
|
||
for (final keyword in entry.value) {
|
||
if (haystack.contains(keyword)) {
|
||
// Longer phrases are more specific and receive a little more
|
||
// weight than generic single words.
|
||
score += keyword.contains(' ')
|
||
? 3
|
||
: 2;
|
||
}
|
||
}
|
||
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestCategory = entry.key;
|
||
}
|
||
}
|
||
|
||
return bestCategory;
|
||
}
|
||
|
||
static const Map<String, List<String>>
|
||
_categoryKeywords =
|
||
<String, List<String>>{
|
||
'Jeux vidéo': <String>[
|
||
'jeu vidéo',
|
||
'jeux vidéo',
|
||
'gaming',
|
||
'gameplay',
|
||
'gamer',
|
||
'fortnite',
|
||
'minecraft',
|
||
'roblox',
|
||
'gta',
|
||
'call of duty',
|
||
'playstation',
|
||
'ps5',
|
||
'xbox',
|
||
'nintendo',
|
||
'switch',
|
||
'steam',
|
||
'pokemon',
|
||
'elden ring',
|
||
],
|
||
'Culture & histoire': <String>[
|
||
'culture',
|
||
'histoire',
|
||
'history',
|
||
'historique',
|
||
'documentaire',
|
||
'documentary',
|
||
'empire',
|
||
'civilisation',
|
||
'civilization',
|
||
'archéologie',
|
||
'archeology',
|
||
'mythologie',
|
||
'mythology',
|
||
'art ',
|
||
'littérature',
|
||
'literature',
|
||
'philosophie',
|
||
],
|
||
'OVNI & mystères': <String>[
|
||
'ovni',
|
||
'ufo',
|
||
'uap',
|
||
'alien',
|
||
'extraterrestre',
|
||
'extraterrestrial',
|
||
'paranormal',
|
||
'mystère',
|
||
'mystery',
|
||
'mysterious',
|
||
'inexpliqué',
|
||
'unexplained',
|
||
'roswell',
|
||
'area 51',
|
||
'zone 51',
|
||
'crop circle',
|
||
'conspiracy',
|
||
'complot',
|
||
],
|
||
'Technologie & IA': <String>[
|
||
'technologie',
|
||
'technology',
|
||
'tech ',
|
||
'intelligence artificielle',
|
||
'artificial intelligence',
|
||
'chatgpt',
|
||
'openai',
|
||
'android',
|
||
'windows',
|
||
'linux',
|
||
'xiaomi',
|
||
'samsung',
|
||
'iphone',
|
||
'apple',
|
||
'gpu',
|
||
'nvidia',
|
||
'amd',
|
||
'ordinateur',
|
||
'smartphone',
|
||
'robot',
|
||
],
|
||
'Science & espace': <String>[
|
||
'science',
|
||
'espace',
|
||
'space',
|
||
'nasa',
|
||
'spacex',
|
||
'astronomie',
|
||
'astronomy',
|
||
'physique',
|
||
'physics',
|
||
'quantique',
|
||
'quantum',
|
||
'mars ',
|
||
'lune',
|
||
'moon',
|
||
'univers',
|
||
'universe',
|
||
'telescope',
|
||
'télescope',
|
||
],
|
||
'Auto & transport': <String>[
|
||
'voiture',
|
||
'automobile',
|
||
'car ',
|
||
'cars ',
|
||
'tesla',
|
||
'bmw',
|
||
'mercedes',
|
||
'audi',
|
||
'porsche',
|
||
'moto',
|
||
'motorcycle',
|
||
'train',
|
||
'avion',
|
||
'airplane',
|
||
'aviation',
|
||
],
|
||
'Musique': <String>[
|
||
'music',
|
||
'musique',
|
||
'song',
|
||
'chanson',
|
||
'album',
|
||
'concert',
|
||
'live music',
|
||
'piano',
|
||
'guitar',
|
||
'guitare',
|
||
'rap ',
|
||
'hip hop',
|
||
'jazz',
|
||
],
|
||
'Politique': <String>[
|
||
'politique',
|
||
'politics',
|
||
'political',
|
||
'gouvernement',
|
||
'government',
|
||
'parlement',
|
||
'parliament',
|
||
'président',
|
||
'president',
|
||
'élection',
|
||
'election',
|
||
'élections',
|
||
'elections',
|
||
'géopolitique',
|
||
'geopolitics',
|
||
'diplomatie',
|
||
'diplomacy',
|
||
'ministre',
|
||
'minister',
|
||
'الانتخابات',
|
||
'سياسة',
|
||
'السياسة',
|
||
'حكومة',
|
||
'الحكومة',
|
||
],
|
||
'Finance': <String>[
|
||
'finance',
|
||
'financial',
|
||
'investissement',
|
||
'investment',
|
||
'investing',
|
||
'investisseur',
|
||
'investor',
|
||
'bourse',
|
||
'stock market',
|
||
'stocks',
|
||
'actions',
|
||
'etf',
|
||
'ipo',
|
||
'dividende',
|
||
'dividend',
|
||
'trading',
|
||
'trader',
|
||
'forex',
|
||
'crypto',
|
||
'bitcoin',
|
||
'ethereum',
|
||
'banque',
|
||
'banking',
|
||
'épargne',
|
||
'epargne',
|
||
'portefeuille',
|
||
'portfolio',
|
||
'nasdaq',
|
||
's&p 500',
|
||
'sp500',
|
||
'cac 40',
|
||
'masi',
|
||
'bvc',
|
||
'استثمار',
|
||
'الاستثمار',
|
||
'البورصة',
|
||
'الأسهم',
|
||
'تداول',
|
||
'التداول',
|
||
'بيتكوين',
|
||
],
|
||
'Société & actualité': <String>[
|
||
'actualité',
|
||
'actualités',
|
||
'news',
|
||
'société',
|
||
'society',
|
||
'économie',
|
||
'economy',
|
||
'reportage',
|
||
'interview',
|
||
'social',
|
||
'actualité internationale',
|
||
],
|
||
'Tutoriels & apprentissage': <String>[
|
||
'tutorial',
|
||
'tutoriel',
|
||
'tuto ',
|
||
'guide',
|
||
'cours',
|
||
'course',
|
||
'learn',
|
||
'apprendre',
|
||
'expliqué',
|
||
'explained',
|
||
'how to',
|
||
'comment ',
|
||
'formation',
|
||
],
|
||
};
|
||
|
||
String _normalizeInterest(
|
||
String value,
|
||
) {
|
||
final trimmed =
|
||
value.trim();
|
||
|
||
return _interestSearchQueries
|
||
.containsKey(trimmed)
|
||
? trimmed
|
||
: 'Tous';
|
||
}
|
||
|
||
static const Map<String, String>
|
||
_interestSearchQueries =
|
||
<String, String>{
|
||
'Tous': '',
|
||
'Politique':
|
||
'politique géopolitique actualité gouvernement élections',
|
||
'Finance':
|
||
'finance investissement bourse ETF marchés financiers',
|
||
'Technologie & IA':
|
||
'technologie intelligence artificielle informatique',
|
||
'Jeux vidéo':
|
||
'jeux vidéo gaming gameplay nouveautés',
|
||
'Culture & histoire':
|
||
'culture histoire documentaire civilisation',
|
||
'Science & espace':
|
||
'science espace astronomie univers',
|
||
'OVNI & mystères':
|
||
'ovni uap mystères inexpliqué',
|
||
'Auto & transport':
|
||
'automobile voiture transport technologie',
|
||
'Musique':
|
||
'musique artistes concert nouveautés',
|
||
'Société & actualité':
|
||
'société actualité reportage économie',
|
||
'Tutoriels & apprentissage':
|
||
'tutoriel apprendre guide formation',
|
||
};
|
||
|
||
List<RadarRecommendation> _diversify(
|
||
List<RadarRecommendation> source,
|
||
) {
|
||
final result =
|
||
<RadarRecommendation>[];
|
||
final authorCounts =
|
||
<String, int>{};
|
||
|
||
for (final item in source) {
|
||
final author =
|
||
_normalizeAuthor(
|
||
item.video.author,
|
||
);
|
||
|
||
final count =
|
||
authorCounts[author] ?? 0;
|
||
|
||
if (count >= 2) {
|
||
continue;
|
||
}
|
||
|
||
result.add(item);
|
||
|
||
if (author.isNotEmpty) {
|
||
authorCounts[author] =
|
||
count + 1;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
List<String> _terms(String value) {
|
||
final normalized = value
|
||
.toLowerCase()
|
||
.replaceAll(
|
||
RegExp(
|
||
r"[^a-z0-9à-ÿ\u0600-\u06ff]+",
|
||
caseSensitive: false,
|
||
),
|
||
' ',
|
||
);
|
||
|
||
return normalized
|
||
.split(RegExp(r'\s+'))
|
||
.map((word) => word.trim())
|
||
.where(
|
||
(word) =>
|
||
word.length >= 3 &&
|
||
!_stopWords.contains(word),
|
||
)
|
||
.toList(growable: false);
|
||
}
|
||
|
||
String _normalizeAuthor(
|
||
String value,
|
||
) {
|
||
return value
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
Future<Map<String, int>>
|
||
_readReducedAuthors() async {
|
||
final raw =
|
||
await _preferences.getString(
|
||
_reducedAuthorsKey,
|
||
);
|
||
|
||
if (raw == null ||
|
||
raw.isEmpty) {
|
||
return <String, int>{};
|
||
}
|
||
|
||
try {
|
||
final decoded =
|
||
jsonDecode(raw);
|
||
|
||
if (decoded is! Map) {
|
||
return <String, int>{};
|
||
}
|
||
|
||
final result =
|
||
<String, int>{};
|
||
|
||
for (final entry
|
||
in decoded.entries) {
|
||
if (entry.key is String &&
|
||
entry.value is num) {
|
||
result[entry.key as String] =
|
||
(entry.value as num).toInt();
|
||
}
|
||
}
|
||
|
||
return result;
|
||
} catch (_) {
|
||
return <String, int>{};
|
||
}
|
||
}
|
||
|
||
Future<Map<String, int>>
|
||
_readDismissed() async {
|
||
final raw =
|
||
await _preferences.getString(
|
||
_dismissedKey,
|
||
);
|
||
|
||
if (raw == null ||
|
||
raw.isEmpty) {
|
||
return <String, int>{};
|
||
}
|
||
|
||
try {
|
||
final decoded =
|
||
jsonDecode(raw);
|
||
|
||
if (decoded is! Map) {
|
||
return <String, int>{};
|
||
}
|
||
|
||
final result =
|
||
<String, int>{};
|
||
|
||
for (final entry
|
||
in decoded.entries) {
|
||
if (entry.key is String &&
|
||
entry.value is num) {
|
||
result[entry.key as String] =
|
||
(entry.value as num).toInt();
|
||
}
|
||
}
|
||
|
||
final cutoff = DateTime.now()
|
||
.subtract(_dismissDuration)
|
||
.millisecondsSinceEpoch;
|
||
|
||
result.removeWhere(
|
||
(key, value) =>
|
||
value < cutoff,
|
||
);
|
||
|
||
return result;
|
||
} catch (_) {
|
||
return <String, int>{};
|
||
}
|
||
}
|
||
|
||
bool _isDismissed(
|
||
String videoId,
|
||
Map<String, int> dismissed,
|
||
DateTime now,
|
||
) {
|
||
final value =
|
||
dismissed[videoId];
|
||
|
||
if (value == null) {
|
||
return false;
|
||
}
|
||
|
||
final dismissedAt =
|
||
DateTime.fromMillisecondsSinceEpoch(
|
||
value,
|
||
);
|
||
|
||
return now.difference(dismissedAt) <=
|
||
_dismissDuration;
|
||
}
|
||
|
||
String _fingerprint(
|
||
List<VideoItem> history,
|
||
List<VideoItem> favorites,
|
||
Map<String, int> dismissed,
|
||
Map<String, int> reducedAuthors,
|
||
int hiddenRevision,
|
||
) {
|
||
return <String>[
|
||
...history
|
||
.take(20)
|
||
.map((video) => video.id),
|
||
'|',
|
||
...favorites
|
||
.take(20)
|
||
.map((video) => video.id),
|
||
'|',
|
||
...dismissed.keys,
|
||
'|',
|
||
...reducedAuthors.entries.map(
|
||
(entry) =>
|
||
'${entry.key}:${entry.value}',
|
||
),
|
||
'|hidden:$hiddenRevision',
|
||
].join(';');
|
||
}
|
||
|
||
static const Set<String> _stopWords =
|
||
<String>{
|
||
'the',
|
||
'and',
|
||
'for',
|
||
'with',
|
||
'from',
|
||
'this',
|
||
'that',
|
||
'you',
|
||
'your',
|
||
'are',
|
||
'how',
|
||
'why',
|
||
'what',
|
||
'into',
|
||
'une',
|
||
'des',
|
||
'les',
|
||
'pour',
|
||
'avec',
|
||
'dans',
|
||
'sur',
|
||
'est',
|
||
'que',
|
||
'qui',
|
||
'pas',
|
||
'plus',
|
||
'tout',
|
||
'tous',
|
||
'cette',
|
||
'ces',
|
||
'aux',
|
||
'du',
|
||
'de',
|
||
'la',
|
||
'le',
|
||
'un',
|
||
'en',
|
||
'der',
|
||
'die',
|
||
'das',
|
||
'und',
|
||
'mit',
|
||
'für',
|
||
'von',
|
||
'ist',
|
||
'ein',
|
||
'eine',
|
||
'wie',
|
||
'den',
|
||
'dem',
|
||
'auf',
|
||
'los',
|
||
'las',
|
||
'una',
|
||
'uno',
|
||
'con',
|
||
'para',
|
||
'por',
|
||
'como',
|
||
'del',
|
||
'este',
|
||
'esta',
|
||
'هذا',
|
||
'هذه',
|
||
'على',
|
||
'من',
|
||
'في',
|
||
'الى',
|
||
'إلى',
|
||
'كيف',
|
||
'مع',
|
||
'عن',
|
||
'كل',
|
||
'official',
|
||
'video',
|
||
'youtube',
|
||
};
|
||
}
|
||
|
||
class _RadarProfile {
|
||
const _RadarProfile({
|
||
required this.topicWeights,
|
||
required this.favoriteWeights,
|
||
required this.authorWeights,
|
||
required this.knownAuthors,
|
||
required this.recentAuthor,
|
||
required this.recentTerms,
|
||
required this.topTerms,
|
||
required this.history,
|
||
required this.favorites,
|
||
});
|
||
|
||
final Map<String, double> topicWeights;
|
||
final Map<String, double> favoriteWeights;
|
||
final Map<String, double> authorWeights;
|
||
final Set<String> knownAuthors;
|
||
final String recentAuthor;
|
||
final Set<String> recentTerms;
|
||
final List<String> topTerms;
|
||
final List<VideoItem> history;
|
||
final List<VideoItem> favorites;
|
||
}
|
||
|
||
class _CachedRadar {
|
||
const _CachedRadar({
|
||
required this.fingerprint,
|
||
required this.feed,
|
||
});
|
||
|
||
final String fingerprint;
|
||
final RadarFeed feed;
|
||
}
|