Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'preferred_language_service.dart';
class AppSettingsService {
AppSettingsService._();
static final AppSettingsService instance = AppSettingsService._();
static const String _defaultResolutionKey =
'youtube_personal.settings.default_resolution.v1';
static const String _preferredLanguageKey =
'youtube_personal.settings.preferred_language.v1';
static const String _youngAudienceFilterKey =
'youtube_personal.settings.young_audience_filter.v1';
static const String _pauseScreenSleepMinutesKey =
'youtube_personal.settings.pause_screen_sleep_minutes.v1';
static const String _appearanceModeKey =
'youtube_personal.settings.appearance_mode.v1';
static const int historicalDefaultResolution = 1080;
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
final ValueNotifier<int> defaultResolution =
ValueNotifier<int>(historicalDefaultResolution);
final ValueNotifier<String> preferredLanguage =
ValueNotifier<String>('auto');
final ValueNotifier<int> youngAudienceFilterLevel =
ValueNotifier<int>(0);
final ValueNotifier<int> pauseScreenSleepMinutes =
ValueNotifier<int>(5);
final ValueNotifier<String> appearanceMode =
ValueNotifier<String>('light');
Future<void> initialize() async {
final storedResolution = await _preferences.getInt(_defaultResolutionKey);
final storedLanguage = await _preferences.getString(_preferredLanguageKey);
final storedYoungAudienceFilter =
await _preferences.getInt(_youngAudienceFilterKey);
final storedPauseScreenSleepMinutes =
await _preferences.getInt(_pauseScreenSleepMinutesKey);
final storedAppearanceMode =
await _preferences.getString(_appearanceModeKey);
if (storedResolution != null &&
supportedResolutions.contains(storedResolution)) {
defaultResolution.value = storedResolution;
}
if (storedLanguage != null &&
PreferredLanguageService.isSupported(storedLanguage)) {
preferredLanguage.value = storedLanguage;
}
if (storedYoungAudienceFilter != null &&
supportedYoungAudienceFilterLevels
.contains(storedYoungAudienceFilter)) {
youngAudienceFilterLevel.value = storedYoungAudienceFilter;
}
if (storedPauseScreenSleepMinutes != null &&
supportedPauseScreenSleepMinutes
.contains(storedPauseScreenSleepMinutes)) {
pauseScreenSleepMinutes.value = storedPauseScreenSleepMinutes;
}
if (storedAppearanceMode != null &&
supportedAppearanceModes.contains(storedAppearanceMode)) {
appearanceMode.value = storedAppearanceMode;
}
}
Future<void> setAppearanceMode(String mode) async {
if (!supportedAppearanceModes.contains(mode)) {
return;
}
if (appearanceMode.value == mode) {
return;
}
appearanceMode.value = mode;
await _preferences.setString(_appearanceModeKey, mode);
}
Future<void> setDefaultResolution(int resolution) async {
if (!supportedResolutions.contains(resolution)) {
return;
}
if (defaultResolution.value == resolution) {
return;
}
defaultResolution.value = resolution;
await _preferences.setInt(_defaultResolutionKey, resolution);
}
Future<void> setPreferredLanguage(String languageCode) async {
if (!PreferredLanguageService.isSupported(languageCode)) {
return;
}
if (preferredLanguage.value == languageCode) {
return;
}
preferredLanguage.value = languageCode;
await _preferences.setString(_preferredLanguageKey, languageCode);
}
Future<void> setPauseScreenSleepMinutes(int minutes) async {
if (!supportedPauseScreenSleepMinutes.contains(minutes)) {
return;
}
if (pauseScreenSleepMinutes.value == minutes) {
return;
}
pauseScreenSleepMinutes.value = minutes;
await _preferences.setInt(_pauseScreenSleepMinutesKey, minutes);
}
Future<void> setYoungAudienceFilterLevel(int level) async {
if (!supportedYoungAudienceFilterLevels.contains(level)) {
return;
}
if (youngAudienceFilterLevel.value == level) {
return;
}
youngAudienceFilterLevel.value = level;
await _preferences.setInt(_youngAudienceFilterKey, level);
}
static const List<String> supportedAppearanceModes = <String>[
'auto',
'dark',
'light',
'system',
];
static String appearanceModeLabel(String mode) {
return switch (mode) {
'dark' => 'Sombre',
'light' => 'Clair',
'auto' => 'Auto (jour/nuit)',
_ => 'Système',
};
}
static const List<int> supportedPauseScreenSleepMinutes = <int>[
2,
5,
10,
0,
];
static String pauseScreenSleepLabel(int minutes) {
return switch (minutes) {
0 => 'Jamais',
2 => '2 minutes',
10 => '10 minutes',
_ => '5 minutes',
};
}
static const List<int> supportedYoungAudienceFilterLevels = <int>[
0,
1,
2,
];
static String youngAudienceFilterLabel(int level) {
return switch (level) {
1 => 'Modéré',
2 => 'Strict',
_ => 'Désactivé',
};
}
static const List<int> supportedResolutions = <int>[
0,
360,
720,
1080,
1440,
2160,
];
static String resolutionLabel(int resolution) {
return switch (resolution) {
0 => 'Auto',
2160 => '2160p (4K)',
_ => '${resolution}p',
};
}
}
@@ -0,0 +1,755 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import '../models/video_item.dart';
import 'library_service.dart';
import 'sponsor_block_service.dart';
class BackgroundPlaybackSnapshot {
const BackgroundPlaybackSnapshot({
required this.position,
required this.playing,
required this.rate,
});
final Duration position;
final bool playing;
final double rate;
}
class BackgroundPlaybackService {
BackgroundPlaybackService._();
static final BackgroundPlaybackService instance =
BackgroundPlaybackService._();
MediaKitAudioHandler? _handler;
bool get isAvailable => Platform.isAndroid && _handler != null;
Future<void> initialize() async {
if (!Platform.isAndroid || _handler != null) {
return;
}
MediaKitAudioHandler? created;
await AudioService.init(
builder: () {
created = MediaKitAudioHandler();
return created!;
},
config: const AudioServiceConfig(
androidNotificationChannelId:
'com.studioweb.youtube_personal.playback',
androidNotificationChannelName:
'Lecture YouTube Personal',
androidNotificationChannelDescription:
'Contrôles de lecture en arrière-plan',
androidNotificationOngoing: false,
androidStopForegroundOnPause: false,
rewindInterval: Duration(seconds: 10),
fastForwardInterval: Duration(seconds: 10),
),
);
_handler = created;
}
Future<void> attachPlayer(
Player player,
VideoItem video,
) async {
final handler = _handler;
if (handler == null) {
return;
}
await handler.attachForegroundPlayer(
player,
video,
);
}
Future<void> enterBackgroundAudio({
required String url,
required VideoItem video,
required Duration position,
required bool shouldPlay,
required double rate,
}) async {
await _handler?.enterBackgroundAudio(
url: url,
video: video,
position: position,
shouldPlay: shouldPlay,
rate: rate,
);
}
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
required Player foregroundPlayer,
required VideoItem video,
}) async {
return _handler?.exitBackgroundAudio(
foregroundPlayer: foregroundPlayer,
video: video,
);
}
Future<void> detachPlayer() async {
await _handler?.detachPlayer();
}
}
class MediaKitAudioHandler extends BaseAudioHandler
with SeekHandler {
Player? _foregroundPlayer;
Player? _backgroundPlayer;
Player? _activePlayer;
VideoItem? _video;
final List<StreamSubscription<dynamic>> _subscriptions =
<StreamSubscription<dynamic>>[];
bool _backgroundMode = false;
bool _playing = false;
bool _buffering = false;
bool _completed = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
double _speed = 1.0;
Duration _lastPersistedPosition = Duration.zero;
List<SponsorSegment> _backgroundSponsorSegments =
const <SponsorSegment>[];
bool _backgroundSponsorSeekInProgress = false;
String? _backgroundSponsorActiveId;
Future<void> attachForegroundPlayer(
Player player,
VideoItem video,
) async {
_foregroundPlayer = player;
_video = video;
if (_backgroundMode) {
return;
}
await _bindPlayer(
player,
video,
);
}
Future<void> enterBackgroundAudio({
required String url,
required VideoItem video,
required Duration position,
required bool shouldPlay,
required double rate,
}) async {
if (_backgroundMode) {
return;
}
debugPrint(
'[BACKGROUND-AUDIO] switching to dedicated player '
'at ${position.inMilliseconds} ms '
'(shouldPlay=$shouldPlay rate=$rate)',
);
_backgroundMode = true;
_video = video;
unawaited(_prepareBackgroundSponsors(video.id));
await _cancelSubscriptions();
// Garde la MediaSession/foreground service en état actif pendant
// l'ouverture du nouveau Player afin d'éviter qu'Android arrête
// le service entre les deux moteurs.
_playing = shouldPlay;
_buffering = true;
_completed = false;
_position = position;
_duration = video.duration ?? Duration.zero;
_speed = rate;
_setMediaItem(video);
_broadcastState();
final backgroundPlayer = Player(
configuration: const PlayerConfiguration(
vo: 'null',
title: 'YouTube Personal Background',
),
);
_backgroundPlayer = backgroundPlayer;
_activePlayer = backgroundPlayer;
try {
// Ne pas lancer immédiatement : on désactive d'abord explicitement
// la piste vidéo pour que ce Player ne dépende d'aucune Surface Android.
await backgroundPlayer.open(
Media(url),
play: false,
);
await backgroundPlayer.setVideoTrack(
VideoTrack.no(),
);
// Sur certains appareils, open() retourne avant que la timeline du
// média soit réellement prête. Un seek envoyé immédiatement peut alors
// être ignoré et laisser position=0. On attend une durée exploitable.
await _waitUntilMediaReady(
backgroundPlayer,
wantedPosition: position,
fallbackDuration: video.duration,
);
if (position > Duration.zero) {
await _seekAndConfirm(
backgroundPlayer,
position,
);
}
await backgroundPlayer.setRate(rate);
if (shouldPlay) {
await backgroundPlayer.play();
// Confirme aussi que le nouveau Player est réellement passé en
// lecture avant de rendre la main au code écran-verrouillé.
await _waitUntilPlaying(backgroundPlayer);
}
await _bindPlayer(
backgroundPlayer,
video,
);
debugPrint(
'[BACKGROUND-AUDIO] dedicated player ready '
'at ${backgroundPlayer.state.position.inMilliseconds} ms '
'playing=${backgroundPlayer.state.playing}',
);
} catch (error) {
debugPrint(
'[BACKGROUND-AUDIO] dedicated player failed: $error',
);
_backgroundMode = false;
_backgroundPlayer = null;
await backgroundPlayer.dispose();
final foreground = _foregroundPlayer;
if (foreground != null) {
await _bindPlayer(
foreground,
video,
);
} else {
_activePlayer = null;
}
rethrow;
}
}
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
required Player foregroundPlayer,
required VideoItem video,
}) async {
if (!_backgroundMode) {
_foregroundPlayer = foregroundPlayer;
return BackgroundPlaybackSnapshot(
position: foregroundPlayer.state.position,
playing: foregroundPlayer.state.playing,
rate: foregroundPlayer.state.rate,
);
}
final background = _backgroundPlayer;
if (background == null) {
_backgroundMode = false;
_foregroundPlayer = foregroundPlayer;
await _bindPlayer(
foregroundPlayer,
video,
);
return null;
}
final snapshot = BackgroundPlaybackSnapshot(
position: background.state.position,
playing: background.state.playing,
rate: background.state.rate,
);
await LibraryService.instance.savePlaybackPosition(
video.id,
snapshot.position,
duration: video.duration,
);
debugPrint(
'[BACKGROUND-AUDIO] returning to video player '
'at ${snapshot.position.inMilliseconds} ms '
'playing=${snapshot.playing}',
);
// Annule les listeners avant de couper le Player de fond, sinon
// le MediaSession verrait artificiellement une pause.
await _cancelSubscriptions();
_backgroundMode = false;
_backgroundPlayer = null;
_foregroundPlayer = foregroundPlayer;
_activePlayer = foregroundPlayer;
await background.pause();
await background.dispose();
await _bindPlayer(
foregroundPlayer,
video,
);
return snapshot;
}
Future<void> detachPlayer() async {
await _cancelSubscriptions();
final background = _backgroundPlayer;
_backgroundPlayer = null;
if (background != null) {
await background.dispose();
}
_foregroundPlayer = null;
_activePlayer = null;
_video = null;
_backgroundMode = false;
_playing = false;
_buffering = false;
_completed = false;
_position = Duration.zero;
_duration = Duration.zero;
_speed = 1.0;
_lastPersistedPosition = Duration.zero;
_backgroundSponsorSegments = const <SponsorSegment>[];
_backgroundSponsorSeekInProgress = false;
_backgroundSponsorActiveId = null;
mediaItem.add(null);
playbackState.add(
PlaybackState(
processingState: AudioProcessingState.idle,
playing: false,
),
);
}
Future<void> _waitUntilMediaReady(
Player player, {
required Duration wantedPosition,
required Duration? fallbackDuration,
}) async {
final currentDuration = player.state.duration;
if (currentDuration > Duration.zero &&
(wantedPosition <= Duration.zero ||
currentDuration >= wantedPosition)) {
return;
}
try {
await player.stream.duration
.firstWhere(
(duration) =>
duration > Duration.zero &&
(wantedPosition <= Duration.zero ||
duration >= wantedPosition),
)
.timeout(const Duration(seconds: 10));
} on TimeoutException {
// Si youtube_explode nous a déjà fourni une durée cohérente, on peut
// malgré tout tenter le seek. Le contrôle suivant confirmera ou non.
if (fallbackDuration == null ||
fallbackDuration <= Duration.zero ||
(wantedPosition > Duration.zero &&
fallbackDuration < wantedPosition)) {
rethrow;
}
}
}
Future<void> _seekAndConfirm(
Player player,
Duration target,
) async {
const tolerance = Duration(seconds: 2);
for (var attempt = 1; attempt <= 3; attempt++) {
debugPrint(
'[BACKGROUND-AUDIO] seek attempt $attempt -> '
'${target.inMilliseconds} ms',
);
await player.seek(target);
final immediate = player.state.position;
if (_closeEnough(
immediate,
target,
tolerance,
)) {
debugPrint(
'[BACKGROUND-AUDIO] seek confirmed immediately '
'at ${immediate.inMilliseconds} ms',
);
return;
}
try {
final confirmed = await player.stream.position
.firstWhere(
(position) => _closeEnough(
position,
target,
tolerance,
),
)
.timeout(const Duration(seconds: 3));
debugPrint(
'[BACKGROUND-AUDIO] seek confirmed '
'at ${confirmed.inMilliseconds} ms',
);
return;
} on TimeoutException {
debugPrint(
'[BACKGROUND-AUDIO] seek attempt $attempt not confirmed '
'(current=${player.state.position.inMilliseconds} ms)',
);
await Future<void>.delayed(
const Duration(milliseconds: 300),
);
}
}
throw TimeoutException(
'Le Player audio de fond n’a pas confirmé le seek.',
);
}
Future<void> _waitUntilPlaying(Player player) async {
if (player.state.playing) {
return;
}
await player.stream.playing
.firstWhere((playing) => playing)
.timeout(const Duration(seconds: 5));
}
bool _closeEnough(
Duration value,
Duration target,
Duration tolerance,
) {
final delta =
(value.inMilliseconds - target.inMilliseconds).abs();
return delta <= tolerance.inMilliseconds;
}
Future<void> _prepareBackgroundSponsors(String videoId) async {
final segments = await SponsorBlockService.instance
.getSponsorSegments(videoId);
if (_video?.id != videoId) {
return;
}
_backgroundSponsorSegments = segments;
_backgroundSponsorActiveId = null;
}
Future<void> _maybeSkipBackgroundSponsor(
Player player,
Duration position,
) async {
if (!_backgroundMode ||
_backgroundSponsorSeekInProgress ||
_backgroundSponsorSegments.isEmpty) {
return;
}
SponsorSegment? match;
for (final segment in _backgroundSponsorSegments) {
if (segment.contains(position)) {
match = segment;
break;
}
}
if (match == null) {
_backgroundSponsorActiveId = null;
return;
}
if (_backgroundSponsorActiveId == match.id) {
return;
}
_backgroundSponsorActiveId = match.id;
_backgroundSponsorSeekInProgress = true;
try {
final target = match.end + const Duration(milliseconds: 80);
final wasPlaying = player.state.playing;
debugPrint(
'[BACKGROUND-SPONSORBLOCK] skip '
'${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms',
);
await player.seek(target);
if (wasPlaying) {
await player.play();
}
_position = target;
_lastPersistedPosition = target;
} catch (error) {
debugPrint('[BACKGROUND-SPONSORBLOCK] skip impossible: $error');
} finally {
_backgroundSponsorSeekInProgress = false;
}
}
Future<void> _bindPlayer(
Player player,
VideoItem video,
) async {
await _cancelSubscriptions();
_activePlayer = player;
_video = video;
_playing = player.state.playing;
_buffering = player.state.buffering;
_position = player.state.position;
_duration = video.duration ?? player.state.duration;
_speed = player.state.rate;
_completed = false;
_lastPersistedPosition = _position;
_setMediaItem(video);
_subscriptions.addAll([
player.stream.playing.listen((value) {
_playing = value;
_broadcastState();
}),
player.stream.buffering.listen((value) {
_buffering = value;
_broadcastState();
}),
player.stream.position.listen((value) {
_position = value;
if (_backgroundMode) {
unawaited(_maybeSkipBackgroundSponsor(player, value));
}
if (_backgroundMode &&
(_position - _lastPersistedPosition).abs() >=
const Duration(seconds: 5)) {
_lastPersistedPosition = _position;
unawaited(
LibraryService.instance.savePlaybackPosition(
video.id,
_position,
duration: video.duration,
),
);
}
_broadcastState();
}),
player.stream.duration.listen((value) {
if (value > Duration.zero) {
_duration = value;
}
_broadcastState();
}),
player.stream.rate.listen((value) {
_speed = value;
_broadcastState();
}),
player.stream.completed.listen((value) {
_completed = value;
_broadcastState();
}),
]);
_broadcastState();
}
void _setMediaItem(VideoItem video) {
mediaItem.add(
MediaItem(
id: video.id,
title: video.title,
artist: video.author,
duration: video.duration,
artUri: Uri.tryParse(video.thumbnailUrl),
),
);
}
Future<void> _cancelSubscriptions() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
}
void _broadcastState() {
final processingState = _completed
? AudioProcessingState.completed
: _buffering
? AudioProcessingState.buffering
: AudioProcessingState.ready;
playbackState.add(
PlaybackState(
controls: [
MediaControl.rewind,
_playing ? MediaControl.pause : MediaControl.play,
MediaControl.fastForward,
],
androidCompactActionIndices: const [0, 1, 2],
systemActions: const {
MediaAction.seek,
MediaAction.setSpeed,
},
processingState: processingState,
playing: _playing,
updatePosition: _position,
bufferedPosition: Duration.zero,
speed: _speed,
),
);
}
@override
Future<void> play() async {
await _activePlayer?.play();
}
@override
Future<void> pause() async {
await _activePlayer?.pause();
}
@override
Future<void> seek(Duration position) async {
final target = _clampPosition(position);
await _activePlayer?.seek(target);
_position = target;
final video = _video;
if (_backgroundMode && video != null) {
_lastPersistedPosition = target;
await LibraryService.instance.savePlaybackPosition(
video.id,
target,
duration: video.duration,
);
}
_broadcastState();
}
@override
Future<void> rewind() async {
await seek(
_position - const Duration(seconds: 10),
);
}
@override
Future<void> fastForward() async {
await seek(
_position + const Duration(seconds: 10),
);
}
@override
Future<void> setSpeed(double speed) async {
await _activePlayer?.setRate(speed);
}
@override
Future<void> stop() async {
await _activePlayer?.stop();
_playing = false;
_completed = false;
_position = Duration.zero;
playbackState.add(
PlaybackState(
processingState: AudioProcessingState.idle,
playing: false,
),
);
await super.stop();
}
Duration _clampPosition(Duration value) {
if (value < Duration.zero) {
return Duration.zero;
}
if (_duration > Duration.zero && value > _duration) {
return _duration;
}
return value;
}
}
+240
View File
@@ -0,0 +1,240 @@
import '../models/channel_rank_item.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
import 'hidden_channels_service.dart';
import 'library_service.dart';
import 'youtube_service.dart';
class ChannelCatalogService {
ChannelCatalogService._();
static final ChannelCatalogService instance =
ChannelCatalogService._();
final YoutubeService _youtube = YoutubeService();
List<ChannelRankItem>? _cache;
String? _cacheFingerprint;
DateTime? _cacheAt;
Future<List<ChannelRankItem>> load({
bool force = false,
}) async {
await HiddenChannelsService.instance.ensureLoaded();
final history = LibraryService.instance.history.value;
if (history.isEmpty) {
return const [];
}
final fingerprint = <String>[
'${HiddenChannelsService.instance.revision.value}',
for (final video in history.take(100))
'${video.id}:${LibraryService.instance.watchCountForVideo(video.id)}',
].join('|');
final cacheFresh = _cacheAt != null &&
DateTime.now().difference(_cacheAt!) <
const Duration(minutes: 10);
if (!force &&
cacheFresh &&
_cache != null &&
_cacheFingerprint == fingerprint) {
return _cache!;
}
final enriched = <VideoItem>[];
// History is unique by video ID, but personal repeat counts are stored
// separately. Resolve old entries created before channelId existed.
for (var start = 0; start < history.length; start += 6) {
final end = start + 6 < history.length
? start + 6
: history.length;
final chunk = history.sublist(start, end);
final resolved = await Future.wait(
chunk.map(
(video) async {
if (video.channelId != null &&
video.channelId!.isNotEmpty) {
return video;
}
try {
return await _youtube.getVideoDetails(video.id);
} catch (_) {
return video;
}
},
),
);
enriched.addAll(resolved);
}
final groups = <String, _ChannelAccumulator>{};
for (var index = 0; index < enriched.length; index++) {
final video = enriched[index];
var channelId = video.channelId?.trim() ?? '';
SubscriptionChannel? resolvedChannel;
if (channelId.isEmpty) {
try {
resolvedChannel =
await _youtube.getChannelForVideo(video.id);
channelId = resolvedChannel.channelId;
} catch (_) {
continue;
}
}
if (HiddenChannelsService.instance.isHiddenChannel(
channelId: channelId,
title: resolvedChannel?.title ?? video.author,
)) {
continue;
}
final personalViews =
LibraryService.instance.watchCountForVideo(video.id);
final safePersonalViews = personalViews > 0 ? personalViews : 1;
groups
.putIfAbsent(
channelId,
() => _ChannelAccumulator(
channelId: channelId,
fallbackTitle: resolvedChannel?.title ?? video.author,
),
)
.add(
video,
personalViews: safePersonalViews,
historyIndex: index,
channelOverride: resolvedChannel,
);
}
final accumulators = groups.values.toList();
final result = <ChannelRankItem>[];
for (var start = 0; start < accumulators.length; start += 6) {
final end = start + 6 < accumulators.length
? start + 6
: accumulators.length;
final chunk = accumulators.sublist(start, end);
final resolved = await Future.wait(
chunk.map(
(group) async {
SubscriptionChannel channel;
if (group.channelOverride != null) {
channel = group.channelOverride!;
} else {
try {
channel = await _youtube.getChannel(group.channelId);
} catch (_) {
channel = SubscriptionChannel(
channelId: group.channelId,
title: group.fallbackTitle,
thumbnailUrl: '',
);
}
}
if (HiddenChannelsService.instance.isHiddenChannel(
channelId: channel.channelId,
title: channel.title,
)) {
return null;
}
return ChannelRankItem(
channel: channel,
personalViewCount: group.personalViewCount,
distinctVideoCount: group.distinctVideoCount,
topVideoPersonalViews: group.topVideoPersonalViews,
topVideoTitle: group.topVideoTitle,
);
},
),
);
result.addAll(resolved.whereType<ChannelRankItem>());
}
result.sort(
(a, b) {
final views = b.personalViewCount.compareTo(a.personalViewCount);
if (views != 0) {
return views;
}
final distinct =
b.distinctVideoCount.compareTo(a.distinctVideoCount);
if (distinct != 0) {
return distinct;
}
return a.channel.title
.toLowerCase()
.compareTo(b.channel.title.toLowerCase());
},
);
_cache = List.unmodifiable(result);
_cacheFingerprint = fingerprint;
_cacheAt = DateTime.now();
return _cache!;
}
}
class _ChannelAccumulator {
_ChannelAccumulator({
required this.channelId,
required this.fallbackTitle,
});
final String channelId;
final String fallbackTitle;
SubscriptionChannel? channelOverride;
int personalViewCount = 0;
int distinctVideoCount = 0;
int topVideoPersonalViews = 0;
int newestHistoryIndex = 1 << 30;
String? topVideoTitle;
void add(
VideoItem video, {
required int personalViews,
required int historyIndex,
SubscriptionChannel? channelOverride,
}) {
if (channelOverride != null) {
this.channelOverride = channelOverride;
}
personalViewCount += personalViews;
distinctVideoCount++;
if (historyIndex < newestHistoryIndex) {
newestHistoryIndex = historyIndex;
}
if (personalViews >= topVideoPersonalViews) {
topVideoPersonalViews = personalViews;
topVideoTitle = video.title;
}
}
}
@@ -0,0 +1,33 @@
import '../models/channel_details.dart';
import 'youtube_service.dart';
class ChannelPreviewMetadataService {
ChannelPreviewMetadataService._();
static final ChannelPreviewMetadataService instance =
ChannelPreviewMetadataService._();
final YoutubeService _youtube = YoutubeService();
final Map<String, Future<ChannelDetails?>> _channelCache =
<String, Future<ChannelDetails?>>{};
Future<ChannelDetails?> getChannel(
String? channelId,
) {
final id = channelId?.trim() ?? '';
if (id.isEmpty) {
return Future<ChannelDetails?>.value(null);
}
return _channelCache.putIfAbsent(
id,
() => _youtube.getChannelPreviewDetails(id),
);
}
void invalidate(String channelId) {
_channelCache.remove(channelId);
}
}
+735
View File
@@ -0,0 +1,735 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'google_oauth_config_service.dart';
class GoogleSession {
const GoogleSession({
required this.accessToken,
required this.displayName,
this.email,
this.photoUrl,
});
final String accessToken;
final String displayName;
final String? email;
final String? photoUrl;
GoogleSession copyWith({
String? accessToken,
String? displayName,
String? email,
String? photoUrl,
}) {
return GoogleSession(
accessToken: accessToken ?? this.accessToken,
displayName: displayName ?? this.displayName,
email: email ?? this.email,
photoUrl: photoUrl ?? this.photoUrl,
);
}
}
class GoogleAuthConfigurationException implements Exception {
const GoogleAuthConfigurationException(this.message);
final String message;
@override
String toString() => message;
}
class GoogleAuthService {
GoogleAuthService._();
static final GoogleAuthService instance =
GoogleAuthService._();
static const String youtubeReadonlyScope =
'https://www.googleapis.com/auth/youtube.readonly';
static const String _envWebClientId =
String.fromEnvironment(
'GOOGLE_WEB_CLIENT_ID',
);
static const String _envDesktopClientId =
String.fromEnvironment(
'GOOGLE_DESKTOP_CLIENT_ID',
);
static const String _envDesktopClientSecret =
String.fromEnvironment(
'GOOGLE_DESKTOP_CLIENT_SECRET',
);
final ValueNotifier<GoogleSession?> session =
ValueNotifier<GoogleSession?>(null);
bool _googleSignInInitialized = false;
String? _initializedAndroidWebClientId;
GoogleSignInAccount? _androidAccount;
String? _windowsRefreshToken;
DateTime? _windowsAccessTokenExpiry;
GoogleOauthConfig get _savedConfig =>
GoogleOauthConfigService.instance.config.value;
String get _webClientId {
final environment = _envWebClientId.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.webClientId.trim();
}
String get _desktopClientId {
final environment = _envDesktopClientId.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.desktopClientId.trim();
}
String get _desktopClientSecret {
final environment = _envDesktopClientSecret.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.desktopClientSecret.trim();
}
bool get isConfigured {
if (Platform.isAndroid) {
return _webClientId.isNotEmpty;
}
if (Platform.isWindows) {
return _desktopClientId.isNotEmpty;
}
return false;
}
String get configurationHint {
if (Platform.isAndroid) {
return 'Client ID Web OAuth Google manquant';
}
if (Platform.isWindows) {
return 'Client ID OAuth "Desktop app" manquant';
}
return 'Plateforme non prise en charge';
}
Future<void> initialize() async {
await GoogleOauthConfigService.instance.initialize();
if (!Platform.isAndroid || !isConfigured) {
return;
}
await _initializeAndroidGoogleSignIn();
try {
final future =
GoogleSignIn.instance
.attemptLightweightAuthentication();
if (future == null) {
return;
}
final account = await future;
if (account == null) {
return;
}
_androidAccount = account;
final authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
if (authorization == null) {
return;
}
session.value = GoogleSession(
accessToken: authorization.accessToken,
displayName:
account.displayName ?? account.email,
email: account.email,
photoUrl: account.photoUrl,
);
} catch (error) {
debugPrint(
'[GOOGLE-AUTH] Restauration ignorée: $error',
);
}
}
Future<bool> saveAndroidConfiguration(
String webClientId,
) async {
final previous = _webClientId;
await GoogleOauthConfigService.instance.saveAndroid(
webClientId: webClientId,
);
if (!Platform.isAndroid) {
return true;
}
final next = _webClientId;
if (next.isEmpty) {
return true;
}
if (!_googleSignInInitialized) {
await _initializeAndroidGoogleSignIn();
return true;
}
return previous == next;
}
Future<void> saveWindowsConfiguration({
required String desktopClientId,
required String desktopClientSecret,
}) async {
await GoogleOauthConfigService.instance.saveWindows(
desktopClientId: desktopClientId,
desktopClientSecret: desktopClientSecret,
);
}
Future<GoogleSession> signIn() async {
if (!isConfigured) {
throw GoogleAuthConfigurationException(
configurationHint,
);
}
if (Platform.isAndroid) {
return _signInAndroid();
}
if (Platform.isWindows) {
return _signInWindows();
}
throw const GoogleAuthConfigurationException(
'Connexion Google non prise en charge '
'sur cette plateforme.',
);
}
Future<String> getAccessToken() async {
final current = session.value;
if (current == null) {
throw StateError(
'Aucune session Google active.',
);
}
if (Platform.isAndroid) {
final account = _androidAccount;
if (account != null) {
final authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
if (authorization != null) {
final nextAccessToken =
authorization.accessToken;
// Ne notifie les listeners que si le jeton a réellement
// changé. Avant, chaque simple lecture du jeton réémettait
// session.value et pouvait relancer le chargement des
// abonnements en boucle.
if (nextAccessToken != current.accessToken) {
session.value = current.copyWith(
accessToken: nextAccessToken,
);
}
return nextAccessToken;
}
}
return current.accessToken;
}
if (Platform.isWindows) {
final expiry = _windowsAccessTokenExpiry;
if (expiry != null &&
DateTime.now().isAfter(
expiry.subtract(
const Duration(minutes: 2),
),
) &&
_windowsRefreshToken != null) {
await _refreshWindowsAccessToken();
}
return session.value?.accessToken ??
current.accessToken;
}
return current.accessToken;
}
Future<void> signOut() async {
if (Platform.isAndroid &&
_googleSignInInitialized) {
try {
await GoogleSignIn.instance.signOut();
} catch (error) {
debugPrint(
'[GOOGLE-AUTH] signOut Android: $error',
);
}
}
_androidAccount = null;
_windowsRefreshToken = null;
_windowsAccessTokenExpiry = null;
session.value = null;
}
Future<void> _initializeAndroidGoogleSignIn() async {
if (_googleSignInInitialized) {
return;
}
final clientId = _webClientId;
if (clientId.isEmpty) {
throw const GoogleAuthConfigurationException(
'Client ID Web OAuth Google manquant.',
);
}
await GoogleSignIn.instance.initialize(
serverClientId: clientId,
);
_googleSignInInitialized = true;
_initializedAndroidWebClientId = clientId;
}
Future<GoogleSession> _signInAndroid() async {
await _initializeAndroidGoogleSignIn();
if (_initializedAndroidWebClientId !=
_webClientId) {
throw const GoogleAuthConfigurationException(
'Le Client ID Android a été modifié. '
'Redémarre l’application avant de te connecter.',
);
}
if (!GoogleSignIn.instance.supportsAuthenticate()) {
throw StateError(
'Google Sign-In interactif '
'n’est pas disponible.',
);
}
final account =
await GoogleSignIn.instance.authenticate(
scopeHint: const [youtubeReadonlyScope],
);
_androidAccount = account;
var authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
authorization ??=
await account.authorizationClient
.authorizeScopes(
const [youtubeReadonlyScope],
);
final result = GoogleSession(
accessToken: authorization.accessToken,
displayName:
account.displayName ?? account.email,
email: account.email,
photoUrl: account.photoUrl,
);
session.value = result;
return result;
}
Future<GoogleSession> _signInWindows() async {
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
final redirectUri = Uri.parse(
'http://127.0.0.1:${server.port}/',
);
final verifier = _randomUrlSafeString(96);
final state = _randomUrlSafeString(32);
final challenge = base64UrlEncode(
sha256.convert(ascii.encode(verifier)).bytes,
).replaceAll('=', '');
const windowsScopes = <String>[
'openid',
'email',
'profile',
youtubeReadonlyScope,
];
final authorizationUri = Uri.https(
'accounts.google.com',
'/o/oauth2/v2/auth',
{
'client_id': _desktopClientId,
'redirect_uri': redirectUri.toString(),
'response_type': 'code',
'scope': windowsScopes.join(' '),
'code_challenge': challenge,
'code_challenge_method': 'S256',
'state': state,
'access_type': 'offline',
'prompt': 'consent',
},
);
final launched = await launchUrl(
authorizationUri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
await server.close(force: true);
throw StateError(
'Impossible d’ouvrir le navigateur '
'pour Google.',
);
}
HttpRequest request;
try {
request = await server.first.timeout(
const Duration(minutes: 3),
);
} on TimeoutException {
await server.close(force: true);
throw StateError(
'La connexion Google a expiré.',
);
}
final params = request.uri.queryParameters;
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.html
..write(
'<!doctype html><html><head>'
'<meta charset="utf-8">'
'<title>YouTube Personal</title></head>'
'<body style="font-family:Segoe UI,Arial;'
'padding:40px;background:#111;color:#fff">'
'<h2>Connexion terminée</h2>'
'<p>Tu peux fermer cet onglet et revenir '
'dans YouTube Personal.</p></body></html>',
);
await request.response.close();
await server.close(force: true);
if (params['state'] != state) {
throw StateError(
'Réponse OAuth Google invalide '
'(state incorrect).',
);
}
final error = params['error'];
if (error != null) {
throw StateError(
'Connexion Google refusée: $error',
);
}
final code = params['code'];
if (code == null || code.isEmpty) {
throw StateError(
'Google n’a pas renvoyé de '
'code d’autorisation.',
);
}
final tokenBody = <String, String>{
'client_id': _desktopClientId,
'code': code,
'code_verifier': verifier,
'grant_type': 'authorization_code',
'redirect_uri': redirectUri.toString(),
};
if (_desktopClientSecret.isNotEmpty) {
tokenBody['client_secret'] =
_desktopClientSecret;
}
final tokenResponse = await http.post(
Uri.parse(
'https://oauth2.googleapis.com/token',
),
headers: const {
'Content-Type':
'application/x-www-form-urlencoded',
},
body: tokenBody,
);
if (tokenResponse.statusCode < 200 ||
tokenResponse.statusCode >= 300) {
throw StateError(
'Échange OAuth Google impossible '
'(${tokenResponse.statusCode}): '
'${tokenResponse.body}',
);
}
final decoded = jsonDecode(
tokenResponse.body,
);
if (decoded is! Map) {
throw StateError(
'Réponse OAuth Google invalide.',
);
}
final accessToken = decoded['access_token'];
if (accessToken is! String ||
accessToken.isEmpty) {
throw StateError(
'Aucun jeton d’accès Google reçu.',
);
}
final refreshToken = decoded['refresh_token'];
if (refreshToken is String &&
refreshToken.isNotEmpty) {
_windowsRefreshToken = refreshToken;
}
final expiresIn = decoded['expires_in'];
if (expiresIn is num) {
_windowsAccessTokenExpiry =
DateTime.now().add(
Duration(
seconds: expiresIn.toInt(),
),
);
}
final profile = await _fetchGoogleProfile(
accessToken,
);
final result = GoogleSession(
accessToken: accessToken,
displayName:
profile.name ??
profile.email ??
'Compte Google',
email: profile.email,
photoUrl: profile.photoUrl,
);
session.value = result;
return result;
}
Future<void> _refreshWindowsAccessToken() async {
final refreshToken = _windowsRefreshToken;
if (refreshToken == null ||
refreshToken.isEmpty) {
return;
}
final body = <String, String>{
'client_id': _desktopClientId,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
};
if (_desktopClientSecret.isNotEmpty) {
body['client_secret'] =
_desktopClientSecret;
}
final response = await http.post(
Uri.parse(
'https://oauth2.googleapis.com/token',
),
headers: const {
'Content-Type':
'application/x-www-form-urlencoded',
},
body: body,
);
if (response.statusCode < 200 ||
response.statusCode >= 300) {
throw StateError(
'Impossible de renouveler '
'la session Google.',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map ||
decoded['access_token'] is! String) {
throw StateError(
'Réponse de renouvellement '
'Google invalide.',
);
}
final token =
decoded['access_token'] as String;
final expiresIn = decoded['expires_in'];
if (expiresIn is num) {
_windowsAccessTokenExpiry =
DateTime.now().add(
Duration(
seconds: expiresIn.toInt(),
),
);
}
final current = session.value;
if (current != null) {
session.value = current.copyWith(
accessToken: token,
);
}
}
Future<_GoogleProfile> _fetchGoogleProfile(
String accessToken,
) async {
try {
final response = await http.get(
Uri.parse(
'https://www.googleapis.com/oauth2/v3/userinfo',
),
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
);
if (response.statusCode < 200 ||
response.statusCode >= 300) {
return const _GoogleProfile();
}
final decoded = jsonDecode(response.body);
if (decoded is! Map) {
return const _GoogleProfile();
}
return _GoogleProfile(
name: decoded['name'] is String
? decoded['name'] as String
: null,
email: decoded['email'] is String
? decoded['email'] as String
: null,
photoUrl: decoded['picture'] is String
? decoded['picture'] as String
: null,
);
} catch (_) {
return const _GoogleProfile();
}
}
String _randomUrlSafeString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
'abcdefghijklmnopqrstuvwxyz'
'0123456789-._~';
final random = Random.secure();
return List.generate(
length,
(_) => chars[
random.nextInt(chars.length)],
).join();
}
}
class _GoogleProfile {
const _GoogleProfile({
this.name,
this.email,
this.photoUrl,
});
final String? name;
final String? email;
final String? photoUrl;
}
@@ -0,0 +1,131 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class GoogleOauthConfig {
const GoogleOauthConfig({
this.webClientId = '',
this.desktopClientId = '',
this.desktopClientSecret = '',
});
final String webClientId;
final String desktopClientId;
final String desktopClientSecret;
bool get hasAndroidConfig => webClientId.trim().isNotEmpty;
bool get hasWindowsConfig => desktopClientId.trim().isNotEmpty;
GoogleOauthConfig copyWith({
String? webClientId,
String? desktopClientId,
String? desktopClientSecret,
}) {
return GoogleOauthConfig(
webClientId: webClientId ?? this.webClientId,
desktopClientId:
desktopClientId ?? this.desktopClientId,
desktopClientSecret:
desktopClientSecret ?? this.desktopClientSecret,
);
}
}
class GoogleOauthConfigService {
GoogleOauthConfigService._();
static final GoogleOauthConfigService instance =
GoogleOauthConfigService._();
static const String _webClientIdKey =
'youtube_personal.google.web_client_id.v1';
static const String _desktopClientIdKey =
'youtube_personal.google.desktop_client_id.v1';
static const String _desktopClientSecretKey =
'youtube_personal.google.desktop_client_secret.v1';
final SharedPreferencesAsync _preferences =
SharedPreferencesAsync();
final ValueNotifier<GoogleOauthConfig> config =
ValueNotifier<GoogleOauthConfig>(
const GoogleOauthConfig(),
);
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) {
return;
}
final webClientId =
await _preferences.getString(_webClientIdKey) ?? '';
final desktopClientId =
await _preferences.getString(_desktopClientIdKey) ?? '';
final desktopClientSecret =
await _preferences.getString(
_desktopClientSecretKey,
) ??
'';
config.value = GoogleOauthConfig(
webClientId: webClientId,
desktopClientId: desktopClientId,
desktopClientSecret: desktopClientSecret,
);
_initialized = true;
}
Future<void> saveAndroid({
required String webClientId,
}) async {
final value = webClientId.trim();
if (value.isEmpty) {
await _preferences.remove(_webClientIdKey);
} else {
await _preferences.setString(
_webClientIdKey,
value,
);
}
config.value = config.value.copyWith(
webClientId: value,
);
}
Future<void> saveWindows({
required String desktopClientId,
required String desktopClientSecret,
}) async {
final clientId = desktopClientId.trim();
final clientSecret = desktopClientSecret.trim();
if (clientId.isEmpty) {
await _preferences.remove(_desktopClientIdKey);
} else {
await _preferences.setString(
_desktopClientIdKey,
clientId,
);
}
if (clientSecret.isEmpty) {
await _preferences.remove(
_desktopClientSecretKey,
);
} else {
await _preferences.setString(
_desktopClientSecretKey,
clientSecret,
);
}
config.value = config.value.copyWith(
desktopClientId: clientId,
desktopClientSecret: clientSecret,
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
class HiddenChannelsService {
HiddenChannelsService._();
static final HiddenChannelsService instance =
HiddenChannelsService._();
static const String _key =
'youtube_personal.hidden_channels.v1';
final SharedPreferencesAsync _preferences =
SharedPreferencesAsync();
final Map<String, String> _hiddenById = <String, String>{};
final Set<String> _hiddenTitles = <String>{};
bool _loaded = false;
/// Lets Home/Channels invalidate their caches when the user hides or
/// restores a channel.
final ValueNotifier<int> revision = ValueNotifier<int>(0);
Future<void> ensureLoaded() async {
if (_loaded) {
return;
}
_loaded = true;
final raw = await _preferences.getString(_key);
if (raw == null || raw.isEmpty) {
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return;
}
for (final item in decoded) {
if (item is! Map) {
continue;
}
final id = item['id'];
final title = item['title'];
if (id is String && id.trim().isNotEmpty) {
_hiddenById[id.trim()] = title is String ? title : '';
}
if (title is String && title.trim().isNotEmpty) {
_hiddenTitles.add(_normalize(title));
}
}
} catch (_) {
_hiddenById.clear();
_hiddenTitles.clear();
}
}
bool isHiddenChannel({
String? channelId,
String? title,
}) {
final id = channelId?.trim() ?? '';
if (id.isNotEmpty && _hiddenById.containsKey(id)) {
return true;
}
final normalized = _normalize(title ?? '');
return normalized.isNotEmpty && _hiddenTitles.contains(normalized);
}
bool isHiddenVideo(VideoItem video) {
return isHiddenChannel(
channelId: video.channelId,
title: video.author,
);
}
Future<void> hide(SubscriptionChannel channel) async {
await ensureLoaded();
_hiddenById[channel.channelId] = channel.title;
final normalized = _normalize(channel.title);
if (normalized.isNotEmpty) {
_hiddenTitles.add(normalized);
}
await _save();
revision.value++;
}
Future<void> restore(SubscriptionChannel channel) async {
await ensureLoaded();
_hiddenById.remove(channel.channelId);
final normalized = _normalize(channel.title);
if (normalized.isNotEmpty) {
// Keep a title hidden only if another hidden channel still uses it.
final stillUsed = _hiddenById.values.any(
(value) => _normalize(value) == normalized,
);
if (!stillUsed) {
_hiddenTitles.remove(normalized);
}
}
await _save();
revision.value++;
}
Future<void> _save() async {
final payload = <Map<String, String>>[
for (final entry in _hiddenById.entries)
<String, String>{
'id': entry.key,
'title': entry.value,
},
];
await _preferences.setString(
_key,
jsonEncode(payload),
);
}
String _normalize(String value) {
return value.trim().toLowerCase();
}
}
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/video_item.dart';
class LibraryService {
LibraryService._();
static final LibraryService instance = LibraryService._();
static const String _historyKey = 'youtube_personal.history.v1';
static const String _favoritesKey = 'youtube_personal.favorites.v1';
static const String _positionsKey = 'youtube_personal.positions.v1';
static const String _watchCountsKey =
'youtube_personal.watch_counts.v1';
static const int _maxHistoryItems = 100;
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
final ValueNotifier<List<VideoItem>> history =
ValueNotifier<List<VideoItem>>(const []);
final ValueNotifier<List<VideoItem>> favorites =
ValueNotifier<List<VideoItem>>(const []);
final Map<String, int> _positionsMs = <String, int>{};
final Map<String, int> _watchCounts = <String, int>{};
/// Changes whenever a real video opening increments the user's own view
/// counter. The Channels page uses this to refresh its ranking.
final ValueNotifier<int> watchRevision = ValueNotifier<int>(0);
Future<void> initialize() async {
history.value = await _readList(_historyKey);
favorites.value = await _readList(_favoritesKey);
_positionsMs
..clear()
..addAll(await _readPositions());
_watchCounts
..clear()
..addAll(await _readIntMap(_watchCountsKey));
// Migration for installs created before personal watch counters existed.
// Every video already present in history counts as at least one personal
// view, without inventing repeat views that we never recorded.
var migrated = false;
for (final video in history.value) {
if ((_watchCounts[video.id] ?? 0) <= 0) {
_watchCounts[video.id] = 1;
migrated = true;
}
}
if (migrated) {
await _writeWatchCounts();
}
}
int watchCountForVideo(String videoId) {
return _watchCounts[videoId] ?? 0;
}
Future<void> addToHistory(
VideoItem video, {
bool incrementWatchCount = true,
}) async {
final updated = <VideoItem>[
video,
...history.value.where((item) => item.id != video.id),
];
if (updated.length > _maxHistoryItems) {
updated.removeRange(_maxHistoryItems, updated.length);
}
history.value = List<VideoItem>.unmodifiable(updated);
await _writeList(_historyKey, history.value);
if (incrementWatchCount) {
_watchCounts.update(
video.id,
(value) => value + 1,
ifAbsent: () => 1,
);
await _writeWatchCounts();
watchRevision.value++;
}
}
bool isFavorite(String videoId) {
return favorites.value.any((item) => item.id == videoId);
}
Future<bool> toggleFavorite(VideoItem video) async {
final updated = [...favorites.value];
final index = updated.indexWhere((item) => item.id == video.id);
if (index >= 0) {
updated.removeAt(index);
} else {
updated.insert(0, video);
}
favorites.value = List<VideoItem>.unmodifiable(updated);
await _writeList(_favoritesKey, favorites.value);
return isFavorite(video.id);
}
Duration? getPlaybackPosition(String videoId) {
final milliseconds = _positionsMs[videoId];
if (milliseconds == null || milliseconds <= 0) {
return null;
}
return Duration(milliseconds: milliseconds);
}
Future<void> savePlaybackPosition(
String videoId,
Duration position, {
Duration? duration,
}) async {
final positionMs = position.inMilliseconds;
// On ne mémorise pas les toutes premières secondes.
if (positionMs < const Duration(seconds: 5).inMilliseconds) {
return;
}
// Si la vidéo est pratiquement terminée, on efface la reprise.
if (duration != null && duration > Duration.zero) {
final remaining = duration - position;
if (remaining <= const Duration(seconds: 15)) {
await clearPlaybackPosition(videoId);
return;
}
}
_positionsMs[videoId] = positionMs;
await _writePositions();
}
Future<void> clearPlaybackPosition(String videoId) async {
if (_positionsMs.remove(videoId) != null) {
await _writePositions();
}
}
Future<void> removeFromHistory(String videoId) async {
final updated = history.value.where((item) => item.id != videoId).toList();
history.value = List<VideoItem>.unmodifiable(updated);
await _writeList(_historyKey, history.value);
}
Future<void> clearHistory() async {
history.value = const [];
await _preferences.remove(_historyKey);
}
Future<void> clearFavorites() async {
favorites.value = const [];
await _preferences.remove(_favoritesKey);
}
Future<List<VideoItem>> _readList(String key) async {
final raw = await _preferences.getString(key);
if (raw == null || raw.isEmpty) {
return const [];
}
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return const [];
}
return decoded
.whereType<Map>()
.map(
(item) => VideoItem.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList(growable: false);
} catch (_) {
return const [];
}
}
Future<Map<String, int>> _readPositions() {
return _readIntMap(_positionsKey);
}
Future<Map<String, int>> _readIntMap(String key) async {
final raw = await _preferences.getString(key);
if (raw == null || raw.isEmpty) {
return const {};
}
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return const {};
}
final result = <String, int>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (entry.key is String && value is num) {
result[entry.key as String] = value.toInt();
}
}
return result;
} catch (_) {
return const {};
}
}
Future<void> _writeList(
String key,
List<VideoItem> videos,
) async {
final payload = jsonEncode(
videos.map((video) => video.toJson()).toList(growable: false),
);
await _preferences.setString(key, payload);
}
Future<void> _writePositions() async {
await _preferences.setString(
_positionsKey,
jsonEncode(_positionsMs),
);
}
Future<void> _writeWatchCounts() async {
await _preferences.setString(
_watchCountsKey,
jsonEncode(_watchCounts),
);
}
}
@@ -0,0 +1,577 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MediaCacheSettingsService {
MediaCacheSettingsService._();
static final MediaCacheSettingsService instance =
MediaCacheSettingsService._();
static const String _customPathKey =
'youtube_personal.cache.custom_path.v1';
static const String _maxCacheGbKey =
'youtube_personal.cache.max_gb.v1';
static const String _androidBufferMbKey =
'youtube_personal.cache.android_buffer_mb.v1';
static const String _androidDiskCacheModeKey =
'youtube_personal.cache.android_disk_mode.v1';
static const int defaultMaxCacheGb = 5;
static const List<int> supportedMaxCacheGb = <int>[
2,
5,
10,
20,
50,
0,
];
// Android : le flux vidéo direct reste par défaut en cache mémoire.
// 64 Mo offre une marge confortable pour 1080p/1440p/4K sans créer
// de cache disque persistant.
static const int defaultAndroidBufferMb = 64;
static const List<int> supportedAndroidBufferMb = <int>[
32,
64,
128,
];
// 0 = RAM uniquement, 1 = cache temporaire stockage interne app,
// 2 = cache temporaire stockage externe app.
static const int androidDiskCacheDisabled = 0;
static const int androidDiskCacheInternal = 1;
static const int androidDiskCacheExternal = 2;
static const List<int> supportedAndroidDiskCacheModes = <int>[
androidDiskCacheDisabled,
androidDiskCacheInternal,
androidDiskCacheExternal,
];
static const MethodChannel _androidCacheChannel = MethodChannel(
'com.studioweb.youtube_personal/cache',
);
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
final ValueNotifier<String> customCachePath =
ValueNotifier<String>('');
final ValueNotifier<int> maxCacheGb =
ValueNotifier<int>(defaultMaxCacheGb);
final ValueNotifier<int> androidBufferMb =
ValueNotifier<int>(defaultAndroidBufferMb);
final ValueNotifier<int> androidDiskCacheMode =
ValueNotifier<int>(androidDiskCacheDisabled);
Future<void>? _initialization;
bool _initialized = false;
Future<void> ensureInitialized() async {
if (_initialized) {
return;
}
_initialization ??= _load();
await _initialization;
}
Future<void> _load() async {
final storedPath = await _preferences.getString(_customPathKey);
final storedMaxGb = await _preferences.getInt(_maxCacheGbKey);
final storedAndroidBufferMb =
await _preferences.getInt(_androidBufferMbKey);
final storedAndroidDiskCacheMode =
await _preferences.getInt(_androidDiskCacheModeKey);
if (storedPath != null && storedPath.trim().isNotEmpty) {
customCachePath.value = storedPath.trim();
}
if (storedMaxGb != null && supportedMaxCacheGb.contains(storedMaxGb)) {
maxCacheGb.value = storedMaxGb;
}
if (storedAndroidBufferMb != null &&
supportedAndroidBufferMb.contains(storedAndroidBufferMb)) {
androidBufferMb.value = storedAndroidBufferMb;
}
if (storedAndroidDiskCacheMode != null &&
supportedAndroidDiskCacheModes.contains(storedAndroidDiskCacheMode)) {
androidDiskCacheMode.value = storedAndroidDiskCacheMode;
}
_initialized = true;
// Nettoyage immédiat au premier lancement après mise à jour : l'ancien
// cache Windows de plusieurs dizaines de Go est ramené à la limite
// configurée avant même d'ouvrir une nouvelle vidéo.
if (Platform.isWindows) {
await enforceLimit();
}
}
String get defaultCachePath {
final localAppData = Platform.environment['LOCALAPPDATA'];
final rootPath = localAppData != null && localAppData.trim().isNotEmpty
? localAppData.trim()
: Directory.systemTemp.path;
return '$rootPath'
'${Platform.pathSeparator}'
'YouTubePersonal'
'${Platform.pathSeparator}'
'media_cache';
}
Future<String> effectiveCachePath() async {
await ensureInitialized();
final custom = customCachePath.value.trim();
if (custom.isNotEmpty) {
return custom;
}
return defaultCachePath;
}
Future<Directory> resolveCacheRoot() async {
final path = await effectiveCachePath();
final directory = Directory(path);
try {
await directory.create(recursive: true);
return directory;
} catch (error) {
// Si un disque externe / secondaire a disparu, ne pas casser le
// lecteur Windows : retour temporaire au chemin par défaut.
if (customCachePath.value.trim().isNotEmpty) {
debugPrint(
'[MEDIA-CACHE][18.7.15] chemin personnalisé indisponible '
'$path -> fallback: $error',
);
final fallback = Directory(defaultCachePath);
await fallback.create(recursive: true);
return fallback;
}
rethrow;
}
}
Future<void> setCustomCachePath(String value) async {
await ensureInitialized();
final trimmed = value.trim();
if (trimmed.isEmpty || _samePath(trimmed, defaultCachePath)) {
customCachePath.value = '';
await _preferences.remove(_customPathKey);
return;
}
final directory = Directory(trimmed);
await directory.create(recursive: true);
final testFile = File(
'${directory.path}${Platform.pathSeparator}.youtube_personal_write_test',
);
try {
await testFile.writeAsString('ok', flush: true);
} finally {
try {
if (await testFile.exists()) {
await testFile.delete();
}
} catch (_) {
// Non bloquant.
}
}
customCachePath.value = directory.path;
await _preferences.setString(_customPathKey, directory.path);
}
Future<void> resetCustomCachePath() async {
await setCustomCachePath('');
}
Future<void> setMaxCacheGb(int value) async {
await ensureInitialized();
if (!supportedMaxCacheGb.contains(value)) {
return;
}
maxCacheGb.value = value;
await _preferences.setInt(_maxCacheGbKey, value);
if (Platform.isWindows) {
await enforceLimit();
}
}
static String maxCacheLabel(int value) {
if (value == 0) {
return 'Illimité';
}
return '$value Go';
}
Future<void> setAndroidBufferMb(int value) async {
await ensureInitialized();
if (!supportedAndroidBufferMb.contains(value)) {
return;
}
androidBufferMb.value = value;
await _preferences.setInt(_androidBufferMbKey, value);
}
static String androidBufferLabel(int value) => '$value Mo';
Future<void> setAndroidDiskCacheMode(int value) async {
await ensureInitialized();
if (!supportedAndroidDiskCacheModes.contains(value)) {
return;
}
androidDiskCacheMode.value = value;
await _preferences.setInt(_androidDiskCacheModeKey, value);
if (!Platform.isAndroid) {
return;
}
// Quand le cache disque est désactivé, supprimer les éventuels restes
// d'anciennes sessions sur les deux emplacements.
if (value == androidDiskCacheDisabled) {
await clearAndroidMediaCache(androidDiskCacheInternal);
await clearAndroidMediaCache(androidDiskCacheExternal);
} else {
// Le dossier est temporaire. On retire les restes d'un éventuel crash
// précédent avant la prochaine lecture.
await clearAndroidMediaCache(value);
}
}
static String androidDiskCacheModeLabel(int value) {
return switch (value) {
androidDiskCacheInternal => 'Interne (app)',
androidDiskCacheExternal => 'Externe (app)',
_ => 'Désactivé (RAM uniquement)',
};
}
Future<Map<String, String>> androidCachePaths() async {
if (!Platform.isAndroid) {
return const <String, String>{};
}
try {
final raw = await _androidCacheChannel.invokeMapMethod<String, dynamic>(
'getCachePaths',
);
if (raw == null) {
return const <String, String>{};
}
final result = <String, String>{};
for (final entry in raw.entries) {
final value = entry.value;
if (value is String && value.trim().isNotEmpty) {
result[entry.key] = value;
}
}
return result;
} catch (error) {
debugPrint('[MEDIA-CACHE][18.7.15] Android paths: $error');
return const <String, String>{};
}
}
Future<String> androidCachePathForMode(int mode) async {
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
return '';
}
final paths = await androidCachePaths();
if (mode == androidDiskCacheExternal) {
return paths['external'] ?? paths['internal'] ?? '';
}
return paths['internal'] ?? '';
}
Future<int> androidCacheSizeBytes(int mode) async {
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
return 0;
}
try {
final value = await _androidCacheChannel.invokeMethod<num>(
'getCacheSize',
<String, dynamic>{'mode': mode},
);
return value?.toInt() ?? 0;
} catch (error) {
debugPrint('[MEDIA-CACHE][18.7.15] Android size: $error');
return 0;
}
}
Future<void> clearAndroidMediaCache(int mode) async {
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
return;
}
try {
await _androidCacheChannel.invokeMethod<bool>(
'clearCache',
<String, dynamic>{'mode': mode},
);
} catch (error) {
debugPrint('[MEDIA-CACHE][18.7.15] Android clear: $error');
}
}
Future<int> currentCacheSizeBytes() async {
return directorySizeBytes(await resolveCacheRoot());
}
Future<int> defaultCacheSizeBytes() async {
return directorySizeBytes(Directory(defaultCachePath));
}
Future<int> directorySizeBytes(Directory directory) async {
if (!await directory.exists()) {
return 0;
}
var total = 0;
try {
await for (final entity in directory.list(
recursive: true,
followLinks: false,
)) {
if (entity is File) {
try {
total += await entity.length();
} catch (_) {
// Un fichier verrouillé ne doit pas bloquer l'affichage.
}
}
}
} catch (_) {
// Un dossier partiellement inaccessible ne doit pas bloquer l'app.
}
return total;
}
Future<void> clearCurrentCache() async {
final root = await resolveCacheRoot();
await _deleteChildren(root);
}
Future<void> clearDefaultCache() async {
final root = Directory(defaultCachePath);
if (!await root.exists()) {
return;
}
await _deleteChildren(root);
}
Future<void> _deleteChildren(Directory root) async {
try {
final entities = await root.list(followLinks: false).toList();
for (final entity in entities) {
try {
await entity.delete(recursive: true);
} catch (_) {
// Un fichier encore utilisé sera conservé et pourra être supprimé
// lors du prochain nettoyage.
}
}
} catch (_) {
// Le nettoyage ne doit jamais empêcher l'application de fonctionner.
}
}
Future<void> touchVideoDirectory(Directory videoDirectory) async {
final marker = File(
'${videoDirectory.path}${Platform.pathSeparator}.last_access',
);
try {
await marker.writeAsString(
DateTime.now().toIso8601String(),
flush: true,
);
} catch (_) {
// Non bloquant.
}
}
Future<void> enforceLimit({String? keepVideoId}) async {
await ensureInitialized();
if (!Platform.isWindows) {
return;
}
final limitGb = maxCacheGb.value;
if (limitGb <= 0) {
return;
}
final root = await resolveCacheRoot();
final maxBytes = limitGb * 1024 * 1024 * 1024;
final entries = <_CacheEntry>[];
var totalBytes = 0;
try {
final children = await root.list(followLinks: false).toList();
for (final entity in children) {
final name = entity.uri.pathSegments
.where((part) => part.isNotEmpty)
.last;
int bytes = 0;
DateTime lastAccess = DateTime.fromMillisecondsSinceEpoch(0);
if (entity is Directory) {
bytes = await directorySizeBytes(entity);
lastAccess = await _lastAccess(entity);
} else if (entity is File) {
try {
bytes = await entity.length();
lastAccess = (await entity.stat()).modified;
} catch (_) {
continue;
}
}
entries.add(
_CacheEntry(
entity: entity,
name: name,
bytes: bytes,
lastAccess: lastAccess,
),
);
totalBytes += bytes;
}
} catch (_) {
return;
}
if (totalBytes <= maxBytes) {
return;
}
// On descend à 85 % de la limite pour éviter un nettoyage à chaque
// petite écriture suivante.
final targetBytes = (maxBytes * 0.85).round();
entries.sort(
(a, b) => a.lastAccess.compareTo(b.lastAccess),
);
for (final entry in entries) {
if (totalBytes <= targetBytes) {
break;
}
if (keepVideoId != null && entry.name == keepVideoId) {
continue;
}
try {
await entry.entity.delete(recursive: true);
totalBytes -= entry.bytes;
debugPrint(
'[MEDIA-CACHE][18.7.15] LRU supprimé: '
'${entry.name} (${_formatBytes(entry.bytes)})',
);
} catch (_) {
// Probablement encore utilisé par le lecteur.
}
}
debugPrint(
'[MEDIA-CACHE][18.7.15] taille après nettoyage: '
'${_formatBytes(totalBytes)} / ${limitGb}Go',
);
}
Future<DateTime> _lastAccess(Directory directory) async {
final marker = File(
'${directory.path}${Platform.pathSeparator}.last_access',
);
try {
if (await marker.exists()) {
return (await marker.stat()).modified;
}
return (await directory.stat()).modified;
} catch (_) {
return DateTime.fromMillisecondsSinceEpoch(0);
}
}
bool _samePath(String a, String b) {
String normalize(String value) {
var normalized = value
.replaceAll('/', Platform.pathSeparator)
.replaceAll('\\', Platform.pathSeparator)
.trim();
while (normalized.endsWith(Platform.pathSeparator)) {
normalized = normalized.substring(0, normalized.length - 1);
}
return Platform.isWindows
? normalized.toLowerCase()
: normalized;
}
return normalize(a) == normalize(b);
}
static String formatBytesForUi(int bytes) => _formatBytes(bytes);
static String _formatBytes(int bytes) {
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go';
}
if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo';
}
return '${(bytes / 1024).toStringAsFixed(0)} Ko';
}
}
class _CacheEntry {
const _CacheEntry({
required this.entity,
required this.name,
required this.bytes,
required this.lastAccess,
});
final FileSystemEntity entity;
final String name;
final int bytes;
final DateTime lastAccess;
}
+376
View File
@@ -0,0 +1,376 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';
import 'package:file_selector/file_selector.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum MemoryImportMode { merge, replace }
class MemoryExportResult {
const MemoryExportResult({
required this.entryCount,
required this.destination,
});
final int entryCount;
final String destination;
}
class MemoryImportCandidate {
const MemoryImportCandidate({
required this.sourcePlatform,
required this.sourceDeviceId,
required this.exportedAtUtc,
required this.values,
});
final String sourcePlatform;
final String sourceDeviceId;
final DateTime? exportedAtUtc;
final Map<String, Object?> values;
int get entryCount => values.length;
}
class MemoryImportResult {
const MemoryImportResult({
required this.importedCount,
required this.removedCount,
required this.mode,
});
final int importedCount;
final int removedCount;
final MemoryImportMode mode;
}
/// Sauvegarde portable de la mémoire comportementale de YouTube Personal.
///
/// Important : ce service utilise volontairement une liste blanche. Les
/// identifiants Google, tokens OAuth, secrets, chemins de cache et autres
/// données propres à une plateforme ne sont jamais exportés.
class MemoryBackupService {
MemoryBackupService._();
static final MemoryBackupService instance = MemoryBackupService._();
static const String formatId = 'youtube-personal-memory';
static const int schemaVersion = 1;
static const String _deviceIdKey = 'youtube_personal.sync.device_id.v1';
static const String _lastImportKey =
'youtube_personal.sync.last_import_utc.v1';
static const Set<String> _allowedExactKeys = <String>{
'youtube_personal.history.v1',
'youtube_personal.favorites.v1',
'youtube_personal.positions.v1',
'youtube_personal.watch_counts.v1',
'youtube_personal.hidden_channels.v1',
// Ces deux réglages influencent directement les recommandations.
'youtube_personal.settings.preferred_language.v1',
'youtube_personal.settings.young_audience_filter.v1',
};
static const List<String> _allowedPrefixes = <String>[
'youtube_personal.radar.',
'youtube_personal.interest_',
];
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
Future<int> memoryEntryCount() async {
final all = await _preferences.getAll();
return all.keys.where(_isMemoryKey).length;
}
Future<MemoryExportResult?> exportMemory() async {
final all = await _preferences.getAll();
final memory = <String, Object?>{};
for (final entry in all.entries) {
if (!_isMemoryKey(entry.key)) {
continue;
}
memory[entry.key] = entry.value;
}
final deviceId = await _getOrCreateDeviceId();
final now = DateTime.now().toUtc();
final payload = <String, Object?>{
'format': formatId,
'schemaVersion': schemaVersion,
'exportedAtUtc': now.toIso8601String(),
'sourcePlatform': Platform.operatingSystem,
'sourceDeviceId': deviceId,
'entryCount': memory.length,
'preferences': <String, Object?>{
for (final entry in memory.entries)
entry.key: _encodePreference(entry.value),
},
};
final jsonText = const JsonEncoder.withIndent(' ').convert(payload);
final bytes = Uint8List.fromList(utf8.encode(jsonText));
final fileName = 'YouTube_Personal_Memory_${_fileTimestamp(now)}.json';
final baseName = fileName.substring(
0,
fileName.length - '.ypmemory'.length,
);
final destination = await FileSaver.instance.saveAs(
name: baseName,
bytes: bytes,
fileExtension: 'json',
mimeType: MimeType.custom,
customMimeType: 'application/json',
);
if (destination == null || destination.trim().isEmpty) {
return null;
}
return MemoryExportResult(
entryCount: memory.length,
destination: destination,
);
}
Future<MemoryImportCandidate?> pickMemoryBackup() async {
final XFile? selected;
if (Platform.isAndroid) {
// Android ne mappe pas toujours l'extension personnalisée .ypmemory
// vers un type MIME. Le contenu est validé strictement juste après.
selected = await openFile();
} else {
const memoryFiles = XTypeGroup(
label: 'Mémoire YouTube Personal',
extensions: <String>['json', 'ypmemory'],
);
selected = await openFile(acceptedTypeGroups: <XTypeGroup>[memoryFiles]);
}
if (selected == null) {
return null;
}
final bytes = await selected.readAsBytes();
if (bytes.isEmpty) {
throw const FormatException('Impossible de lire le fichier sélectionné.');
}
final rawText = utf8.decode(bytes, allowMalformed: false);
final decoded = jsonDecode(rawText);
if (decoded is! Map) {
throw const FormatException('Sauvegarde mémoire invalide.');
}
final map = decoded.map((key, value) => MapEntry('$key', value));
if (map['format'] != formatId) {
throw const FormatException(
'Ce fichier n’est pas une sauvegarde YouTube Personal.',
);
}
final rawSchema = map['schemaVersion'];
final schema = rawSchema is num ? rawSchema.toInt() : -1;
if (schema <= 0 || schema > schemaVersion) {
throw FormatException(
'Version de sauvegarde non prise en charge : $schema.',
);
}
final rawPreferences = map['preferences'];
if (rawPreferences is! Map) {
throw const FormatException('La mémoire du fichier est absente.');
}
final values = <String, Object?>{};
for (final entry in rawPreferences.entries) {
final key = '${entry.key}';
if (!_isMemoryKey(key)) {
// Défense supplémentaire : même un fichier modifié manuellement ne
// peut pas injecter une préférence sensible hors de la liste blanche.
continue;
}
final rawValue = entry.value;
if (rawValue is! Map) {
continue;
}
values[key] = _decodePreference(
rawValue.map((key, value) => MapEntry('$key', value)),
);
}
DateTime? exportedAt;
final rawExportedAt = map['exportedAtUtc'];
if (rawExportedAt is String) {
exportedAt = DateTime.tryParse(rawExportedAt)?.toUtc();
}
return MemoryImportCandidate(
sourcePlatform: map['sourcePlatform'] is String
? map['sourcePlatform'] as String
: 'inconnu',
sourceDeviceId: map['sourceDeviceId'] is String
? map['sourceDeviceId'] as String
: 'inconnu',
exportedAtUtc: exportedAt,
values: values,
);
}
Future<MemoryImportResult> importMemory(
MemoryImportCandidate candidate, {
required MemoryImportMode mode,
}) async {
int removed = 0;
if (mode == MemoryImportMode.replace) {
final current = await _preferences.getAll();
for (final key in current.keys.where(_isMemoryKey)) {
await _preferences.remove(key);
removed++;
}
}
int imported = 0;
for (final entry in candidate.values.entries) {
if (!_isMemoryKey(entry.key)) {
continue;
}
await _writePreference(entry.key, entry.value);
imported++;
}
await _preferences.setString(
_lastImportKey,
DateTime.now().toUtc().toIso8601String(),
);
return MemoryImportResult(
importedCount: imported,
removedCount: removed,
mode: mode,
);
}
bool _isMemoryKey(String key) {
if (_allowedExactKeys.contains(key)) {
return true;
}
return _allowedPrefixes.any(key.startsWith);
}
Map<String, Object?> _encodePreference(Object? value) {
if (value is bool) {
return <String, Object?>{'type': 'bool', 'value': value};
}
if (value is int) {
return <String, Object?>{'type': 'int', 'value': value};
}
if (value is double) {
return <String, Object?>{'type': 'double', 'value': value};
}
if (value is String) {
return <String, Object?>{'type': 'string', 'value': value};
}
if (value is List<String>) {
return <String, Object?>{'type': 'stringList', 'value': value};
}
if (value is List && value.every((item) => item is String)) {
return <String, Object?>{
'type': 'stringList',
'value': value.cast<String>(),
};
}
throw FormatException(
'Type de préférence non pris en charge : ${value.runtimeType}',
);
}
Object? _decodePreference(Map<String, dynamic> encoded) {
final type = encoded['type'];
final value = encoded['value'];
switch (type) {
case 'bool':
if (value is bool) return value;
break;
case 'int':
if (value is num) return value.toInt();
break;
case 'double':
if (value is num) return value.toDouble();
break;
case 'string':
if (value is String) return value;
break;
case 'stringList':
if (value is List && value.every((item) => item is String)) {
return value.cast<String>();
}
break;
}
throw FormatException('Valeur mémoire invalide pour le type $type.');
}
Future<void> _writePreference(String key, Object? value) async {
if (value is bool) {
await _preferences.setBool(key, value);
return;
}
if (value is int) {
await _preferences.setInt(key, value);
return;
}
if (value is double) {
await _preferences.setDouble(key, value);
return;
}
if (value is String) {
await _preferences.setString(key, value);
return;
}
if (value is List<String>) {
await _preferences.setStringList(key, value);
return;
}
throw FormatException(
'Type mémoire impossible à importer : ${value.runtimeType}',
);
}
Future<String> _getOrCreateDeviceId() async {
final existing = await _preferences.getString(_deviceIdKey);
if (existing != null && existing.trim().isNotEmpty) {
return existing;
}
final random = Random.secure();
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
final randomPart = bytes
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final id = '${Platform.operatingSystem}-$randomPart';
await _preferences.setString(_deviceIdKey, id);
return id;
}
String _fileTimestamp(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)}_'
'${two(value.hour)}-${two(value.minute)}-${two(value.second)}Z';
}
}
@@ -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() +
' ';
}
}
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
class SponsorSegment {
const SponsorSegment({
required this.id,
required this.start,
required this.end,
});
final String id;
final Duration start;
final Duration end;
Duration get duration => end - start;
bool contains(Duration position) {
return position >= start && position < end;
}
}
class SponsorBlockService {
SponsorBlockService._();
static final SponsorBlockService instance =
SponsorBlockService._();
final Map<String, Future<List<SponsorSegment>>> _cache =
<String, Future<List<SponsorSegment>>>{};
Future<List<SponsorSegment>> getSponsorSegments(
String videoId,
) {
return _cache.putIfAbsent(
videoId,
() => _fetch(videoId),
);
}
Future<List<SponsorSegment>> _fetch(String videoId) async {
try {
// SponsorBlock's k-anonymity endpoint receives only the first four
// hexadecimal characters of SHA-256(videoId), not the exact ID.
final fullHash = sha256
.convert(utf8.encode(videoId))
.toString();
final prefix = fullHash.substring(0, 4);
final uri = Uri.https(
'sponsor.ajay.app',
'/api/skipSegments/$prefix',
<String, String>{
'service': 'YouTube',
'categories': jsonEncode(<String>['sponsor']),
},
);
final response = await http
.get(
uri,
headers: const <String, String>{
'Accept': 'application/json',
'User-Agent': 'YouTubePersonal/1.0',
},
)
.timeout(const Duration(seconds: 6));
if (response.statusCode == 404) {
return const <SponsorSegment>[];
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return const <SponsorSegment>[];
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
return const <SponsorSegment>[];
}
Map? matchingVideo;
for (final item in decoded) {
if (item is! Map) {
continue;
}
final responseVideoId = item['videoID'];
final responseHash = item['hash'];
if (responseVideoId == videoId || responseHash == fullHash) {
matchingVideo = item;
break;
}
}
if (matchingVideo == null) {
return const <SponsorSegment>[];
}
final rawSegments = matchingVideo['segments'];
if (rawSegments is! List) {
return const <SponsorSegment>[];
}
final result = <SponsorSegment>[];
for (final item in rawSegments) {
if (item is! Map) {
continue;
}
if (item['category'] != 'sponsor') {
continue;
}
final actionType = item['actionType'];
if (actionType is String && actionType != 'skip') {
continue;
}
final segment = item['segment'];
if (segment is! List || segment.length < 2) {
continue;
}
final start = segment[0];
final end = segment[1];
if (start is! num || end is! num || end <= start) {
continue;
}
final id = item['UUID'] is String
? item['UUID'] as String
: '${start.toDouble()}-${end.toDouble()}';
result.add(
SponsorSegment(
id: id,
start: Duration(
milliseconds: (start.toDouble() * 1000).round(),
),
end: Duration(
milliseconds: (end.toDouble() * 1000).round(),
),
),
);
}
result.sort((a, b) => a.start.compareTo(b.start));
return List.unmodifiable(result);
} catch (_) {
// Sponsor skipping must never block or break normal playback.
return const <SponsorSegment>[];
}
}
}
@@ -0,0 +1,324 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../models/channel_details.dart';
/// Robust public channel metadata fallback for Windows.
///
/// youtube_explode_dart currently returns enough metadata for some channels,
/// but on current YouTube layouts subscriber/about fields may be null. The
/// project already ships a current yt-dlp nightly for HD playback, so Windows
/// can reuse it without adding another dependency or a Google login.
class WindowsChannelMetadataService {
WindowsChannelMetadataService._();
static final WindowsChannelMetadataService instance =
WindowsChannelMetadataService._();
final Map<String, Future<ChannelDetails?>> _cache =
<String, Future<ChannelDetails?>>{};
Future<ChannelDetails?> getDetails(
String channelId,
) {
if (!Platform.isWindows || channelId.trim().isEmpty) {
return Future<ChannelDetails?>.value(null);
}
return _cache.putIfAbsent(
channelId,
() => _load(channelId),
);
}
void invalidate(String channelId) {
_cache.remove(channelId);
}
Future<ChannelDetails?> _load(
String channelId,
) async {
final ytDlp = _resolveYtDlp();
if (!await ytDlp.exists()) {
debugPrint(
'[CHANNEL-YTDLP][18.5.2] yt-dlp introuvable: ${ytDlp.path}',
);
return null;
}
Map<String, dynamic>? videosData;
Map<String, dynamic>? aboutData;
try {
videosData = await _extract(
ytDlp,
'https://www.youtube.com/channel/$channelId/videos',
);
} catch (error) {
debugPrint(
'[CHANNEL-YTDLP][18.5.2] /videos échoué: $error',
);
}
// Current yt-dlp channel extraction normally provides followers on the
// channel URL. The About route can additionally expose channel views and
// description on layouts where /videos omits them. It is intentionally
// best-effort because YouTube has changed the About page several times.
if (_intValue(videosData?['view_count']) == null ||
_stringValue(videosData?['description']) == null) {
try {
aboutData = await _extract(
ytDlp,
'https://www.youtube.com/channel/$channelId/about',
);
} catch (error) {
debugPrint(
'[CHANNEL-YTDLP][18.5.2] /about ignoré: $error',
);
}
}
final data = _mergeMaps(
primary: videosData,
secondary: aboutData,
);
if (data == null) {
return null;
}
final subscribers =
_intValue(data['channel_follower_count']);
final views = _intValue(data['view_count']);
final rawVideoCount =
_intValue(data['playlist_count']);
final videoCount =
rawVideoCount != null && rawVideoCount > 0
? rawVideoCount
: null;
final title =
_stringValue(data['channel']) ??
_stringValue(data['uploader']) ??
_stringValue(data['title']) ??
'';
final description =
_stringValue(data['description']) ?? '';
final thumbnail = _bestThumbnail(
data['thumbnails'],
);
debugPrint(
'[CHANNEL-YTDLP][18.5.2] channel=$channelId '
'subs=${subscribers ?? 'NA'} '
'views=${views ?? 'NA'} '
'videos=${videoCount ?? 'NA'} '
'title=${title.isEmpty ? 'NA' : title}',
);
if (subscribers == null &&
views == null &&
videoCount == null &&
title.isEmpty &&
description.isEmpty) {
return null;
}
return ChannelDetails(
channelId: channelId,
title: title,
description: description,
thumbnailUrl: thumbnail ?? '',
viewCount: views,
subscriberCount: subscribers,
hiddenSubscriberCount: subscribers == null,
videoCount: videoCount,
);
}
Future<Map<String, dynamic>?> _extract(
File ytDlp,
String url,
) async {
final args = <String>[
'--ignore-config',
'--quiet',
'--no-warnings',
'--encoding',
'utf-8',
'--socket-timeout',
'15',
'--retries',
'2',
'--flat-playlist',
'--playlist-items',
'0',
'--skip-download',
'--dump-single-json',
url,
];
final result = await Process.run(
ytDlp.path,
args,
workingDirectory: ytDlp.parent.path,
runInShell: false,
stdoutEncoding: utf8,
stderrEncoding: utf8,
).timeout(
const Duration(seconds: 25),
);
if (result.exitCode != 0) {
throw StateError(
_compactError(result.stderr?.toString() ?? ''),
);
}
final stdout = result.stdout?.toString().trim() ?? '';
if (stdout.isEmpty) {
return null;
}
// Normally --dump-single-json produces one JSON document. If yt-dlp ever
// prints a harmless prefix, recover from the first opening brace.
final jsonStart = stdout.indexOf('{');
final jsonText = jsonStart >= 0
? stdout.substring(jsonStart)
: stdout;
final decoded = jsonDecode(jsonText);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
return null;
}
Map<String, dynamic>? _mergeMaps({
required Map<String, dynamic>? primary,
required Map<String, dynamic>? secondary,
}) {
if (primary == null && secondary == null) {
return null;
}
final merged = <String, dynamic>{};
if (secondary != null) {
merged.addAll(secondary);
}
if (primary != null) {
// Prefer non-null values from /videos, but don't overwrite useful
// About metadata with nulls.
for (final entry in primary.entries) {
if (entry.value != null) {
merged[entry.key] = entry.value;
}
}
}
return merged;
}
File _resolveYtDlp() {
final executableDirectory =
File(Platform.resolvedExecutable).parent;
final bundled = File(
'${executableDirectory.path}'
'${Platform.pathSeparator}data'
'${Platform.pathSeparator}flutter_assets'
'${Platform.pathSeparator}assets'
'${Platform.pathSeparator}windows_hd'
'${Platform.pathSeparator}yt-dlp.exe',
);
if (bundled.existsSync()) {
return bundled;
}
return File(
'${Directory.current.path}'
'${Platform.pathSeparator}assets'
'${Platform.pathSeparator}windows_hd'
'${Platform.pathSeparator}yt-dlp.exe',
);
}
int? _intValue(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
String? _stringValue(dynamic value) {
if (value == null) {
return null;
}
final text = value.toString().trim();
return text.isEmpty ? null : text;
}
String? _bestThumbnail(dynamic raw) {
if (raw is! List || raw.isEmpty) {
return null;
}
String? bestUrl;
int bestArea = -1;
for (final item in raw) {
if (item is! Map) {
continue;
}
final url = _stringValue(item['url']);
if (url == null) {
continue;
}
final width = _intValue(item['width']) ?? 0;
final height = _intValue(item['height']) ?? 0;
final area = width * height;
if (bestUrl == null || area > bestArea) {
bestUrl = url;
bestArea = area;
}
}
return bestUrl;
}
String _compactError(String value) {
final normalized = value
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.length <= 350) {
return normalized;
}
return '${normalized.substring(0, 350)}…';
}
}
@@ -0,0 +1,457 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../models/video_item.dart';
/// Bulk channel-video metadata for Windows.
///
/// One yt-dlp process reads the flat channel playlist instead of issuing one
/// YouTube watch-page request for every video. This prevents the request storm
/// that caused RequestLimitExceededException in step 18.7.
class WindowsChannelVideoCatalogService {
WindowsChannelVideoCatalogService._();
static final WindowsChannelVideoCatalogService instance =
WindowsChannelVideoCatalogService._();
final Map<String, Future<Map<String, VideoItem>>> _cache =
<String, Future<Map<String, VideoItem>>>{};
Future<Map<String, VideoItem>> getMetadata(
String channelId, {
bool force = false,
}) {
if (!Platform.isWindows ||
channelId.trim().isEmpty) {
return Future<Map<String, VideoItem>>.value(
const <String, VideoItem>{},
);
}
if (force) {
_cache.remove(channelId);
}
return _cache.putIfAbsent(
channelId,
() => _load(channelId),
);
}
Future<Map<String, VideoItem>> _load(
String channelId,
) async {
final ytDlp = _resolveYtDlp();
if (!await ytDlp.exists()) {
debugPrint(
'[CHANNEL-CATALOG][18.7.6] '
'yt-dlp introuvable: ${ytDlp.path}',
);
return const <String, VideoItem>{};
}
final args = <String>[
'--ignore-config',
'--quiet',
'--no-warnings',
'--encoding',
'utf-8',
'--socket-timeout',
'15',
'--retries',
'1',
'--flat-playlist',
'--extractor-args',
'youtubetab:approximate_date',
'--dump-json',
'https://www.youtube.com/channel/$channelId/videos',
];
debugPrint(
'[CHANNEL-CATALOG][18.7.6] '
'bulk start channel=$channelId',
);
Process? process;
try {
process = await Process.start(
ytDlp.path,
args,
workingDirectory:
ytDlp.parent.path,
runInShell: false,
mode:
ProcessStartMode.normal,
);
final result =
<String, VideoItem>{};
final stderr = StringBuffer();
final stderrSubscription =
process.stderr
.transform(utf8.decoder)
.listen(
(text) {
if (stderr.length < 3000) {
stderr.write(text);
}
},
);
final completer =
Completer<void>();
final stdoutSubscription =
process.stdout
.transform(utf8.decoder)
.transform(
const LineSplitter(),
)
.listen(
(line) {
final text = line.trim();
if (text.isEmpty ||
!text.startsWith('{')) {
return;
}
try {
final decoded =
jsonDecode(text);
if (decoded is! Map) {
return;
}
final data =
Map<String, dynamic>.from(
decoded,
);
final video =
_fromFlatJson(
data,
);
if (video != null) {
result[video.id] = video;
}
} catch (_) {
// One malformed flat entry must not abort the catalogue.
}
},
onDone: () {
if (!completer.isCompleted) {
completer.complete();
}
},
onError: (Object error) {
if (!completer.isCompleted) {
completer.completeError(error);
}
},
cancelOnError: false,
);
final exitCodeFuture =
process.exitCode;
await Future.wait<void>([
completer.future,
exitCodeFuture.then<void>(
(_) {},
),
]).timeout(
const Duration(seconds: 35),
onTimeout: () {
process?.kill();
throw TimeoutException(
'Catalogue yt-dlp > 35 s',
);
},
);
await stdoutSubscription.cancel();
await stderrSubscription.cancel();
final exitCode =
await exitCodeFuture;
if (exitCode != 0 &&
result.isEmpty) {
final errorText = stderr
.toString()
.replaceAll(
RegExp(r'\s+'),
' ',
)
.trim();
debugPrint(
'[CHANNEL-CATALOG][18.7.6] '
'bulk code=$exitCode '
'${errorText.isEmpty ? '' : errorText}',
);
return const <String, VideoItem>{};
}
debugPrint(
'[CHANNEL-CATALOG][18.7.6] '
'bulk ready channel=$channelId '
'videos=${result.length}',
);
return Map.unmodifiable(result);
} catch (error) {
process?.kill();
debugPrint(
'[CHANNEL-CATALOG][18.7.6] '
'bulk fallback: $error',
);
return const <String, VideoItem>{};
}
}
VideoItem? _fromFlatJson(
Map<String, dynamic> data,
) {
final id =
_string(data['id']);
if (id == null ||
id.isEmpty) {
return null;
}
final title =
_string(data['title']) ??
'Vidéo YouTube';
final channelId =
_string(data['channel_id']) ??
_string(data['uploader_id']);
final author =
_string(data['channel']) ??
_string(data['uploader']) ??
'';
final durationSeconds =
_number(data['duration']);
final timestamp =
_number(data['timestamp']) ??
_number(
data['release_timestamp'],
);
final uploadDate =
timestamp != null
? DateTime
.fromMillisecondsSinceEpoch(
(timestamp * 1000)
.round(),
isUtc: true,
)
.toLocal()
: _parseUploadDate(
_string(
data['upload_date'],
),
);
final thumbnail =
_bestThumbnail(
data['thumbnails'],
) ??
'https://i.ytimg.com/vi/$id/hqdefault.jpg';
return VideoItem(
id: id,
title: title,
author: author,
channelId: channelId,
thumbnailUrl: thumbnail,
duration: durationSeconds != null &&
durationSeconds > 0
? Duration(
milliseconds:
(durationSeconds * 1000)
.round(),
)
: null,
viewCount:
_integer(data['view_count']),
likeCount:
_integer(data['like_count']),
commentCount:
_integer(data['comment_count']),
description:
_string(data['description']) ??
'',
uploadDate: uploadDate,
);
}
DateTime? _parseUploadDate(
String? raw,
) {
if (raw == null ||
raw.length != 8) {
return null;
}
final year =
int.tryParse(
raw.substring(0, 4),
);
final month =
int.tryParse(
raw.substring(4, 6),
);
final day =
int.tryParse(
raw.substring(6, 8),
);
if (year == null ||
month == null ||
day == null) {
return null;
}
try {
return DateTime(
year,
month,
day,
);
} catch (_) {
return null;
}
}
String? _bestThumbnail(
dynamic raw,
) {
if (raw is! List ||
raw.isEmpty) {
return null;
}
String? best;
int bestArea = -1;
for (final item in raw) {
if (item is! Map) {
continue;
}
final url =
_string(item['url']);
if (url == null) {
continue;
}
final width =
_integer(item['width']) ??
0;
final height =
_integer(item['height']) ??
0;
final area =
width * height;
if (best == null ||
area > bestArea) {
best = url;
bestArea = area;
}
}
return best;
}
int? _integer(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
double? _number(dynamic value) {
if (value is num) {
return value.toDouble();
}
if (value is String) {
return double.tryParse(value);
}
return null;
}
String? _string(dynamic value) {
if (value == null) {
return null;
}
final text =
value.toString().trim();
return text.isEmpty
? null
: text;
}
File _resolveYtDlp() {
final executableDirectory =
File(
Platform.resolvedExecutable,
).parent;
final bundled = File(
'${executableDirectory.path}'
'${Platform.pathSeparator}data'
'${Platform.pathSeparator}flutter_assets'
'${Platform.pathSeparator}assets'
'${Platform.pathSeparator}windows_hd'
'${Platform.pathSeparator}yt-dlp.exe',
);
if (bundled.existsSync()) {
return bundled;
}
return File(
'${Directory.current.path}'
'${Platform.pathSeparator}assets'
'${Platform.pathSeparator}windows_hd'
'${Platform.pathSeparator}yt-dlp.exe',
);
}
}
@@ -0,0 +1,646 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
class WindowsEmbeddedCacheSession {
const WindowsEmbeddedCacheSession({
required this.videoUrl,
required this.audioUrl,
});
final String videoUrl;
final String audioUrl;
}
/// Cache progressif Windows pour le chemin `web_embedded`.
///
/// Le test manuel a démontré que yt-dlp télécharge intégralement :
/// - le format vidéo 299 (1080p60) ;
/// - le format audio 251 (Opus).
///
/// Au lieu de donner les URLs googlevideo à libmpv, cette classe lance les
/// mêmes commandes yt-dlp validées vers deux fichiers temporaires qui
/// grandissent pendant le téléchargement.
///
/// libmpv lit uniquement un serveur HTTP loopback :
///
/// libmpv -> 127.0.0.1 -> fichier temporaire en croissance
/// ^
/// |
/// yt-dlp web_embedded
/// |
/// googlevideo
///
/// Le lecteur peut commencer avant la fin du téléchargement complet.
/// Lors d'un seek vers une zone qui n'est pas encore en cache, le serveur
/// attend simplement que yt-dlp atteigne cette zone.
class WindowsEmbeddedCacheRelayService {
HttpServer? _server;
StreamSubscription<HttpRequest>? _serverSubscription;
Directory? _sessionDirectory;
final Map<String, _GrowingSource> _sources =
<String, _GrowingSource>{};
Future<WindowsEmbeddedCacheSession> start({
required String ytDlpPath,
required String denoPath,
required String watchUrl,
required String videoFormatId,
required String audioFormatId,
required int videoLength,
required int audioLength,
required String videoExt,
required String audioExt,
required String videoMimeType,
required String audioMimeType,
}) async {
if (!Platform.isWindows) {
throw UnsupportedError(
'Le cache web_embedded est réservé à Windows.',
);
}
await stop();
final ytDlp = File(ytDlpPath);
final deno = File(denoPath);
if (!await ytDlp.exists()) {
throw StateError(
'yt-dlp.exe introuvable : $ytDlpPath',
);
}
if (!await deno.exists()) {
throw StateError(
'deno.exe introuvable : $denoPath',
);
}
if (videoLength <= 0 || audioLength <= 0) {
throw StateError(
'Tailles web_embedded invalides : '
'video=$videoLength audio=$audioLength',
);
}
final sessionDirectory =
await Directory.systemTemp.createTemp(
'youtube_personal_embedded_cache_',
);
final videoFile = File(
'${sessionDirectory.path}'
'${Platform.pathSeparator}'
'video.$videoExt',
);
final audioFile = File(
'${sessionDirectory.path}'
'${Platform.pathSeparator}'
'audio.$audioExt',
);
final video = _GrowingSource(
id: 'video',
file: videoFile,
formatId: videoFormatId,
length: videoLength,
contentType: videoMimeType,
);
final audio = _GrowingSource(
id: 'audio',
file: audioFile,
formatId: audioFormatId,
length: audioLength,
contentType: audioMimeType,
);
_sessionDirectory = sessionDirectory;
_sources
..clear()
..['/video'] = video
..['/audio'] = audio;
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
shared: false,
);
_server = server;
_serverSubscription = server.listen(
(request) {
unawaited(_handleRequest(request));
},
onError: (Object error, StackTrace stackTrace) {
debugPrint(
'[HD-CACHE][17.15] erreur serveur local: $error',
);
},
cancelOnError: false,
);
// On lance vidéo et audio en parallèle. C'est volontaire :
// yt-dlp peut imposer quelques secondes de sleep avant le téléchargement.
// L'audio 251 est petit et sera normalement entièrement mis en cache
// très rapidement, pendant que la vidéo continue à grossir.
await Future.wait<void>([
_startYtDlp(
source: video,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
),
_startYtDlp(
source: audio,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
),
]);
final base =
'http://127.0.0.1:${server.port}';
debugPrint(
'[HD-CACHE][17.15] actif sur 127.0.0.1:${server.port} '
'video=$videoFormatId audio=$audioFormatId',
);
return WindowsEmbeddedCacheSession(
videoUrl: '$base/video',
audioUrl: '$base/audio',
);
}
Future<void> stop() async {
final subscription = _serverSubscription;
_serverSubscription = null;
if (subscription != null) {
await subscription.cancel();
}
final server = _server;
_server = null;
if (server != null) {
await server.close(force: true);
}
final currentSources =
_sources.values.toList(growable: false);
for (final source in currentSources) {
final process = source.process;
if (process != null) {
process.kill();
}
}
// Laisse Windows libérer les handles de fichiers/processus.
await Future<void>.delayed(
const Duration(milliseconds: 120),
);
_sources.clear();
final directory = _sessionDirectory;
_sessionDirectory = null;
if (directory != null) {
try {
if (await directory.exists()) {
await directory.delete(
recursive: true,
);
}
} catch (error) {
debugPrint(
'[HD-CACHE][17.15] nettoyage différé: $error',
);
}
}
}
Future<void> _startYtDlp({
required _GrowingSource source,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
}) async {
final args = <String>[
'--ignore-config',
'--no-playlist',
'--no-warnings',
'--no-progress',
'--socket-timeout',
'20',
'--retries',
'5',
'--fragment-retries',
'5',
'--js-runtimes',
'deno:$denoPath',
'--extractor-args',
'youtube:player_client=web_embedded',
'--force-overwrites',
'--no-part',
'-f',
source.formatId,
'-o',
source.file.path,
watchUrl,
];
debugPrint(
'[HD-CACHE][17.15] lancement yt-dlp '
'${source.id} format=${source.formatId}',
);
final process = await Process.start(
ytDlpPath,
args,
workingDirectory:
File(ytDlpPath).parent.path,
runInShell: false,
mode: ProcessStartMode.normal,
);
source.process = process;
process.stdout
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
final compact =
text.replaceAll(RegExp(r'\s+'), ' ').trim();
if (compact.isNotEmpty &&
compact.toLowerCase().contains('error')) {
debugPrint(
'[HD-CACHE][17.15] ${source.id} stdout: $compact',
);
}
},
);
process.stderr
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
if (source.stderr.length < 6000) {
final remaining =
6000 - source.stderr.length;
source.stderr.write(
text.length <= remaining
? text
: text.substring(0, remaining),
);
}
},
);
unawaited(
process.exitCode.then(
(code) async {
source.exitCode = code;
if (!source.done.isCompleted) {
source.done.complete(code);
}
final size = await _safeLength(
source.file,
);
if (code == 0) {
debugPrint(
'[HD-CACHE][17.15] ${source.id} téléchargé: '
'$size/${source.length} octets',
);
} else {
final stderr = source.stderr
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
debugPrint(
'[HD-CACHE][17.15] ${source.id} yt-dlp code=$code'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
},
),
);
}
Future<void> _handleRequest(
HttpRequest request,
) async {
final source = _sources[request.uri.path];
if (source == null) {
request.response.statusCode =
HttpStatus.notFound;
await request.response.close();
return;
}
if (request.method != 'GET' &&
request.method != 'HEAD') {
request.response.statusCode =
HttpStatus.methodNotAllowed;
await request.response.close();
return;
}
final rangeHeader =
request.headers.value(
HttpHeaders.rangeHeader,
);
final parsed = _parseRange(
rangeHeader,
source.length,
);
if (rangeHeader != null && parsed == null) {
request.response.statusCode =
HttpStatus.requestedRangeNotSatisfiable;
request.response.headers.set(
'content-range',
'bytes */${source.length}',
);
await request.response.close();
return;
}
final start = parsed?.$1 ?? 0;
final end =
parsed?.$2 ?? source.length - 1;
final response = request.response;
response.statusCode = parsed == null
? HttpStatus.ok
: HttpStatus.partialContent;
response.headers.set(
'accept-ranges',
'bytes',
);
response.headers.set(
HttpHeaders.cacheControlHeader,
'no-store',
);
response.headers.set(
HttpHeaders.contentTypeHeader,
source.contentType,
);
final responseLength =
end - start + 1;
response.contentLength =
responseLength;
if (parsed != null) {
response.headers.set(
'content-range',
'bytes $start-$end/${source.length}',
);
}
debugPrint(
'[HD-CACHE][17.15] ${request.uri.path} '
'${request.method} bytes=$start-$end',
);
if (request.method == 'HEAD') {
await response.close();
return;
}
try {
await _streamGrowingFile(
source: source,
start: start,
end: end,
response: response,
);
} on Object catch (error) {
debugPrint(
'[HD-CACHE][17.15] ${request.uri.path} échec: $error',
);
try {
await response.close();
} catch (_) {
// libmpv peut fermer une requête lors d'un seek.
}
}
}
Future<void> _streamGrowingFile({
required _GrowingSource source,
required int start,
required int end,
required HttpResponse response,
}) async {
var cursor = start;
RandomAccessFile? raf;
try {
while (cursor <= end) {
final available =
await _safeLength(source.file);
if (available > cursor) {
raf ??=
await source.file.open(
mode: FileMode.read,
);
final readable = math.min(
available - cursor,
end - cursor + 1,
);
final count = math.min(
readable,
512 * 1024,
);
await raf.setPosition(cursor);
final data =
await raf.read(count);
if (data.isEmpty) {
await Future<void>.delayed(
const Duration(milliseconds: 30),
);
continue;
}
response.add(data);
await response.flush();
cursor += data.length;
continue;
}
final exitCode = source.exitCode;
if (exitCode != null) {
if (exitCode != 0) {
final stderr = source.stderr
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
throw HttpException(
'yt-dlp ${source.id} code=$exitCode'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
final finalLength =
await _safeLength(source.file);
if (finalLength <= cursor) {
throw HttpException(
'Cache ${source.id} terminé à '
'$finalLength/${source.length} octets, '
'mais mpv demande l’offset $cursor.',
);
}
}
// Le téléchargement est encore en cours et n'a pas atteint la zone.
await Future<void>.delayed(
const Duration(milliseconds: 35),
);
}
await response.close();
} finally {
await raf?.close();
}
}
(int, int)? _parseRange(
String? value,
int totalLength,
) {
if (value == null || value.trim().isEmpty) {
return null;
}
final match = RegExp(
r'^bytes=(\d*)-(\d*)$',
caseSensitive: false,
).firstMatch(value.trim());
if (match == null) {
return null;
}
final startText = match.group(1) ?? '';
final endText = match.group(2) ?? '';
if (startText.isEmpty) {
final suffix = int.tryParse(endText);
if (suffix == null || suffix <= 0) {
return null;
}
final start = math.max(
0,
totalLength - suffix,
);
return (start, totalLength - 1);
}
final start = int.tryParse(startText);
if (start == null ||
start < 0 ||
start >= totalLength) {
return null;
}
final parsedEnd = endText.isEmpty
? totalLength - 1
: int.tryParse(endText);
if (parsedEnd == null ||
parsedEnd < start) {
return null;
}
final end = math.min(
parsedEnd,
totalLength - 1,
);
return (start, end);
}
Future<int> _safeLength(
File file,
) async {
try {
if (!await file.exists()) {
return 0;
}
return await file.length();
} catch (_) {
return 0;
}
}
}
class _GrowingSource {
_GrowingSource({
required this.id,
required this.file,
required this.formatId,
required this.length,
required this.contentType,
});
final String id;
final File file;
final String formatId;
final int length;
final String contentType;
Process? process;
int? exitCode;
final Completer<int> done =
Completer<int>();
final StringBuffer stderr =
StringBuffer();
}
@@ -0,0 +1,518 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'media_cache_settings_service.dart';
typedef WindowsDownloadProgress = void Function(
String track,
double percent,
);
class WindowsEmbeddedLocalMedia {
const WindowsEmbeddedLocalMedia({
required this.videoUri,
required this.audioUri,
required this.videoPath,
required this.audioPath,
required this.videoFromCache,
required this.audioFromCache,
});
final String videoUri;
final String audioUri;
final String videoPath;
final String audioPath;
final bool videoFromCache;
final bool audioFromCache;
}
/// Mode Windows fiable et plus rapide.
///
/// Le transport reste exactement celui validé en 17.16 : yt-dlp télécharge
/// complètement les pistes, puis media_kit ouvre uniquement des fichiers
/// locaux.
///
/// Optimisations 17.17 :
/// - cache persistant vérifié sous LOCALAPPDATA ;
/// - un fichier déjà téléchargé n'est jamais repris du réseau ;
/// - vidéo & audio manquants sont téléchargés en parallèle ;
/// - les anciennes vidéos sont automatiquement limitées aux 5 plus récentes ;
/// - callback de progression pour que l'UI affiche l'avancement.
class WindowsEmbeddedDownloadService {
final Set<Process> _activeProcesses =
<Process>{};
Future<WindowsEmbeddedLocalMedia> download({
required String videoId,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
required String videoFormatId,
required String audioFormatId,
required String videoExt,
required String audioExt,
required int expectedVideoLength,
required int expectedAudioLength,
WindowsDownloadProgress? onProgress,
}) async {
if (!Platform.isWindows) {
throw UnsupportedError(
'Le téléchargement web_embedded est réservé à Windows.',
);
}
await stop();
final ytDlp = File(ytDlpPath);
final deno = File(denoPath);
if (!await ytDlp.exists()) {
throw StateError(
'yt-dlp.exe introuvable : $ytDlpPath',
);
}
if (!await deno.exists()) {
throw StateError(
'deno.exe introuvable : $denoPath',
);
}
final cacheSettings = MediaCacheSettingsService.instance;
final cacheRoot = await cacheSettings.resolveCacheRoot();
final safeVideoId = _safeName(videoId);
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
final videoDirectory = Directory(
'${cacheRoot.path}'
'${Platform.pathSeparator}'
'$safeVideoId',
);
await videoDirectory.create(
recursive: true,
);
final videoFile = File(
'${videoDirectory.path}'
'${Platform.pathSeparator}'
'video_${_safeName(videoFormatId)}.$videoExt',
);
final audioFile = File(
'${videoDirectory.path}'
'${Platform.pathSeparator}'
'audio_${_safeName(audioFormatId)}.$audioExt',
);
final videoHit = await _isValidCacheFile(
videoFile,
expectedVideoLength,
);
final audioHit = await _isValidCacheFile(
audioFile,
expectedAudioLength,
);
debugPrint(
'[HD-CACHE][17.17] '
'video=${videoHit ? 'HIT' : 'MISS'} '
'audio=${audioHit ? 'HIT' : 'MISS'} '
'dossier=${videoDirectory.path}',
);
if (videoHit) {
onProgress?.call('video', 100);
}
if (audioHit) {
onProgress?.call('audio', 100);
}
final futures = <Future<_DownloadResult>>[];
if (!videoHit) {
await _deleteIfExists(videoFile);
futures.add(
_downloadOne(
id: 'video',
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
formatId: videoFormatId,
outputFile: videoFile,
expectedLength: expectedVideoLength,
onProgress: onProgress,
),
);
}
if (!audioHit) {
await _deleteIfExists(audioFile);
futures.add(
_downloadOne(
id: 'audio',
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
formatId: audioFormatId,
outputFile: audioFile,
expectedLength: expectedAudioLength,
onProgress: onProgress,
),
);
}
if (futures.isNotEmpty) {
debugPrint(
'[HD-DOWNLOAD][17.17] téléchargement '
'${videoHit ? '' : 'video=$videoFormatId '}'
'${audioHit ? '' : 'audio=$audioFormatId'}',
);
final results =
await Future.wait<_DownloadResult>(
futures,
);
final failed = results
.where((result) => !result.ok)
.toList(growable: false);
if (failed.isNotEmpty) {
throw StateError(
failed.map((result) => result.error).join(' | '),
);
}
}
final videoLength = await _safeLength(videoFile);
final audioLength = await _safeLength(audioFile);
if (!_lengthMatches(
videoLength,
expectedVideoLength,
) ||
!_lengthMatches(
audioLength,
expectedAudioLength,
)) {
throw StateError(
'Cache final invalide: '
'video=$videoLength/$expectedVideoLength '
'audio=$audioLength/$expectedAudioLength',
);
}
// Marque ce cache comme récemment utilisé.
//
// On utilise un petit fichier marqueur dont la date de modification
// sert de timestamp LRU pour le nettoyage des anciens caches.
final accessMarker = File(
'${videoDirectory.path}'
'${Platform.pathSeparator}'
'.last_access',
);
try {
await accessMarker.writeAsString(
DateTime.now().toIso8601String(),
flush: true,
);
} catch (_) {
// Non bloquant.
}
await cacheSettings.touchVideoDirectory(videoDirectory);
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
debugPrint(
'[HD-DOWNLOAD][17.17] prêts: '
'video=$videoLength/$expectedVideoLength '
'audio=$audioLength/$expectedAudioLength',
);
return WindowsEmbeddedLocalMedia(
videoUri: Uri.file(
videoFile.path,
windows: true,
).toString(),
audioUri: Uri.file(
audioFile.path,
windows: true,
).toString(),
videoPath: videoFile.path,
audioPath: audioFile.path,
videoFromCache: videoHit,
audioFromCache: audioHit,
);
}
Future<_DownloadResult> _downloadOne({
required String id,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
required String formatId,
required File outputFile,
required int expectedLength,
required WindowsDownloadProgress? onProgress,
}) async {
final args = <String>[
'--ignore-config',
'--no-playlist',
'--no-warnings',
'--newline',
'--socket-timeout',
'20',
'--retries',
'5',
'--fragment-retries',
'5',
'--js-runtimes',
'deno:$denoPath',
'--extractor-args',
'youtube:player_client=web_embedded',
'--force-overwrites',
'--no-part',
'-f',
formatId,
'-o',
outputFile.path,
watchUrl,
];
final process = await Process.start(
ytDlpPath,
args,
workingDirectory:
File(ytDlpPath).parent.path,
runInShell: false,
mode: ProcessStartMode.normal,
);
_activeProcesses.add(process);
final stderr = StringBuffer();
var lastLoggedBucket = -1;
final stdoutSubscription = process.stdout
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
for (final line
in text.split(RegExp(r'[\r\n]+'))) {
final match = RegExp(
r'\[download\]\s+(\d+(?:\.\d+)?)%',
).firstMatch(line);
if (match == null) {
continue;
}
final value = double.tryParse(
match.group(1) ?? '',
);
if (value == null) {
continue;
}
onProgress?.call(id, value);
final bucket = value >= 100
? 10
: value.floor() ~/ 10;
if (bucket > lastLoggedBucket) {
lastLoggedBucket = bucket;
debugPrint(
'[HD-DOWNLOAD][17.17] '
'$id ${value.toStringAsFixed(1)}%',
);
}
}
},
);
final stderrSubscription = process.stderr
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
if (stderr.length < 5000) {
final remaining =
5000 - stderr.length;
stderr.write(
text.length <= remaining
? text
: text.substring(0, remaining),
);
}
},
);
final exitCode = await process.exitCode;
await stdoutSubscription.cancel();
await stderrSubscription.cancel();
_activeProcesses.remove(process);
final actualLength =
await _safeLength(outputFile);
if (exitCode != 0) {
final compact = stderr
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
await _deleteIfExists(outputFile);
return _DownloadResult(
ok: false,
error:
'$id yt-dlp code=$exitCode'
'${compact.isEmpty ? '' : ': $compact'}',
);
}
if (!_lengthMatches(
actualLength,
expectedLength,
)) {
await _deleteIfExists(outputFile);
return _DownloadResult(
ok: false,
error:
'$id taille invalide '
'$actualLength/$expectedLength',
);
}
onProgress?.call(id, 100);
debugPrint(
'[HD-DOWNLOAD][17.17] $id terminé: '
'$actualLength/$expectedLength octets',
);
return const _DownloadResult(
ok: true,
error: '',
);
}
String _safeName(String value) {
final safe = value.replaceAll(
RegExp(r'[^A-Za-z0-9._-]'),
'_',
);
return safe.isEmpty ? 'media' : safe;
}
bool _lengthMatches(
int actual,
int expected,
) {
if (actual <= 0) {
return false;
}
if (expected <= 0) {
return true;
}
return actual == expected;
}
Future<bool> _isValidCacheFile(
File file,
int expectedLength,
) async {
final length = await _safeLength(file);
if (_lengthMatches(
length,
expectedLength,
)) {
return true;
}
if (length > 0) {
await _deleteIfExists(file);
}
return false;
}
Future<int> _safeLength(
File file,
) async {
try {
if (!await file.exists()) {
return 0;
}
return await file.length();
} catch (_) {
return 0;
}
}
Future<void> _deleteIfExists(
File file,
) async {
try {
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Le prochain téléchargement donnera une erreur explicite si besoin.
}
}
Future<void> stop() async {
final processes =
_activeProcesses.toList(growable: false);
_activeProcesses.clear();
for (final process in processes) {
process.kill();
}
if (processes.isNotEmpty) {
await Future<void>.delayed(
const Duration(milliseconds: 120),
);
}
// Important : le cache persistant n'est PAS supprimé ici.
// C'est ce qui rend une seconde lecture quasi immédiate.
}
}
class _DownloadResult {
const _DownloadResult({
required this.ok,
required this.error,
});
final bool ok;
final String error;
}
@@ -0,0 +1,937 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'media_cache_settings_service.dart';
class WindowsEmbeddedProgressiveSession {
const WindowsEmbeddedProgressiveSession({
required this.videoUrl,
required this.audioUri,
required this.videoFromCache,
required this.audioFromCache,
});
final String videoUrl;
final String audioUri;
final bool videoFromCache;
final bool audioFromCache;
}
class WindowsEmbeddedProgressiveStatus {
const WindowsEmbeddedProgressiveStatus({
required this.videoBytes,
required this.videoLength,
required this.audioBytes,
required this.audioLength,
required this.videoComplete,
required this.audioComplete,
required this.estimatedBufferedDuration,
});
final int videoBytes;
final int videoLength;
final int audioBytes;
final int audioLength;
final bool videoComplete;
final bool audioComplete;
final Duration estimatedBufferedDuration;
}
/// Streaming progressif Windows basé sur le transport déjà validé yt-dlp.
///
/// yt-dlp télécharge le fichier web_embedded pendant que libmpv lit une
/// façade HTTP locale du fichier qui grandit.
///
/// Différence critique avec l'étape 17.15 :
/// on N'OUVRE PLUS libmpv lorsque le fichier fait 0 octet. Le lecteur 360p
/// reste actif jusqu'à ce qu'une vraie réserve HD soit disponible autour du
/// timestamp courant.
///
/// Les fichiers restent dans le cache persistant :
/// %LOCALAPPDATA%\YouTubePersonal\media_cache\<videoId>
class WindowsEmbeddedProgressiveService {
HttpServer? _server;
StreamSubscription<HttpRequest>? _serverSubscription;
final Set<Process> _activeProcesses =
<Process>{};
_GrowingSource? _video;
_GrowingSource? _audio;
String? _activeVideoCacheId;
Directory? _activeVideoDirectory;
Future<WindowsEmbeddedProgressiveSession> start({
required String videoId,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
required String videoFormatId,
required String audioFormatId,
required String videoExt,
required String audioExt,
required String videoMimeType,
required int videoLength,
required int audioLength,
}) async {
if (!Platform.isWindows) {
throw UnsupportedError(
'Le streaming progressif HD est réservé à Windows.',
);
}
await stop();
final ytDlp = File(ytDlpPath);
final deno = File(denoPath);
if (!await ytDlp.exists()) {
throw StateError(
'yt-dlp.exe introuvable : $ytDlpPath',
);
}
if (!await deno.exists()) {
throw StateError(
'deno.exe introuvable : $denoPath',
);
}
if (videoLength <= 0 || audioLength <= 0) {
throw StateError(
'Tailles web_embedded invalides : '
'video=$videoLength audio=$audioLength',
);
}
final cacheSettings = MediaCacheSettingsService.instance;
final cacheRoot = await cacheSettings.resolveCacheRoot();
final safeVideoId = _safeName(videoId);
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
final videoDirectory = Directory(
'${cacheRoot.path}'
'${Platform.pathSeparator}'
'$safeVideoId',
);
await videoDirectory.create(
recursive: true,
);
_activeVideoCacheId = safeVideoId;
_activeVideoDirectory = videoDirectory;
await cacheSettings.touchVideoDirectory(videoDirectory);
final videoFile = File(
'${videoDirectory.path}'
'${Platform.pathSeparator}'
'video_${_safeName(videoFormatId)}.$videoExt',
);
final audioFile = File(
'${videoDirectory.path}'
'${Platform.pathSeparator}'
'audio_${_safeName(audioFormatId)}.$audioExt',
);
final videoHit =
await _isComplete(
videoFile,
videoLength,
);
final audioHit =
await _isComplete(
audioFile,
audioLength,
);
// Un ancien fichier incomplet ne doit pas être pris pour un HIT.
// On le recrée proprement; yt-dlp continuera ensuite à le remplir
// pendant la lecture.
if (!videoHit) {
await _deleteIfExists(videoFile);
}
if (!audioHit) {
await _deleteIfExists(audioFile);
}
final video = _GrowingSource(
id: 'video',
file: videoFile,
expectedLength: videoLength,
formatId: videoFormatId,
);
final audio = _GrowingSource(
id: 'audio',
file: audioFile,
expectedLength: audioLength,
formatId: audioFormatId,
);
if (videoHit) {
video.exitCode = 0;
}
if (audioHit) {
audio.exitCode = 0;
}
_video = video;
_audio = audio;
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
shared: false,
);
_server = server;
_serverSubscription = server.listen(
(request) {
unawaited(
_handleVideoRequest(
request,
video,
videoMimeType,
),
);
},
onError: (Object error, StackTrace stackTrace) {
debugPrint(
'[HD-PROGRESSIVE][17.19] serveur local: $error',
);
},
cancelOnError: false,
);
if (!videoHit) {
await _startDownload(
source: video,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
);
}
if (!audioHit) {
await _startDownload(
source: audio,
ytDlpPath: ytDlp.path,
denoPath: deno.path,
watchUrl: watchUrl,
);
}
final base =
'http://127.0.0.1:${server.port}';
debugPrint(
'[HD-PROGRESSIVE][17.19] session prête '
'video=${videoHit ? 'HIT' : 'STREAM'} '
'audio=${audioHit ? 'HIT' : 'DOWNLOAD'} '
'video=$videoFormatId audio=$audioFormatId',
);
return WindowsEmbeddedProgressiveSession(
videoUrl: '$base/video',
audioUri: Uri.file(
audioFile.path,
windows: true,
).toString(),
videoFromCache: videoHit,
audioFromCache: audioHit,
);
}
Future<WindowsEmbeddedProgressiveStatus> status({
required Duration mediaDuration,
}) async {
final video = _video;
final audio = _audio;
if (video == null || audio == null) {
throw StateError(
'Aucune session progressive active.',
);
}
final videoBytes =
await _safeLength(video.file);
final audioBytes =
await _safeLength(audio.file);
final videoComplete =
videoBytes == video.expectedLength;
final audioComplete =
audioBytes == audio.expectedLength;
Duration estimated = Duration.zero;
if (mediaDuration > Duration.zero &&
video.expectedLength > 0 &&
videoBytes > 0) {
final ratio = (
videoBytes / video.expectedLength
).clamp(0.0, 1.0);
estimated = Duration(
milliseconds:
(mediaDuration.inMilliseconds * ratio)
.round(),
);
}
return WindowsEmbeddedProgressiveStatus(
videoBytes: videoBytes,
videoLength: video.expectedLength,
audioBytes: audioBytes,
audioLength: audio.expectedLength,
videoComplete: videoComplete,
audioComplete: audioComplete,
estimatedBufferedDuration: estimated,
);
}
/// Vrai lorsque le fichier vidéo contient suffisamment d'octets pour
/// couvrir approximativement [position] + [ahead], avec une marge.
///
/// On n'utilise pas --download-sections ici : le fichier DASH continue de
/// grandir normalement, ce qui évite une découpe/re-encapsulation ffmpeg.
Future<bool> hasEnoughBuffer({
required Duration position,
required Duration mediaDuration,
Duration ahead = const Duration(seconds: 20),
}) async {
final video = _video;
final audio = _audio;
if (video == null || audio == null) {
return false;
}
final videoBytes =
await _safeLength(video.file);
final audioBytes =
await _safeLength(audio.file);
// L'audio 251 est petit. Pour garantir le son lors de la bascule,
// on attend qu'il soit complet.
if (audioBytes != audio.expectedLength) {
return false;
}
if (videoBytes == video.expectedLength) {
return true;
}
if (mediaDuration <= Duration.zero) {
return videoBytes >= 24 * 1024 * 1024;
}
final wantedPosition = Duration(
milliseconds: math.min(
mediaDuration.inMilliseconds,
position.inMilliseconds +
ahead.inMilliseconds,
),
);
final ratio = (
wantedPosition.inMilliseconds /
mediaDuration.inMilliseconds
).clamp(0.0, 1.0);
// 6 MiB de marge pour l'init/index + variations de bitrate.
final estimatedRequired =
(video.expectedLength * ratio).ceil() +
6 * 1024 * 1024;
// Même au début, on exige un prébuffer réel pour ne pas reproduire
// l'échec 17.15 où mpv ouvrait le flux alors qu'il était vide.
final required = math.min(
video.expectedLength,
math.max(
16 * 1024 * 1024,
estimatedRequired,
),
);
return videoBytes >= required;
}
Future<void> stop() async {
final subscription =
_serverSubscription;
_serverSubscription = null;
if (subscription != null) {
await subscription.cancel();
}
final server = _server;
_server = null;
if (server != null) {
await server.close(
force: true,
);
}
final processes =
_activeProcesses.toList(
growable: false,
);
_activeProcesses.clear();
for (final process in processes) {
process.kill();
}
if (processes.isNotEmpty) {
await Future<void>.delayed(
const Duration(milliseconds: 150),
);
}
// Les fichiers COMPLETS restent en cache.
// Les fichiers incomplets sont supprimés pour éviter un faux HIT.
final video = _video;
final audio = _audio;
_video = null;
_audio = null;
if (video != null) {
final length =
await _safeLength(video.file);
if (length != video.expectedLength) {
await _deleteIfExists(video.file);
}
}
if (audio != null) {
final length =
await _safeLength(audio.file);
if (length != audio.expectedLength) {
await _deleteIfExists(audio.file);
}
}
final cacheId = _activeVideoCacheId;
final cacheDirectory = _activeVideoDirectory;
_activeVideoCacheId = null;
_activeVideoDirectory = null;
if (cacheDirectory != null) {
final cacheSettings = MediaCacheSettingsService.instance;
await cacheSettings.touchVideoDirectory(cacheDirectory);
await cacheSettings.enforceLimit(keepVideoId: cacheId);
}
}
Future<void> _startDownload({
required _GrowingSource source,
required String ytDlpPath,
required String denoPath,
required String watchUrl,
}) async {
final args = <String>[
'--ignore-config',
'--no-playlist',
'--no-warnings',
'--newline',
'--socket-timeout',
'20',
'--retries',
'5',
'--fragment-retries',
'5',
'--js-runtimes',
'deno:$denoPath',
'--extractor-args',
'youtube:player_client=web_embedded',
'--force-overwrites',
'--no-part',
'-f',
source.formatId,
'-o',
source.file.path,
watchUrl,
];
debugPrint(
'[HD-PROGRESSIVE][17.19] yt-dlp '
'${source.id} format=${source.formatId}',
);
final process = await Process.start(
ytDlpPath,
args,
workingDirectory:
File(ytDlpPath).parent.path,
runInShell: false,
mode: ProcessStartMode.normal,
);
source.process = process;
_activeProcesses.add(process);
process.stdout
.transform(
const SystemEncoding().decoder,
)
.listen(
(_) {
// Le player affiche l'état à partir de la taille réelle du fichier,
// plus fiable que les lignes de progression yt-dlp.
},
);
process.stderr
.transform(
const SystemEncoding().decoder,
)
.listen(
(text) {
if (source.stderr.length < 5000) {
final remaining =
5000 - source.stderr.length;
source.stderr.write(
text.length <= remaining
? text
: text.substring(0, remaining),
);
}
},
);
unawaited(
process.exitCode.then(
(code) async {
_activeProcesses.remove(process);
source.exitCode = code;
final length =
await _safeLength(source.file);
if (code == 0 &&
length == source.expectedLength) {
debugPrint(
'[HD-PROGRESSIVE][17.19] '
'${source.id} complet: '
'$length/${source.expectedLength}',
);
} else {
final stderr = source.stderr
.toString()
.replaceAll(
RegExp(r'\s+'),
' ',
)
.trim();
debugPrint(
'[HD-PROGRESSIVE][17.19] '
'${source.id} fin code=$code '
'$length/${source.expectedLength}'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
},
),
);
}
Future<void> _handleVideoRequest(
HttpRequest request,
_GrowingSource source,
String contentType,
) async {
if (request.uri.path != '/video') {
request.response.statusCode =
HttpStatus.notFound;
await request.response.close();
return;
}
if (request.method != 'GET' &&
request.method != 'HEAD') {
request.response.statusCode =
HttpStatus.methodNotAllowed;
await request.response.close();
return;
}
final rangeHeader =
request.headers.value(
HttpHeaders.rangeHeader,
);
final parsed = _parseRange(
rangeHeader,
source.expectedLength,
);
if (rangeHeader != null &&
parsed == null) {
request.response.statusCode =
HttpStatus.requestedRangeNotSatisfiable;
request.response.headers.set(
'content-range',
'bytes */${source.expectedLength}',
);
await request.response.close();
return;
}
final start = parsed?.$1 ?? 0;
final end =
parsed?.$2 ??
source.expectedLength - 1;
// Ne pas envoyer de réponse vide à mpv.
// Si mpv demande directement un offset légèrement au-delà du buffer
// estimé, on attend que yt-dlp atteigne réellement cet offset.
await _waitForOffset(
source,
start,
);
final response = request.response;
response.statusCode = parsed == null
? HttpStatus.ok
: HttpStatus.partialContent;
response.headers.set(
'accept-ranges',
'bytes',
);
response.headers.set(
HttpHeaders.cacheControlHeader,
'no-store',
);
response.headers.set(
HttpHeaders.contentTypeHeader,
contentType,
);
response.contentLength =
end - start + 1;
if (parsed != null) {
response.headers.set(
'content-range',
'bytes $start-$end/'
'${source.expectedLength}',
);
}
debugPrint(
'[HD-PROGRESSIVE][17.19] /video '
'${request.method} bytes=$start-$end',
);
if (request.method == 'HEAD') {
await response.close();
return;
}
try {
await _streamGrowingFile(
source: source,
start: start,
end: end,
response: response,
);
} on Object catch (error) {
debugPrint(
'[HD-PROGRESSIVE][17.19] /video: $error',
);
try {
await response.close();
} catch (_) {
// mpv peut fermer la connexion lors d'un seek/changement source.
}
}
}
Future<void> _waitForOffset(
_GrowingSource source,
int offset,
) async {
final deadline =
DateTime.now().add(
const Duration(seconds: 45),
);
while (true) {
final available =
await _safeLength(source.file);
if (available > offset ||
available == source.expectedLength) {
return;
}
final exitCode = source.exitCode;
if (exitCode != null &&
exitCode != 0) {
throw HttpException(
'yt-dlp ${source.id} code=$exitCode',
);
}
if (DateTime.now().isAfter(deadline)) {
throw TimeoutException(
'Offset $offset non disponible '
'après 45 s.',
);
}
await Future<void>.delayed(
const Duration(milliseconds: 40),
);
}
}
Future<void> _streamGrowingFile({
required _GrowingSource source,
required int start,
required int end,
required HttpResponse response,
}) async {
var cursor = start;
RandomAccessFile? raf;
try {
while (cursor <= end) {
final available =
await _safeLength(source.file);
if (available > cursor) {
raf ??=
await source.file.open(
mode: FileMode.read,
);
final readable = math.min(
available - cursor,
end - cursor + 1,
);
final count = math.min(
readable,
512 * 1024,
);
await raf.setPosition(cursor);
final data =
await raf.read(count);
if (data.isEmpty) {
await Future<void>.delayed(
const Duration(milliseconds: 20),
);
continue;
}
response.add(data);
await response.flush();
cursor += data.length;
continue;
}
final exitCode = source.exitCode;
if (exitCode != null) {
if (exitCode != 0) {
throw HttpException(
'yt-dlp ${source.id} code=$exitCode',
);
}
if (available <= cursor) {
throw HttpException(
'EOF progressif à $available, '
'offset demandé=$cursor.',
);
}
}
// Le Player a rejoint le bord du téléchargement.
// On attend que yt-dlp reprenne de l'avance.
await Future<void>.delayed(
const Duration(milliseconds: 30),
);
}
await response.close();
} finally {
await raf?.close();
}
}
(int, int)? _parseRange(
String? value,
int totalLength,
) {
if (value == null ||
value.trim().isEmpty) {
return null;
}
final match = RegExp(
r'^bytes=(\d*)-(\d*)$',
caseSensitive: false,
).firstMatch(
value.trim(),
);
if (match == null) {
return null;
}
final startText =
match.group(1) ?? '';
final endText =
match.group(2) ?? '';
if (startText.isEmpty) {
final suffix =
int.tryParse(endText);
if (suffix == null ||
suffix <= 0) {
return null;
}
final start = math.max(
0,
totalLength - suffix,
);
return (
start,
totalLength - 1,
);
}
final start =
int.tryParse(startText);
if (start == null ||
start < 0 ||
start >= totalLength) {
return null;
}
final parsedEnd =
endText.isEmpty
? totalLength - 1
: int.tryParse(endText);
if (parsedEnd == null ||
parsedEnd < start) {
return null;
}
return (
start,
math.min(
parsedEnd,
totalLength - 1,
),
);
}
String _safeName(
String value,
) {
final safe =
value.replaceAll(
RegExp(r'[^A-Za-z0-9._-]'),
'_',
);
return safe.isEmpty
? 'media'
: safe;
}
Future<bool> _isComplete(
File file,
int expectedLength,
) async {
return await _safeLength(file) ==
expectedLength;
}
Future<int> _safeLength(
File file,
) async {
try {
if (!await file.exists()) {
return 0;
}
return await file.length();
} catch (_) {
return 0;
}
}
Future<void> _deleteIfExists(
File file,
) async {
try {
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Une erreur ultérieure sera plus explicite.
}
}
}
class _GrowingSource {
_GrowingSource({
required this.id,
required this.file,
required this.expectedLength,
required this.formatId,
});
final String id;
final File file;
final int expectedLength;
final String formatId;
Process? process;
int? exitCode;
final StringBuffer stderr =
StringBuffer();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,619 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
class WindowsRelaySession {
const WindowsRelaySession({
required this.videoUrl,
required this.audioUrl,
});
final String videoUrl;
final String audioUrl;
}
/// Relais HTTP local Windows dont le transport upstream est exécuté par
/// yt-dlp lui-même.
///
/// Architecture :
///
/// libmpv -> 127.0.0.1 -> yt-dlp HttpFD -> googlevideo
///
/// Les étapes 17.10/17.11 ont démontré que :
/// - libmpv lit correctement le 1080p depuis localhost ;
/// - le HttpClient Dart n'est pas accepté de façon stable par Googlevideo ;
/// - yt-dlp, lui, sait télécharger le même format.
///
/// Pour chaque GET/Range demandé par mpv, le relais construit un petit
/// info-json local contenant l'URL signée, les headers et la plage demandée,
/// puis lance le yt-dlp déjà empaqueté avec `--load-info-json` et `-o -`.
///
/// yt-dlp applique alors exactement son propre downloader HTTP, y compris
/// la logique officielle de reprise/chunking et son stack réseau.
class WindowsMediaRelayService {
HttpServer? _server;
StreamSubscription<HttpRequest>? _serverSubscription;
final Map<String, _RelaySource> _sources =
<String, _RelaySource>{};
final Set<Process> _activeProcesses =
<Process>{};
Directory? _sessionDirectory;
Future<WindowsRelaySession> start({
required String ytDlpPath,
required String videoUrl,
required String audioUrl,
required Map<String, String> videoHeaders,
required Map<String, String> audioHeaders,
required int videoLength,
required int audioLength,
required int videoChunkSize,
required int audioChunkSize,
required String videoMimeType,
required String audioMimeType,
}) async {
if (!Platform.isWindows) {
throw UnsupportedError(
'Le relais HD local est réservé à Windows.',
);
}
await stop();
final ytDlp = File(ytDlpPath);
if (!await ytDlp.exists()) {
throw StateError(
'yt-dlp.exe introuvable pour le relais : $ytDlpPath',
);
}
if (videoLength <= 0 || audioLength <= 0) {
throw StateError(
'Taille vidéo/audio inconnue : '
'video=$videoLength audio=$audioLength',
);
}
final videoUri = Uri.tryParse(videoUrl);
final audioUri = Uri.tryParse(audioUrl);
if (videoUri == null ||
!videoUri.hasScheme ||
audioUri == null ||
!audioUri.hasScheme) {
throw StateError(
'URL vidéo/audio invalide pour le relais.',
);
}
final sessionDirectory =
await Directory.systemTemp.createTemp(
'youtube_personal_ytdlp_relay_',
);
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
shared: false,
);
_sessionDirectory = sessionDirectory;
_server = server;
_sources
..clear()
..['/video'] = _RelaySource(
id: 'video',
ytDlpPath: ytDlp.path,
upstream: videoUri,
headers: videoHeaders,
length: videoLength,
chunkSize: _safeChunkSize(videoChunkSize),
contentType: videoMimeType,
ext: _extensionForMime(
videoMimeType,
fallback: 'mp4',
),
)
..['/audio'] = _RelaySource(
id: 'audio',
ytDlpPath: ytDlp.path,
upstream: audioUri,
headers: audioHeaders,
length: audioLength,
chunkSize: _safeChunkSize(audioChunkSize),
contentType: audioMimeType,
ext: _extensionForMime(
audioMimeType,
fallback: 'm4a',
),
);
_serverSubscription = server.listen(
(request) {
unawaited(_handleRequest(request));
},
onError: (Object error, StackTrace stackTrace) {
debugPrint(
'[HD-RELAY] erreur serveur local: $error',
);
},
cancelOnError: false,
);
final base =
'http://127.0.0.1:${server.port}';
debugPrint(
'[HD-RELAY][17.12] actif sur 127.0.0.1:${server.port} '
'(transport upstream = yt-dlp HttpFD)',
);
return WindowsRelaySession(
videoUrl: '$base/video',
audioUrl: '$base/audio',
);
}
Future<void> stop() async {
final subscription = _serverSubscription;
_serverSubscription = null;
if (subscription != null) {
await subscription.cancel();
}
final server = _server;
_server = null;
if (server != null) {
await server.close(force: true);
}
final processes =
_activeProcesses.toList(growable: false);
_activeProcesses.clear();
for (final process in processes) {
process.kill();
}
final sessionDirectory = _sessionDirectory;
_sessionDirectory = null;
if (sessionDirectory != null) {
try {
if (await sessionDirectory.exists()) {
await sessionDirectory.delete(
recursive: true,
);
}
} catch (_) {
// Windows peut garder brièvement un handle après l'arrêt de yt-dlp.
}
}
_sources.clear();
}
int _safeChunkSize(int requested) {
if (requested <= 0) {
return 10 * 1024 * 1024;
}
return requested.clamp(
256 * 1024,
10 * 1024 * 1024,
);
}
String _extensionForMime(
String mime, {
required String fallback,
}) {
final normalized = mime.toLowerCase();
if (normalized.contains('webm')) {
return 'webm';
}
if (normalized.contains('mp4') ||
normalized.contains('m4a')) {
return fallback;
}
return fallback;
}
Future<void> _handleRequest(
HttpRequest request,
) async {
final source = _sources[request.uri.path];
if (source == null) {
request.response.statusCode =
HttpStatus.notFound;
await request.response.close();
return;
}
if (request.method != 'GET' &&
request.method != 'HEAD') {
request.response.statusCode =
HttpStatus.methodNotAllowed;
request.response.headers.set(
'allow',
'GET, HEAD',
);
await request.response.close();
return;
}
final rangeHeader =
request.headers.value(
HttpHeaders.rangeHeader,
);
final parsed = _parseRange(
rangeHeader,
source.length,
);
if (rangeHeader != null && parsed == null) {
request.response.statusCode =
HttpStatus.requestedRangeNotSatisfiable;
request.response.headers.set(
'content-range',
'bytes */${source.length}',
);
await request.response.close();
return;
}
final start = parsed?.$1 ?? 0;
final end =
parsed?.$2 ?? source.length - 1;
final response = request.response;
response.statusCode = parsed == null
? HttpStatus.ok
: HttpStatus.partialContent;
response.headers.set(
'accept-ranges',
'bytes',
);
response.headers.set(
HttpHeaders.cacheControlHeader,
'no-store',
);
response.headers.set(
HttpHeaders.contentTypeHeader,
source.contentType,
);
final responseLength =
end - start + 1;
response.contentLength =
responseLength;
if (parsed != null) {
response.headers.set(
'content-range',
'bytes $start-$end/${source.length}',
);
}
debugPrint(
'[HD-RELAY][17.12] ${request.uri.path} '
'${request.method} bytes=$start-$end '
'(${responseLength}o)',
);
if (request.method == 'HEAD') {
await response.close();
return;
}
try {
await _pipeWithYtDlp(
source: source,
start: start,
end: end,
downstream: response,
);
} on Object catch (error) {
debugPrint(
'[HD-RELAY][17.12] ${request.uri.path} échec: $error',
);
try {
await response.close();
} catch (_) {
// Le lecteur peut avoir fermé la connexion lors d'un seek.
}
}
}
(int, int)? _parseRange(
String? value,
int totalLength,
) {
if (value == null || value.trim().isEmpty) {
return null;
}
final match = RegExp(
r'^bytes=(\d*)-(\d*)$',
caseSensitive: false,
).firstMatch(value.trim());
if (match == null) {
return null;
}
final startText = match.group(1) ?? '';
final endText = match.group(2) ?? '';
if (startText.isEmpty) {
final suffix = int.tryParse(endText);
if (suffix == null || suffix <= 0) {
return null;
}
final start =
(totalLength - suffix).clamp(
0,
totalLength - 1,
);
return (start, totalLength - 1);
}
final start = int.tryParse(startText);
if (start == null ||
start < 0 ||
start >= totalLength) {
return null;
}
final parsedEnd = endText.isEmpty
? totalLength - 1
: int.tryParse(endText);
if (parsedEnd == null ||
parsedEnd < start) {
return null;
}
final end = parsedEnd.clamp(
start,
totalLength - 1,
);
return (start, end);
}
Future<void> _pipeWithYtDlp({
required _RelaySource source,
required int start,
required int end,
required HttpResponse downstream,
}) async {
final sessionDirectory = _sessionDirectory;
if (sessionDirectory == null) {
throw StateError(
'Session relais yt-dlp fermée.',
);
}
final rangeValue =
'bytes=$start-$end';
final infoHeaders =
<String, String>{
...source.headers,
'Accept-Encoding': 'identity',
'Range': rangeValue,
};
final info = <String, dynamic>{
'id':
'youtube-personal-${source.id}',
'title':
'YouTube Personal ${source.id}',
'ext': source.ext,
'protocol': 'https',
'url': source.upstream.toString(),
'http_headers': infoHeaders,
'filesize': source.length,
'downloader_options':
<String, dynamic>{
'http_chunk_size':
source.chunkSize,
},
};
final stamp =
'${DateTime.now().microsecondsSinceEpoch}'
'-${requestCounter++}';
final infoFile = File(
'${sessionDirectory.path}'
'${Platform.pathSeparator}'
'${source.id}-$stamp.info.json',
);
await infoFile.writeAsString(
jsonEncode(info),
encoding: utf8,
flush: true,
);
final process = await Process.start(
source.ytDlpPath,
<String>[
'--ignore-config',
'--no-warnings',
'--no-progress',
'--socket-timeout',
'20',
'--retries',
'2',
'--no-part',
'--load-info-json',
infoFile.path,
'-o',
'-',
],
workingDirectory:
File(source.ytDlpPath).parent.path,
runInShell: false,
mode: ProcessStartMode.normal,
);
_activeProcesses.add(process);
final stderrBuffer =
StringBuffer();
final stderrSubscription =
process.stderr
.transform(utf8.decoder)
.listen(
(text) {
if (stderrBuffer.length < 4000) {
final remaining =
4000 - stderrBuffer.length;
stderrBuffer.write(
text.length <= remaining
? text
: text.substring(0, remaining),
);
}
},
);
var written = 0;
Object? streamError;
debugPrint(
'[HD-RELAY][17.12] ${source.id}: '
'yt-dlp démarre Range=$rangeValue',
);
try {
await for (final data in process.stdout) {
if (written >= end - start + 1) {
break;
}
final remaining =
end - start + 1 - written;
if (data.length <= remaining) {
downstream.add(data);
written += data.length;
} else {
downstream.add(
data.sublist(0, remaining),
);
written += remaining;
}
// Backpressure : permet au lecteur de contrôler la vitesse
// au lieu de remplir massivement la mémoire.
await downstream.flush();
if (written >= end - start + 1) {
break;
}
}
} on Object catch (error) {
streamError = error;
}
if (written >= end - start + 1) {
// La plage demandée est complète. Si yt-dlp n'a pas encore quitté
// (par ex. sortie stdout plus large), on peut arrêter le process.
process.kill();
}
final exitCode = await process.exitCode;
await stderrSubscription.cancel();
_activeProcesses.remove(process);
try {
if (await infoFile.exists()) {
await infoFile.delete();
}
} catch (_) {
// Non bloquant.
}
if (streamError != null) {
throw streamError;
}
if (written != end - start + 1) {
final stderr = stderrBuffer
.toString()
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
throw HttpException(
'yt-dlp a fourni $written/'
'${end - start + 1} octets '
'(code=$exitCode)'
'${stderr.isEmpty ? '' : ': $stderr'}',
);
}
debugPrint(
'[HD-RELAY][17.12] ${source.id}: '
'yt-dlp OK ${written}o',
);
await downstream.close();
}
int requestCounter = 0;
}
class _RelaySource {
const _RelaySource({
required this.id,
required this.ytDlpPath,
required this.upstream,
required this.headers,
required this.length,
required this.chunkSize,
required this.contentType,
required this.ext,
});
final String id;
final String ytDlpPath;
final Uri upstream;
final Map<String, String> headers;
final int length;
final int chunkSize;
final String contentType;
final String ext;
}
@@ -0,0 +1,179 @@
import '../models/video_item.dart';
class YoungAudienceFilterService {
YoungAudienceFilterService._();
static bool shouldHide(VideoItem video, int level) {
if (level <= 0) {
return false;
}
final score = scoreVideo(video);
final threshold = level >= 2 ? 4 : 8;
return score >= threshold;
}
static int scoreVideo(VideoItem video) {
final author = _normalize(video.author);
final title = _normalize(video.title);
final description = _normalize(video.description);
final combined = ' $author $title $description ';
var score = 0;
for (final marker in _knownYoungAudienceBrands) {
if (author.contains(marker) || title.contains(marker)) {
score += 12;
break;
}
}
for (final marker in _strongSignals) {
if (combined.contains(' $marker ')) {
score += 5;
}
}
for (final marker in _phraseSignals) {
if (combined.contains(marker)) {
score += 6;
}
}
for (final marker in _softSignals) {
if (combined.contains(' $marker ')) {
score += 2;
}
}
return score.clamp(0, 30).toInt();
}
static const List<String> _knownYoungAudienceBrands = <String>[
'cocomelon',
'babybus',
'little angel',
'kids diana',
'vlad and niki',
'like nastya',
'ryans world',
'ryan world',
'toys and colors',
'chuchu tv',
'pinkfong',
'super simple songs',
'blippi',
'peppa pig',
'masha and the bear',
'vlad a4',
'vlad ara',
'ninja kidz tv',
'kids fun tv',
'a for adley',
'sis vs bro',
'salish matter',
];
static const List<String> _strongSignals = <String>[
'kids',
'kid',
'children',
'child',
'baby',
'babies',
'toddler',
'toddlers',
'preschool',
'nursery',
'toys',
'toy',
'enfant',
'enfants',
'bebe',
'bebes',
'jouet',
'jouets',
'comptine',
'comptines',
'maternelle',
'creche',
'ninos',
'juguetes',
'kinder',
'spielzeug',
'اطفال',
'أطفال',
'طفل',
'رضيع',
'العاب',
'ألعاب',
'اناشيد',
'أناشيد',
];
static const List<String> _phraseSignals = <String>[
'nursery rhyme',
'nursery rhymes',
'kids song',
'kids songs',
'baby song',
'baby songs',
'toy review',
'for kids',
'for children',
'pour enfants',
'pour bebe',
'pour les enfants',
'dessin anime enfant',
'dessins animes enfants',
];
static const List<String> _softSignals = <String>[
'school',
'cartoon',
'animation',
'family',
'familyfun',
'playtime',
'playhouse',
'slime',
'challenge',
];
static String _normalize(String value) {
var text = value.toLowerCase();
const replacements = <String, String>{
'à': 'a',
'â': 'a',
'ä': 'a',
'á': 'a',
'ã': 'a',
'ç': 'c',
'é': 'e',
'è': 'e',
'ê': 'e',
'ë': 'e',
'î': 'i',
'ï': 'i',
'í': 'i',
'ô': 'o',
'ö': 'o',
'ó': 'o',
'ù': 'u',
'û': 'u',
'ü': 'u',
'ú': 'u',
'ÿ': 'y',
};
replacements.forEach((source, target) {
text = text.replaceAll(source, target);
});
return text
.replaceAll(RegExp(r'[^a-z0-9\u0600-\u06ff]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
}
+830
View File
@@ -0,0 +1,830 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/channel_details.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
class YoutubeAccountService {
Future<List<SubscriptionChannel>> fetchSubscriptions(
String accessToken,
) async {
final result = <SubscriptionChannel>[];
String? pageToken;
do {
final decoded = await _authorizedGetJson(
accessToken,
'/youtube/v3/subscriptions',
{
'part': 'snippet',
'mine': 'true',
'maxResults': '50',
'order': 'alphabetical',
if (pageToken != null)
'pageToken': pageToken,
},
);
final items = decoded['items'];
if (items is List) {
for (final item in items) {
if (item is! Map) {
continue;
}
final snippet = item['snippet'];
if (snippet is! Map) {
continue;
}
final resourceId = snippet['resourceId'];
final thumbnails = snippet['thumbnails'];
final channelId = resourceId is Map
? resourceId['channelId']
: null;
final title = snippet['title'];
if (channelId is! String ||
title is! String) {
continue;
}
result.add(
SubscriptionChannel(
channelId: channelId,
title: title,
thumbnailUrl:
_bestThumbnailUrl(thumbnails),
description:
snippet['description'] is String
? snippet['description'] as String
: '',
),
);
}
}
final next = decoded['nextPageToken'];
pageToken =
next is String && next.isNotEmpty
? next
: null;
} while (pageToken != null);
return result;
}
Future<List<VideoItem>> fetchRecentVideosForChannel(
String accessToken,
String channelId, {
int maxResults = 30,
}) async {
final safeMaxResults = maxResults.clamp(1, 50);
// 1. Récupère la playlist système "uploads" de la chaîne.
final channelData = await _authorizedGetJson(
accessToken,
'/youtube/v3/channels',
{
'part': 'contentDetails',
'id': channelId,
},
);
final channelItems = channelData['items'];
if (channelItems is! List ||
channelItems.isEmpty ||
channelItems.first is! Map) {
return const [];
}
final channel = channelItems.first as Map;
final contentDetails = channel['contentDetails'];
if (contentDetails is! Map) {
return const [];
}
final relatedPlaylists =
contentDetails['relatedPlaylists'];
if (relatedPlaylists is! Map) {
return const [];
}
final uploadsPlaylistId =
relatedPlaylists['uploads'];
if (uploadsPlaylistId is! String ||
uploadsPlaylistId.isEmpty) {
return const [];
}
// 2. Les éléments de cette playlist sont les mises en ligne,
// dans l'ordre de la playlist YouTube.
final playlistData = await _authorizedGetJson(
accessToken,
'/youtube/v3/playlistItems',
{
'part': 'snippet,contentDetails',
'playlistId': uploadsPlaylistId,
'maxResults': '$safeMaxResults',
},
);
final playlistItems = playlistData['items'];
if (playlistItems is! List ||
playlistItems.isEmpty) {
return const [];
}
final rawVideos = <_RawChannelVideo>[];
for (final item in playlistItems) {
if (item is! Map) {
continue;
}
final content = item['contentDetails'];
final snippet = item['snippet'];
if (content is! Map || snippet is! Map) {
continue;
}
final videoId = content['videoId'];
final title = snippet['title'];
if (videoId is! String ||
videoId.isEmpty ||
title is! String ||
title.isEmpty) {
continue;
}
// Les entrées privées/supprimées peuvent rester dans une
// playlist d'uploads. On évite de les présenter comme vidéos.
if (title == 'Private video' ||
title == 'Deleted video') {
continue;
}
rawVideos.add(
_RawChannelVideo(
id: videoId,
title: title,
author:
snippet['videoOwnerChannelTitle']
is String
? snippet[
'videoOwnerChannelTitle']
as String
: snippet['channelTitle']
is String
? snippet['channelTitle']
as String
: '',
thumbnailUrl: _bestThumbnailUrl(
snippet['thumbnails'],
),
),
);
}
if (rawVideos.isEmpty) {
return const [];
}
// 3. playlistItems ne donne pas la durée. videos.list permet
// de récupérer contentDetails.duration pour tous les IDs en
// une seule requête (jusqu'à 50 IDs).
final videoData = await _authorizedGetJson(
accessToken,
'/youtube/v3/videos',
{
'part': 'contentDetails,status',
'id': rawVideos
.map((video) => video.id)
.join(','),
},
);
final durationById = <String, Duration?>{};
final availableIds = <String>{};
final videoItems = videoData['items'];
if (videoItems is List) {
for (final item in videoItems) {
if (item is! Map) {
continue;
}
final id = item['id'];
if (id is! String || id.isEmpty) {
continue;
}
final status = item['status'];
if (status is Map &&
status['privacyStatus'] == 'private') {
continue;
}
availableIds.add(id);
final details = item['contentDetails'];
final durationText =
details is Map
? details['duration']
: null;
durationById[id] =
durationText is String
? _parseIso8601Duration(
durationText,
)
: null;
}
}
return rawVideos
.where(
(video) => availableIds.contains(video.id),
)
.map(
(video) => VideoItem(
id: video.id,
title: video.title,
author: video.author,
thumbnailUrl: video.thumbnailUrl,
duration: durationById[video.id],
),
)
.toList(growable: false);
}
Future<ChannelDetails?> fetchChannelDetails(
String accessToken,
String channelId,
) async {
final decoded = await _authorizedGetJson(
accessToken,
'/youtube/v3/channels',
<String, String>{
'part': 'snippet,statistics',
'id': channelId,
},
);
final items = decoded['items'];
if (items is! List ||
items.isEmpty ||
items.first is! Map) {
return null;
}
final item = items.first as Map;
final snippet = item['snippet'];
final statistics = item['statistics'];
if (snippet is! Map) {
return null;
}
final hiddenSubscriberCount =
statistics is Map &&
statistics['hiddenSubscriberCount'] == true;
return ChannelDetails(
channelId: channelId,
title: snippet['title'] is String
? snippet['title'] as String
: '',
description: snippet['description'] is String
? snippet['description'] as String
: '',
thumbnailUrl:
_bestThumbnailUrl(snippet['thumbnails']),
publishedAt: snippet['publishedAt'] is String
? DateTime.tryParse(
snippet['publishedAt'] as String,
)
: null,
viewCount: statistics is Map
? _parseInt(statistics['viewCount'])
: null,
subscriberCount:
hiddenSubscriberCount || statistics is! Map
? null
: _parseInt(
statistics['subscriberCount'],
),
hiddenSubscriberCount:
hiddenSubscriberCount,
videoCount: statistics is Map
? _parseInt(statistics['videoCount'])
: null,
country: snippet['country'] is String
? snippet['country'] as String
: null,
);
}
/// Streams all public uploads from a channel through YouTube Data API v3.
///
/// Unlike the old 30-video helper, this follows playlistItems.nextPageToken
/// until the uploads playlist is exhausted and enriches each page with
/// duration, public view count and publication date.
Stream<VideoItem> streamAllVideosForChannel(
String accessToken,
String channelId,
) async* {
final channelData = await _authorizedGetJson(
accessToken,
'/youtube/v3/channels',
<String, String>{
'part': 'contentDetails',
'id': channelId,
},
);
final channelItems = channelData['items'];
if (channelItems is! List ||
channelItems.isEmpty ||
channelItems.first is! Map) {
return;
}
final contentDetails =
(channelItems.first as Map)['contentDetails'];
if (contentDetails is! Map) {
return;
}
final relatedPlaylists = contentDetails['relatedPlaylists'];
if (relatedPlaylists is! Map) {
return;
}
final uploadsPlaylistId = relatedPlaylists['uploads'];
if (uploadsPlaylistId is! String || uploadsPlaylistId.isEmpty) {
return;
}
String? pageToken;
do {
final playlistData = await _authorizedGetJson(
accessToken,
'/youtube/v3/playlistItems',
<String, String>{
'part': 'snippet,contentDetails',
'playlistId': uploadsPlaylistId,
'maxResults': '50',
if (pageToken != null) 'pageToken': pageToken,
},
);
final playlistItems = playlistData['items'];
final rawVideos = <_RawFullChannelVideo>[];
if (playlistItems is List) {
for (final item in playlistItems) {
if (item is! Map) {
continue;
}
final content = item['contentDetails'];
final snippet = item['snippet'];
if (content is! Map || snippet is! Map) {
continue;
}
final videoId = content['videoId'];
final title = snippet['title'];
if (videoId is! String ||
videoId.isEmpty ||
title is! String ||
title.isEmpty ||
title == 'Private video' ||
title == 'Deleted video') {
continue;
}
rawVideos.add(
_RawFullChannelVideo(
id: videoId,
title: title,
author: snippet['videoOwnerChannelTitle'] is String
? snippet['videoOwnerChannelTitle'] as String
: snippet['channelTitle'] is String
? snippet['channelTitle'] as String
: '',
thumbnailUrl: _bestThumbnailUrl(snippet['thumbnails']),
description: snippet['description'] is String
? snippet['description'] as String
: '',
uploadDate: snippet['publishedAt'] is String
? DateTime.tryParse(snippet['publishedAt'] as String)
: null,
),
);
}
}
if (rawVideos.isNotEmpty) {
final videoData = await _authorizedGetJson(
accessToken,
'/youtube/v3/videos',
<String, String>{
'part': 'contentDetails,status,statistics,snippet',
'id': rawVideos.map((video) => video.id).join(','),
'maxResults': '50',
},
);
final byId = <String, Map>{};
final videoItems = videoData['items'];
if (videoItems is List) {
for (final item in videoItems) {
if (item is! Map) {
continue;
}
final id = item['id'];
if (id is String && id.isNotEmpty) {
byId[id] = item;
}
}
}
for (final raw in rawVideos) {
final item = byId[raw.id];
if (item == null) {
continue;
}
final status = item['status'];
if (status is Map && status['privacyStatus'] == 'private') {
continue;
}
final details = item['contentDetails'];
final durationText =
details is Map ? details['duration'] : null;
final statistics = item['statistics'];
final viewCountText =
statistics is Map ? statistics['viewCount'] : null;
final likeCountText =
statistics is Map ? statistics['likeCount'] : null;
final commentCountText =
statistics is Map ? statistics['commentCount'] : null;
final apiSnippet = item['snippet'];
final apiPublishedAt = apiSnippet is Map
? apiSnippet['publishedAt']
: null;
yield VideoItem(
id: raw.id,
title: raw.title,
author: raw.author,
channelId: channelId,
thumbnailUrl: raw.thumbnailUrl,
description: raw.description,
duration: durationText is String
? _parseIso8601Duration(durationText)
: null,
viewCount: _parseInt(viewCountText),
likeCount: _parseInt(likeCountText),
commentCount: _parseInt(commentCountText),
uploadDate: apiPublishedAt is String
? DateTime.tryParse(apiPublishedAt)
: raw.uploadDate,
);
}
}
final next = playlistData['nextPageToken'];
pageToken = next is String && next.isNotEmpty ? next : null;
} while (pageToken != null);
}
int? _parseInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
Future<Map<dynamic, dynamic>> _authorizedGetJson(
String accessToken,
String path,
Map<String, String> query,
) async {
final uri = Uri.https(
'www.googleapis.com',
path,
query,
);
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
);
if (response.statusCode == 401) {
throw StateError(
'La session Google a expiré. '
'Déconnecte puis reconnecte ton compte.',
);
}
if (response.statusCode < 200 ||
response.statusCode >= 300) {
final apiError = _parseApiError(
response.statusCode,
response.body,
);
print(
'[YT-ACCOUNT][API-ERROR] '
'status=${response.statusCode} '
'reason=${apiError.reason} '
'message=${apiError.message}',
);
throw StateError(
_friendlyErrorMessage(
response.statusCode,
apiError,
),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map) {
throw StateError(
'Réponse YouTube Data API invalide.',
);
}
return decoded;
}
String _bestThumbnailUrl(dynamic thumbnails) {
if (thumbnails is! Map) {
return '';
}
for (final key in const [
'maxres',
'standard',
'high',
'medium',
'default',
]) {
final value = thumbnails[key];
if (value is Map &&
value['url'] is String) {
return value['url'] as String;
}
}
return '';
}
Duration? _parseIso8601Duration(
String value,
) {
final match = RegExp(
r'^P(?:(\d+)D)?'
r'(?:T'
r'(?:(\d+)H)?'
r'(?:(\d+)M)?'
r'(?:(\d+(?:\.\d+)?)S)?'
r')?$',
).firstMatch(value);
if (match == null) {
return null;
}
final days =
int.tryParse(match.group(1) ?? '') ?? 0;
final hours =
int.tryParse(match.group(2) ?? '') ?? 0;
final minutes =
int.tryParse(match.group(3) ?? '') ?? 0;
final seconds =
double.tryParse(match.group(4) ?? '') ?? 0;
final totalMilliseconds =
(((days * 24 + hours) * 60 + minutes) *
60 *
1000 +
seconds * 1000)
.round();
return Duration(
milliseconds: totalMilliseconds,
);
}
_YoutubeApiError _parseApiError(
int statusCode,
String body,
) {
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
final error = decoded['error'];
if (error is Map) {
final topMessage =
error['message'] is String
? error['message'] as String
: '';
final errors = error['errors'];
if (errors is List &&
errors.isNotEmpty) {
final first = errors.first;
if (first is Map) {
return _YoutubeApiError(
reason:
first['reason'] is String
? first['reason'] as String
: 'unknown',
message:
first['message'] is String
? first['message'] as String
: topMessage,
);
}
}
final details = error['details'];
if (details is List) {
for (final detail in details) {
if (detail is! Map) {
continue;
}
final reason = detail['reason'];
if (reason is String &&
reason.isNotEmpty) {
return _YoutubeApiError(
reason: reason,
message: topMessage,
);
}
}
}
return _YoutubeApiError(
reason: 'unknown',
message: topMessage,
);
}
}
} catch (_) {
// Le corps brut sera repris ci-dessous.
}
return _YoutubeApiError(
reason: 'unknown',
message: body,
);
}
String _friendlyErrorMessage(
int statusCode,
_YoutubeApiError error,
) {
final reason = error.reason;
if (statusCode == 403) {
switch (reason) {
case 'insufficientPermissions':
return 'Google a accepté la connexion, '
'mais le jeton OAuth ne contient pas '
'la permission YouTube nécessaire '
'(youtube.readonly). Ajoute ce scope '
'dans Google Auth Platform > Accès aux '
'données, puis déconnecte et reconnecte '
'le compte dans l’application.';
case 'accessNotConfigured':
case 'serviceDisabled':
case 'apiDisabled':
return 'YouTube Data API v3 est désactivée '
'pour ce projet Google Cloud.';
case 'quotaExceeded':
case 'dailyLimitExceeded':
return 'Le quota YouTube Data API du projet '
'est temporairement épuisé.';
case 'accountClosed':
return 'Le compte ou la chaîne YouTube '
'associée est fermé(e).';
case 'accountSuspended':
return 'YouTube indique que la chaîne '
'associée au compte est suspendue.';
case 'subscriptionForbidden':
return 'Google a authentifié le compte, '
'mais YouTube refuse l’accès aux '
'abonnements pour ce compte.';
default:
return 'YouTube Data API refuse la requête '
'(403, raison: $reason). '
'${error.message}';
}
}
return 'YouTube Data API $statusCode '
'(raison: $reason): ${error.message}';
}
}
class _RawChannelVideo {
const _RawChannelVideo({
required this.id,
required this.title,
required this.author,
required this.thumbnailUrl,
});
final String id;
final String title;
final String author;
final String thumbnailUrl;
}
class _RawFullChannelVideo {
const _RawFullChannelVideo({
required this.id,
required this.title,
required this.author,
required this.thumbnailUrl,
required this.description,
required this.uploadDate,
});
final String id;
final String title;
final String author;
final String thumbnailUrl;
final String description;
final DateTime? uploadDate;
}
class _YoutubeApiError {
const _YoutubeApiError({
required this.reason,
required this.message,
});
final String reason;
final String message;
}
+481
View File
@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import '../models/channel_details.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
import 'windows_channel_metadata_service.dart';
enum ChannelUploadSort {
newest,
popularity,
oldest,
}
class ChannelUploadBatch {
const ChannelUploadBatch({
required this.videos,
required this.hasMore,
});
final List<VideoItem> videos;
final bool hasMore;
}
class YoutubeService {
final YoutubeExplode _youtube = YoutubeExplode();
ChannelUploadsList? _channelUploadsPage;
String? _channelUploadsChannelId;
ChannelUploadSort? _channelUploadsSort;
bool _channelUploadsExhausted = false;
Future<List<VideoItem>> searchVideos(String query) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return const [];
}
try {
final results = await _youtube.search.search(normalizedQuery);
return results
.map(_toVideoItem)
.toList(growable: false);
} catch (error) {
return _searchWithYtDlp(normalizedQuery);
}
}
Future<List<VideoItem>> _searchWithYtDlp(String query) async {
try {
final result = await Process.run('yt-dlp', [
'--flat-playlist',
'-j',
'ytsearch10:$query',
]);
if (result.exitCode != 0) {
return const [];
}
final lines = (result.stdout as String)
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
final videos = <VideoItem>[];
for (final line in lines) {
try {
final json = jsonDecode(line) as Map<String, dynamic>;
videos.add(VideoItem(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
author: json['channel'] as String? ?? json['uploader'] as String? ?? '',
channelId: json['channel_id'] as String? ?? '',
duration: json['duration'] != null
? Duration(seconds: (json['duration'] as num).toInt())
: null,
viewCount: json['view_count'] as num?,
likeCount: null,
description: json['description'] as String? ?? '',
uploadDate: null,
thumbnailUrl: json['thumbnails'] != null
? (json['thumbnails'] as List).isNotEmpty
? (json['thumbnails'][0] as Map<String, dynamic>)['url'] as String? ??
'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg',
));
} catch (_) {
continue;
}
}
return videos;
} catch (_) {
return const [];
}
}
Future<VideoItem> getVideoDetails(
String videoId,
) async {
final video =
await _youtube.videos.get(videoId);
return _toVideoItem(video);
}
/// Resolves the uploader directly from the video.
///
/// This is intentionally independent from Google OAuth, so clicking the
/// channel works for search results, Radar recommendations, history and
/// favorites on both Windows and Android.
Future<SubscriptionChannel> getChannelForVideo(
String videoId,
) async {
final channel =
await _youtube.channels.getByVideo(videoId);
return _toSubscriptionChannel(channel);
}
Future<SubscriptionChannel> getChannel(
String channelId,
) async {
final channel =
await _youtube.channels.get(channelId);
return _toSubscriptionChannel(channel);
}
Future<ChannelDetails?> getChannelPreviewDetails(
String channelId,
) async {
if (channelId.trim().isEmpty) {
return null;
}
try {
final channel =
await _youtube.channels.get(channelId);
return ChannelDetails(
channelId: channel.id.value,
title: channel.title,
description: '',
thumbnailUrl: channel.logoUrl,
subscriberCount: channel.subscribersCount,
hiddenSubscriberCount:
channel.subscribersCount == null,
);
} catch (_) {
return null;
}
}
/// Public channel metadata that does NOT require Google OAuth.
///
/// youtube_explode_dart exposes the channel's approximate subscriber
/// count and the public About page exposes description, channel views,
/// join date and country.
Future<ChannelDetails> getPublicChannelDetails(
String channelId,
) async {
Channel? channel;
ChannelAbout? about;
try {
channel = await _youtube.channels.get(channelId);
} catch (error) {
// The Windows yt-dlp fallback below can still recover channel metadata.
// ignore: avoid_print
print(
'[CHANNEL-PUBLIC][18.5.2] Channel.get échoué: $error',
);
}
try {
about = await _youtube.channels.getAboutPage(channelId);
} catch (error) {
// Current YouTube layouts sometimes make the public About parser return
// nothing. This is expected; yt-dlp is our Windows fallback.
// ignore: avoid_print
print(
'[CHANNEL-PUBLIC][18.5.2] About indisponible: $error',
);
}
final publicDetails = ChannelDetails(
channelId: channel?.id.value ?? channelId,
title: channel?.title ?? about?.title ?? '',
description: about?.description ?? '',
thumbnailUrl: channel?.logoUrl ??
(about != null && about.thumbnails.isNotEmpty
? about.thumbnails.last.url.toString()
: ''),
joinDateLabel: about?.joinDate,
viewCount: about?.viewCount,
subscriberCount: channel?.subscribersCount,
hiddenSubscriberCount:
channel?.subscribersCount == null,
country: about?.country,
);
final windowsDetails =
await WindowsChannelMetadataService.instance
.getDetails(channelId);
final merged = _mergeChannelDetails(
publicDetails,
windowsDetails,
);
// ignore: avoid_print
print(
'[CHANNEL-META][18.5.2] id=$channelId '
'subs=${merged.subscriberCount ?? 'NA'} '
'views=${merged.viewCount ?? 'NA'} '
'videos=${merged.videoCount ?? 'NA'} '
'date=${merged.publishedAt?.toIso8601String() ?? merged.joinDateLabel ?? 'NA'}',
);
return merged;
}
ChannelDetails _mergeChannelDetails(
ChannelDetails primary,
ChannelDetails? fallback,
) {
if (fallback == null) {
return primary;
}
String chooseString(
String first,
String second,
) {
return first.trim().isNotEmpty
? first
: second;
}
return ChannelDetails(
channelId: chooseString(
primary.channelId,
fallback.channelId,
),
title: chooseString(
primary.title,
fallback.title,
),
description: chooseString(
primary.description,
fallback.description,
),
thumbnailUrl: chooseString(
primary.thumbnailUrl,
fallback.thumbnailUrl,
),
publishedAt:
primary.publishedAt ?? fallback.publishedAt,
joinDateLabel:
primary.joinDateLabel ?? fallback.joinDateLabel,
viewCount:
primary.viewCount ?? fallback.viewCount,
subscriberCount:
primary.subscriberCount ?? fallback.subscriberCount,
hiddenSubscriberCount:
primary.subscriberCount == null &&
fallback.subscriberCount == null,
videoCount:
primary.videoCount ?? fallback.videoCount,
country:
primary.country ?? fallback.country,
);
}
VideoSorting _videoSortingFor(ChannelUploadSort sort) {
return switch (sort) {
ChannelUploadSort.newest => VideoSorting.newest,
ChannelUploadSort.popularity => VideoSorting.popularity,
ChannelUploadSort.oldest => VideoSorting.oldest,
};
}
/// Loads ONE public channel page at a time.
///
/// youtube_explode_dart exposes ChannelUploadsList.nextPage(), so the UI
/// can keep the first ~30 entries fast and explicitly request more instead
/// of pretending that the first page is the whole channel.
Future<ChannelUploadBatch> getChannelUploadsPage(
String channelId, {
ChannelUploadSort sort = ChannelUploadSort.newest,
bool reset = false,
}) async {
final normalizedId = channelId.trim();
if (normalizedId.isEmpty) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final mustReset =
reset ||
_channelUploadsPage == null ||
_channelUploadsChannelId != normalizedId ||
_channelUploadsSort != sort;
ChannelUploadsList? page;
if (mustReset) {
_channelUploadsPage =
await _youtube.channels.getUploadsFromPage(
normalizedId,
videoSorting: _videoSortingFor(sort),
);
_channelUploadsChannelId = normalizedId;
_channelUploadsSort = sort;
_channelUploadsExhausted = false;
page = _channelUploadsPage;
} else {
if (_channelUploadsExhausted) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final current = _channelUploadsPage;
if (current == null) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
page = await current.nextPage();
if (page == null) {
_channelUploadsExhausted = true;
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
_channelUploadsPage = page;
}
final resolvedPage = page;
if (resolvedPage == null) {
_channelUploadsExhausted = true;
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final videos = resolvedPage
.map(_toVideoItem)
.toList(growable: false);
// YouTube channel pages are normally around 30 items. There is no public
// `hasNextPage` flag on ChannelUploadsList, only nextPage(). A short page
// is therefore considered final; an exact full page keeps the button.
// If the last page contains exactly 30 entries, the next click simply
// receives null and hides the button without losing any data.
final hasMore = videos.length >= 30;
if (!hasMore) {
_channelUploadsExhausted = true;
}
// ignore: avoid_print
print(
'[CHANNEL-VIDEOS][18.7.20] '
'channel=$normalizedId '
'sort=${sort.name} '
'pageVideos=${videos.length} '
'hasMore=$hasMore',
);
return ChannelUploadBatch(
videos: videos,
hasMore: hasMore,
);
}
/// Streams the full public upload catalogue when a caller really needs it.
/// The channel UI uses [getChannelUploadsPage] instead for progressive load.
Stream<VideoItem> getChannelUploads(
String channelId, {
ChannelUploadSort sort = ChannelUploadSort.newest,
}) async* {
var page = await _youtube.channels.getUploadsFromPage(
channelId,
videoSorting: _videoSortingFor(sort),
);
while (true) {
for (final video in page) {
yield _toVideoItem(video);
}
final next = await page.nextPage();
if (next == null) {
break;
}
page = next;
}
}
Future<String> getPlayableMuxedUrl(
String videoId,
) async {
// Stable progressive fallback used by the existing player.
final manifest =
await _youtube.videos.streams.getManifest(videoId);
final muxed =
manifest.muxed.toList(growable: false);
if (muxed.isEmpty) {
throw StateError(
'Aucun flux audio + vidéo compatible n’est disponible.',
);
}
final sorted = [...muxed]
..sort((a, b) {
final height =
b.videoResolution.height.compareTo(
a.videoResolution.height,
);
if (height != 0) {
return height;
}
return b.bitrate.bitsPerSecond.compareTo(
a.bitrate.bitsPerSecond,
);
});
return sorted.first.url.toString();
}
VideoItem _toVideoItem(Video video) {
return VideoItem(
id: video.id.value,
title: video.title,
author: video.author,
channelId: video.channelId.value,
duration: video.duration,
viewCount: video.engagement.viewCount,
likeCount: video.engagement.likeCount,
description: video.description,
uploadDate: video.uploadDate,
thumbnailUrl:
'https://i.ytimg.com/vi/${video.id.value}/hqdefault.jpg',
);
}
SubscriptionChannel _toSubscriptionChannel(
Channel channel,
) {
return SubscriptionChannel(
channelId: channel.id.value,
title: channel.title,
thumbnailUrl: channel.logoUrl,
);
}
void close() {
_youtube.close();
}
}