Files
youtube_personal/lib/screens/video_player_page.dart
T

6342 lines
169 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart';
import '../models/channel_details.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
import '../services/app_settings_service.dart';
import '../services/background_playback_service.dart';
import '../services/interest_profile_service.dart';
import '../services/library_service.dart';
import '../services/media_cache_settings_service.dart';
import '../services/sponsor_block_service.dart';
import '../services/youtube_service.dart';
import '../services/windows_hd_service.dart';
import '../services/windows_media_relay_service.dart';
import '../services/windows_embedded_progressive_service.dart';
import 'channel_videos_page.dart';
class VideoPlayerPage extends StatefulWidget {
const VideoPlayerPage({
super.key,
required this.video,
});
final VideoItem video;
@override
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
}
class _VideoPlayerPageState extends State<VideoPlayerPage>
with WidgetsBindingObserver {
final YoutubeService _youtubeService = YoutubeService();
final WindowsMediaRelayService _windowsRelay =
WindowsMediaRelayService();
final WindowsEmbeddedProgressiveService _windowsEmbeddedProgressive =
WindowsEmbeddedProgressiveService();
late final Player _player;
late final VideoController _videoController;
late final FocusNode _videoFocusNode;
final GlobalKey<VideoState> _videoKey =
GlobalKey<VideoState>();
// Gestes Android sur la surface vidéo :
// - double tap gauche/droite en plein écran = -10/+10 s
// - glissement vertical à droite = volume système média
// - glissement vertical à gauche = luminosité de la fenêtre
// - pincement à deux doigts = zoom vidéo
bool? _androidDoubleTapSeekLeft;
String _androidGestureMode = 'none';
double _androidGestureStartY = 0.0;
double _androidGestureStartValue = 0.5;
double _androidSystemVolume = 0.5;
double _androidWindowBrightness = 0.5;
double _androidZoomScale = 1.0;
double _androidZoomStartScale = 1.0;
final ValueNotifier<String?> _androidGestureFeedbackNotifier =
ValueNotifier<String?>(null);
Timer? _androidGestureFeedbackTimer;
Timer? _androidPauseSleepTimer;
Timer? _androidScreenPolicyWatchdog;
DateTime? _androidPausedSince;
bool? _androidKeepScreenOnApplied;
int? _androidScheduledPauseSleepMinutes;
Timer? _androidOrientationDebounce;
bool _androidOrientationTransitionInProgress = false;
bool? _androidPendingLandscape;
final Map<int, Offset> _androidPointerPositions = <int, Offset>{};
int? _androidPrimaryPointer;
Offset _androidGestureStartPosition = Offset.zero;
double _androidPinchStartDistance = 0.0;
bool _androidGestureActivated = false;
bool _androidPlaybackCompleted = false;
bool _androidMediaCacheConfigured = false;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _completedSubscription;
bool _loading = true;
bool _restoringPosition = false;
bool _openingChannel = false;
bool _historyRecordedForThisOpen = false;
ChannelDetails? _inlineChannelDetails;
VideoItem? _inlineVideoDetails;
bool _inlineChannelDetailsLoading = true;
bool _descriptionExpanded = false;
String? _error;
List<SponsorSegment> _sponsorSegments = <SponsorSegment>[];
bool _sponsorSeekInProgress = false;
String? _activeSponsorSegmentId;
Duration _latestPosition = Duration.zero;
Duration _lastSavedPosition = Duration.zero;
double _playbackRate = 1.0;
static const MethodChannel _pipChannel = MethodChannel(
'com.studioweb.youtube_personal/pip',
);
static const MethodChannel _hdChannel = MethodChannel(
'com.studioweb.youtube_personal/hd',
);
static const MethodChannel _deviceChannel = MethodChannel(
'com.studioweb.youtube_personal/device',
);
bool _androidPip = false;
bool _desktopPip = false;
Rect? _desktopPreviousBounds;
bool _desktopWasMaximized = false;
bool _backgroundAudioActive = false;
bool _screenInteractive = true;
// URL progressive 360p stable. Elle reste volontairement utilisée
// par le Player audio dédié lorsque l'écran Android est verrouillé.
String? _playableUrl;
// Le Player visible peut utiliser un flux vidéo adaptatif HD séparé
// du flux audio. media_kit/libmpv rattache l'audio externe au même Player.
String? _foregroundPlayableUrl;
bool _hdActive = false;
String _qualityLabel = '360p stable';
String _qualityDetails = 'Flux audio + vidéo';
// Chaque nouvelle demande de qualité Windows invalide le téléchargement
// HD précédent. Le 360p stable reste lisible pendant la préparation.
int _windowsHdUpgradeGeneration = 0;
// Qualités réellement absentes de la liste web_embedded pour la vidéo
// courante. Elles sont désactivées après la première vérification.
final Set<int> _windowsUnavailableQualityHeights = <int>{};
// Résultat d'une vérification 1440p/2160p effectuée AVANT de toucher
// au Player. _prepareAndSwitchWindowsHd le réutilise pour éviter une
// deuxième extraction yt-dlp.
Map<String, dynamic>? _windowsHdPreflight;
int? _windowsHdPreflightTarget;
// 0 = Auto. Android et Windows.
int _selectedQualityHeight =
AppSettingsService.instance.defaultResolution.value;
static const Map<int, String> _qualityOptions = <int, String>{
0: 'Auto',
360: '360p',
720: '720p',
1080: '1080p',
1440: '1440p',
2160: '2160p (4K)',
};
String get _preferredAudioLanguage =>
AppSettingsService.instance.preferredLanguage.value;
// 18.7.26 : choix audio uniquement pour la vidéo courante.
// null = suivre le réglage global ; original/ar/en/fr/... = forçage local.
String? _audioTrackOverride;
bool _audioTrackLoading = false;
String get _effectiveAudioLanguagePreference =>
_audioTrackOverride ?? _preferredAudioLanguage;
String get _audioTrackDisplayLabel {
final value = _audioTrackOverride;
if (value == null) {
return 'Auto';
}
if (value == 'original') {
return 'Originale';
}
return _audioLanguageLabel(value);
}
String _audioLanguageLabel(String raw) {
final code = raw.trim().toLowerCase().split(RegExp(r'[-_]')).first;
switch (code) {
case 'ar':
return 'Arabe';
case 'en':
return 'Anglais';
case 'fr':
return 'Français';
case 'de':
return 'Allemand';
case 'es':
return 'Espagnol';
case 'it':
return 'Italien';
case 'pt':
return 'Portugais';
case 'tr':
return 'Turc';
case 'ru':
return 'Russe';
case 'ja':
return 'Japonais';
case 'ko':
return 'Coréen';
case 'hi':
return 'Hindi';
default:
return raw.trim().isEmpty ? 'Piste audio' : raw.trim();
}
}
String _inferOriginalAudioLanguageHint() {
final text = '${widget.video.title} ${widget.video.author}';
// Dernier filet de sécurité pour les clients/extracteurs qui ne marquent
// pas explicitement la piste originale. On reste volontairement prudent :
// l'arabe est fiable grâce à son alphabet distinct.
if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) {
return 'ar';
}
return 'auto';
}
// YouTube peut encoder une vidéo non 16:9 dans un palier qualité sans
// conserver la hauteur nominale du libellé. Exemples réels :
// - 1920x1012 = palier 1080p
// - 2560x1350 = palier 1440p
// - 3840x2026 = palier 2160p / 4K
// La validation doit donc reconnaître le palier par les dimensions,
// et non exiger exactement 1440 ou 2160 lignes.
static bool _matchesQualityTier({
required int targetHeight,
int? width,
int? height,
}) {
final w = width ?? 0;
final h = height ?? 0;
final longest = math.max(w, h);
if (targetHeight >= 2160) {
return longest >= 3800 || h >= 2000;
}
if (targetHeight >= 1440) {
return longest >= 2500 || h >= 1300;
}
if (targetHeight >= 1080) {
return longest >= 1900 || h >= 1000;
}
if (targetHeight >= 720) {
return longest >= 1200 || h >= 650;
}
return h >= targetHeight;
}
// Android arrête le Player vidéo avant ACTION_SCREEN_OFF sur certains
// appareils. On mémorise donc ici l'intention de lecture dès le premier
// callback lifecycle (hidden/paused), tant que Player.state.playing vaut
// encore true.
bool _continuePlaybackOnScreenOff = false;
Timer? _tapFeedbackTimer;
IconData? _tapFeedbackIcon;
@override
void initState() {
super.initState();
if (Platform.isAndroid) {
final bufferMb =
MediaCacheSettingsService.instance.androidBufferMb.value;
_player = Player(
configuration: PlayerConfiguration(
bufferSize: bufferMb * 1024 * 1024,
),
);
} else {
_player = Player();
}
_videoController = VideoController(_player);
_videoFocusNode = FocusNode(
debugLabel: 'youtube-personal-video',
);
_positionSubscription = _player.stream.position.listen(
_onPositionChanged,
);
WidgetsBinding.instance.addObserver(this);
if (Platform.isAndroid) {
_pipChannel.setMethodCallHandler(_handlePipMethodCall);
_playingSubscription =
_player.stream.playing.listen(_onAndroidPlayingChanged);
_completedSubscription =
_player.stream.completed.listen(_onAndroidCompletedChanged);
unawaited(_primeAndroidDeviceControls());
unawaited(_primeAndroidOrientation());
_startAndroidScreenPolicyWatchdog();
unawaited(
_pipChannel.invokeMethod<bool>(
'requestNotificationPermission',
),
);
}
unawaited(
BackgroundPlaybackService.instance.attachPlayer(
_player,
widget.video,
),
);
// The card already contains the video metadata we need.
// Do not issue another watch-page request just for the description:
// that extra request contributed to YouTube rate limiting.
_inlineVideoDetails = widget.video;
// Les notes et catégories personnelles sont chargées indépendamment du
// pipeline vidéo. Une correction manuelle doit être disponible dès que
// la page est affichée, sans déclencher de requête YouTube supplémentaire.
unawaited(
InterestProfileService.instance.ensureLoaded().then((_) {
if (mounted) {
setState(() {});
}
}),
);
unawaited(_loadSponsorSegments());
if (Platform.isWindows) {
unawaited(
_loadVideo().whenComplete(() {
if (mounted) {
unawaited(
_loadInlineChannelDetails(),
);
}
}),
);
} else {
unawaited(
_loadInlineChannelDetails(),
);
unawaited(_loadVideo());
}
}
Future<void> _configureAndroidMediaCache() async {
if (!Platform.isAndroid || _androidMediaCacheConfigured) {
return;
}
_androidMediaCacheConfigured = true;
try {
final cache = MediaCacheSettingsService.instance;
await cache.ensureInitialized();
final native = _player.platform;
if (native is! NativePlayer) {
return;
}
final mode = cache.androidDiskCacheMode.value;
if (mode == MediaCacheSettingsService.androidDiskCacheDisabled) {
await native.setProperty('cache-on-disk', 'no');
debugPrint(
'[MEDIA-CACHE][18.7.15] Android: RAM uniquement '
'${cache.androidBufferMb.value}Mo',
);
return;
}
// Les fichiers mpv sont temporaires. On nettoie les restes d'une
// éventuelle fermeture brutale avant de préparer la nouvelle session.
await cache.clearAndroidMediaCache(mode);
final path = await cache.androidCachePathForMode(mode);
if (path.isEmpty) {
await native.setProperty('cache-on-disk', 'no');
return;
}
await native.setProperty('demuxer-cache-dir', path);
await native.setProperty('demuxer-cache-unlink-files', 'whendone');
await native.setProperty('cache-on-disk', 'yes');
debugPrint(
'[MEDIA-CACHE][18.7.15] Android: cache disque temporaire '
'mode=$mode path=$path buffer=${cache.androidBufferMb.value}Mo',
);
} catch (error) {
// Le cache est une optimisation. Une option mpv non disponible ne doit
// jamais empêcher la lecture : on garde le comportement réseau normal.
debugPrint(
'[MEDIA-CACHE][18.7.15] configuration Android ignorée: $error',
);
}
}
Future<void> _primeAndroidDeviceControls() async {
if (!Platform.isAndroid) {
return;
}
try {
final volume = await _deviceChannel.invokeMethod<num>(
'getSystemVolume',
);
final brightness = await _deviceChannel.invokeMethod<num>(
'getWindowBrightness',
);
if (volume != null) {
_androidSystemVolume =
volume.toDouble().clamp(0.0, 1.0).toDouble();
}
if (brightness != null) {
_androidWindowBrightness =
brightness.toDouble().clamp(0.02, 1.0).toDouble();
}
} catch (error) {
debugPrint(
'[ANDROID-DEVICE][18.7.14] init contrôles: $error',
);
}
}
Future<void> _primeAndroidOrientation() async {
if (!Platform.isAndroid) {
return;
}
try {
final orientation = await _pipChannel.invokeMethod<String>(
'getOrientation',
);
if (!mounted || orientation == null) {
return;
}
_scheduleAndroidFullscreenForOrientation(
orientation == 'landscape',
);
} catch (error) {
debugPrint(
'[ANDROID-ORIENTATION][18.7.19] init: $error',
);
}
}
void _scheduleAndroidFullscreenForOrientation(bool landscape) {
if (!Platform.isAndroid || !mounted || _androidPip) {
return;
}
_androidPendingLandscape = landscape;
_androidOrientationDebounce?.cancel();
_androidOrientationDebounce = Timer(
Duration(milliseconds: 250),
() {
final target = _androidPendingLandscape;
if (target == null) {
return;
}
unawaited(_applyAndroidFullscreenForOrientation(target));
},
);
}
Future<void> _applyAndroidFullscreenForOrientation(
bool landscape,
) async {
if (!Platform.isAndroid ||
!mounted ||
_androidPip ||
_androidOrientationTransitionInProgress) {
return;
}
final state = _videoKey.currentState;
if (state == null) {
_androidOrientationDebounce?.cancel();
_androidOrientationDebounce = Timer(
Duration(milliseconds: 300),
() => _scheduleAndroidFullscreenForOrientation(landscape),
);
return;
}
final fullscreen = state.isFullscreen();
if (landscape == fullscreen) {
// media_kit peut verrouiller temporairement l'orientation lors de
// l'entrée fullscreen. On rend immédiatement la main au capteur Android
// pour que le retour portrait puisse déclencher la sortie fullscreen.
await SystemChrome.setPreferredOrientations(
<DeviceOrientation>[],
);
return;
}
_androidOrientationTransitionInProgress = true;
try {
if (landscape) {
await state.toggleFullscreen();
debugPrint(
'[ANDROID-ORIENTATION][18.7.19] paysage -> plein écran',
);
} else {
await state.exitFullscreen();
debugPrint(
'[ANDROID-ORIENTATION][18.7.19] portrait -> sortie plein écran',
);
}
await SystemChrome.setPreferredOrientations(
<DeviceOrientation>[],
);
} catch (error) {
debugPrint(
'[ANDROID-ORIENTATION][18.7.19] sync: $error',
);
} finally {
await Future<void>.delayed(
Duration(milliseconds: 250),
);
_androidOrientationTransitionInProgress = false;
}
}
void _onAndroidPlayingChanged(bool playing) {
if (!Platform.isAndroid) {
return;
}
if (playing) {
_androidPlaybackCompleted = false;
_androidPausedSince = null;
_androidScheduledPauseSleepMinutes = null;
_androidPauseSleepTimer?.cancel();
unawaited(_setAndroidKeepScreenOn(true));
return;
}
if (_loading) {
return;
}
if (_androidPlaybackCompleted || _player.state.completed) {
_androidScheduledPauseSleepMinutes = null;
_androidPauseSleepTimer?.cancel();
unawaited(_setAndroidKeepScreenOn(false));
return;
}
_androidPausedSince ??= DateTime.now();
_scheduleAndroidPauseScreenSleep();
}
void _onAndroidCompletedChanged(bool completed) {
if (!Platform.isAndroid) {
return;
}
_androidPlaybackCompleted = completed;
if (completed) {
_androidPausedSince = null;
_androidScheduledPauseSleepMinutes = null;
_androidPauseSleepTimer?.cancel();
unawaited(_setAndroidKeepScreenOn(false));
debugPrint(
'[ANDROID-SCREEN][18.7.19] fin vidéo -> veille autorisée',
);
}
}
void _startAndroidScreenPolicyWatchdog() {
if (!Platform.isAndroid) {
return;
}
_androidScreenPolicyWatchdog?.cancel();
_androidScreenPolicyWatchdog = Timer.periodic(
Duration(seconds: 1),
(_) => _syncAndroidScreenPolicy(),
);
_syncAndroidScreenPolicy();
}
void _syncAndroidScreenPolicy() {
if (!Platform.isAndroid || !mounted) {
return;
}
if (_androidPlaybackCompleted || _player.state.completed) {
_androidPausedSince = null;
_androidScheduledPauseSleepMinutes = null;
unawaited(_setAndroidKeepScreenOn(false));
return;
}
if (_loading || _player.state.playing) {
_androidPausedSince = null;
_androidScheduledPauseSleepMinutes = null;
unawaited(_setAndroidKeepScreenOn(true));
return;
}
final minutes =
AppSettingsService.instance.pauseScreenSleepMinutes.value;
if (minutes == 0) {
_androidPausedSince = null;
_androidScheduledPauseSleepMinutes = 0;
unawaited(_setAndroidKeepScreenOn(true));
return;
}
_androidPausedSince ??= DateTime.now();
// Le watchdog ne maintient plus lui-même un Timer Flutter. Il garantit
// seulement que le timer NATIF Android a bien été armé. Ce timer reste
// valable dans la route fullscreen de media_kit.
if (_androidScheduledPauseSleepMinutes != minutes) {
_scheduleAndroidPauseScreenSleep();
}
}
void _scheduleAndroidPauseScreenSleep() {
if (!Platform.isAndroid) {
return;
}
_androidPauseSleepTimer?.cancel();
final minutes =
AppSettingsService.instance.pauseScreenSleepMinutes.value;
if (minutes == 0) {
_androidScheduledPauseSleepMinutes = 0;
unawaited(_setAndroidKeepScreenOn(true));
return;
}
_androidScheduledPauseSleepMinutes = minutes;
_androidKeepScreenOnApplied = true;
unawaited(
_deviceChannel.invokeMethod<bool>(
'schedulePauseScreenSleep',
<String, dynamic>{
'milliseconds': Duration(minutes: minutes).inMilliseconds,
},
).then((_) {
debugPrint(
'[ANDROID-SCREEN][18.7.19] timer natif pause=${minutes}min',
);
}).catchError((Object error) {
debugPrint(
'[ANDROID-SCREEN][18.7.19] timer natif pause: $error',
);
_androidScheduledPauseSleepMinutes = null;
}),
);
}
Future<void> _setAndroidKeepScreenOn(bool value) async {
if (!Platform.isAndroid) {
return;
}
if (_androidKeepScreenOnApplied == value) {
return;
}
try {
await _deviceChannel.invokeMethod<bool>(
'setKeepScreenOn',
<String, dynamic>{'value': value},
);
_androidKeepScreenOnApplied = value;
debugPrint(
'[ANDROID-SCREEN][18.7.19] keepOn=$value',
);
} catch (error) {
debugPrint(
'[ANDROID-SCREEN][18.7.19] keepOn=$value: $error',
);
}
}
void _showAndroidGestureFeedback(String value) {
_androidGestureFeedbackTimer?.cancel();
if (!mounted) {
return;
}
_androidGestureFeedbackNotifier.value = value;
_androidGestureFeedbackTimer = Timer(
Duration(milliseconds: 750),
() {
if (!mounted) {
return;
}
_androidGestureFeedbackNotifier.value = null;
},
);
}
Future<void> _applyAndroidVideoZoom(double scale) async {
if (!Platform.isAndroid) {
return;
}
try {
final native = _player.platform;
if (native is! NativePlayer) {
return;
}
// mpv video-zoom est logarithmique base 2 :
// 0.0 = 1x, 1.0 = 2x, log2(3) ~= 1.585 = 3x.
final mpvZoom = math.log(scale) / math.ln2;
await native.setProperty(
'video-zoom',
mpvZoom.toStringAsFixed(4),
);
} catch (error) {
debugPrint(
'[ANDROID-GESTURE][18.7.18] zoom natif: $error',
);
}
}
Widget _buildAndroidVideoControls(VideoState state) {
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
children: [
// Ce Listener est un PARENT des contrôles media_kit.
// Il observe donc les pointeurs sans intercepter les taps.
// media_kit recopie ce builder dans sa route fullscreen, ce qui
// rend automatiquement les mêmes gestes disponibles en plein écran.
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) => _handleAndroidPointerDown(
event,
constraints.maxWidth,
),
onPointerMove: (event) => _handleAndroidPointerMove(
event,
constraints.maxWidth,
constraints.maxHeight,
),
onPointerUp: _handleAndroidPointerEnd,
onPointerCancel: _handleAndroidPointerEnd,
child: MaterialVideoControls(state),
),
IgnorePointer(
child: Center(
child: ValueListenableBuilder<String?>(
valueListenable: _androidGestureFeedbackNotifier,
builder: (context, feedback, child) {
if (feedback == null) {
return SizedBox.shrink();
}
return Container(
padding: EdgeInsets.symmetric(
horizontal: 18,
vertical: 10,
),
decoration: BoxDecoration(
color: Color(0xB8000000),
borderRadius: BorderRadius.circular(18),
),
child: Text(
feedback,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
);
},
),
),
),
],
);
},
);
}
void _handleAndroidPointerDown(
PointerDownEvent event,
double width,
) {
if (!Platform.isAndroid || width <= 0) {
return;
}
_androidPointerPositions[event.pointer] = event.localPosition;
if (_androidPointerPositions.length >= 2) {
final points = _androidPointerPositions.values.take(2).toList();
_androidGestureMode = 'zoom';
_androidGestureActivated = true;
_androidZoomStartScale = _androidZoomScale;
_androidPinchStartDistance = (points[0] - points[1]).distance;
return;
}
_androidPrimaryPointer = event.pointer;
_androidGestureStartPosition = event.localPosition;
_androidGestureStartY = event.localPosition.dy;
_androidGestureActivated = false;
if (event.localPosition.dx < width / 2) {
_androidGestureMode = 'brightness-pending';
_androidGestureStartValue = _androidWindowBrightness;
} else {
_androidGestureMode = 'volume-pending';
_androidGestureStartValue = _androidSystemVolume;
}
}
void _handleAndroidPointerMove(
PointerMoveEvent event,
double width,
double height,
) {
if (!Platform.isAndroid || width <= 0 || height <= 0) {
return;
}
if (!_androidPointerPositions.containsKey(event.pointer)) {
return;
}
_androidPointerPositions[event.pointer] = event.localPosition;
if (_androidPointerPositions.length >= 2 ||
_androidGestureMode == 'zoom') {
if (_androidPointerPositions.length < 2) {
return;
}
final points = _androidPointerPositions.values.take(2).toList();
final distance = (points[0] - points[1]).distance;
if (_androidGestureMode != 'zoom' ||
_androidPinchStartDistance <= 0.0) {
_androidGestureMode = 'zoom';
_androidGestureActivated = true;
_androidZoomStartScale = _androidZoomScale;
_androidPinchStartDistance = distance;
return;
}
if (_androidPinchStartDistance <= 0.0) {
return;
}
final ratio = distance / _androidPinchStartDistance;
final next = (_androidZoomStartScale * ratio)
.clamp(1.0, 3.0)
.toDouble();
if ((next - _androidZoomScale).abs() >= 0.01) {
_androidZoomScale = next;
unawaited(_applyAndroidVideoZoom(next));
_showAndroidGestureFeedback(
'Zoom ${next.toStringAsFixed(1)}×',
);
}
return;
}
if (event.pointer != _androidPrimaryPointer) {
return;
}
final dy = _androidGestureStartPosition.dy - event.localPosition.dy;
final dx = event.localPosition.dx - _androidGestureStartPosition.dx;
// Laisse les petits mouvements aux taps/double-taps et aux contrôles.
if (!_androidGestureActivated) {
if (dy.abs() < 14.0) {
return;
}
if (dy.abs() <= dx.abs() * 1.15) {
return;
}
_androidGestureActivated = true;
if (_androidGestureMode == 'volume-pending') {
_androidGestureMode = 'volume';
} else if (_androidGestureMode == 'brightness-pending') {
_androidGestureMode = 'brightness';
}
}
if (_androidGestureMode != 'volume' &&
_androidGestureMode != 'brightness') {
return;
}
final valueDelta = dy / (height * 0.65);
final next = (_androidGestureStartValue + valueDelta)
.clamp(0.0, 1.0)
.toDouble();
if (_androidGestureMode == 'volume') {
if ((next - _androidSystemVolume).abs() < 0.01) {
return;
}
_androidSystemVolume = next;
unawaited(_applyAndroidSystemVolume(next));
_showAndroidGestureFeedback(
'Volume ${(next * 100).round()}%',
);
return;
}
final brightness = next.clamp(0.02, 1.0).toDouble();
if ((brightness - _androidWindowBrightness).abs() < 0.01) {
return;
}
_androidWindowBrightness = brightness;
unawaited(_applyAndroidWindowBrightness(brightness));
_showAndroidGestureFeedback(
'Luminosité ${(brightness * 100).round()}%',
);
}
void _handleAndroidPointerEnd(PointerEvent event) {
if (!Platform.isAndroid) {
return;
}
_androidPointerPositions.remove(event.pointer);
if (_androidPointerPositions.isEmpty) {
debugPrint(
'[ANDROID-GESTURE][18.7.18] mode=$_androidGestureMode '
'zoom=${_androidZoomScale.toStringAsFixed(2)} '
'volume=${(_androidSystemVolume * 100).round()}% '
'brightness=${(_androidWindowBrightness * 100).round()}%',
);
_androidPrimaryPointer = null;
_androidGestureMode = 'none';
_androidGestureActivated = false;
_androidPinchStartDistance = 0.0;
return;
}
// Après un pincement, on attend que tous les doigts soient relevés pour
// éviter qu'un doigt restant soit interprété comme volume/luminosité.
if (_androidGestureMode == 'zoom') {
_androidGestureMode = 'ignore-until-up';
}
}
Future<void> _applyAndroidSystemVolume(double value) async {
try {
await _deviceChannel.invokeMethod<bool>(
'setSystemVolume',
<String, dynamic>{'value': value},
);
} catch (error) {
debugPrint(
'[ANDROID-GESTURE][18.7.18] volume natif: $error',
);
}
}
Future<void> _applyAndroidWindowBrightness(double value) async {
try {
await _deviceChannel.invokeMethod<bool>(
'setWindowBrightness',
<String, dynamic>{'value': value},
);
} catch (error) {
debugPrint(
'[ANDROID-GESTURE][18.7.18] luminosité native: $error',
);
}
}
void _handleAndroidScaleStart(
ScaleStartDetails details,
double width,
) {
if (!Platform.isAndroid || width <= 0) {
_androidGestureMode = 'none';
return;
}
if (details.pointerCount >= 2) {
_androidGestureMode = 'zoom';
_androidZoomStartScale = _androidZoomScale;
return;
}
_androidGestureStartY = details.localFocalPoint.dy;
if (details.localFocalPoint.dx < width / 2) {
_androidGestureMode = 'brightness';
_androidGestureStartValue = _androidWindowBrightness;
} else {
_androidGestureMode = 'volume';
_androidGestureStartValue = _androidSystemVolume;
}
}
void _handleAndroidScaleUpdate(
ScaleUpdateDetails details,
double height,
) {
if (!Platform.isAndroid || height <= 0) {
return;
}
if (details.pointerCount >= 2) {
if (_androidGestureMode != 'zoom') {
_androidGestureMode = 'zoom';
_androidZoomStartScale = _androidZoomScale;
}
final next = (_androidZoomStartScale * details.scale)
.clamp(1.0, 3.0)
.toDouble();
if ((next - _androidZoomScale).abs() < 0.01) {
return;
}
_androidZoomScale = next;
unawaited(_applyAndroidVideoZoom(next));
_showAndroidGestureFeedback(
'Zoom ${next.toStringAsFixed(1)}×',
);
return;
}
if (_androidGestureMode != 'volume' &&
_androidGestureMode != 'brightness') {
return;
}
final delta =
_androidGestureStartY - details.localFocalPoint.dy;
final valueDelta = delta / (height * 0.65);
final next = (_androidGestureStartValue + valueDelta)
.clamp(0.0, 1.0)
.toDouble();
if (_androidGestureMode == 'volume') {
if ((next - _androidSystemVolume).abs() < 0.01) {
return;
}
_androidSystemVolume = next;
unawaited(
_deviceChannel.invokeMethod<bool>(
'setSystemVolume',
<String, dynamic>{'value': next},
),
);
_showAndroidGestureFeedback(
'Volume ${(next * 100).round()}%',
);
return;
}
final brightness = next.clamp(0.02, 1.0).toDouble();
if ((brightness - _androidWindowBrightness).abs() < 0.01) {
return;
}
_androidWindowBrightness = brightness;
unawaited(
_deviceChannel.invokeMethod<bool>(
'setWindowBrightness',
<String, dynamic>{'value': brightness},
),
);
_showAndroidGestureFeedback(
'Luminosité ${(brightness * 100).round()}%',
);
}
void _handleAndroidScaleEnd(ScaleEndDetails details) {
if (!Platform.isAndroid) {
return;
}
debugPrint(
'[ANDROID-GESTURE][18.7.14] mode=$_androidGestureMode '
'zoom=${_androidZoomScale.toStringAsFixed(2)} '
'volume=${(_androidSystemVolume * 100).round()}% '
'brightness=${(_androidWindowBrightness * 100).round()}%',
);
_androidGestureMode = 'none';
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!Platform.isAndroid) {
return;
}
final playingNow = _player.state.playing;
// Important sur Xiaomi/HyperOS et certains Android récents :
// hidden/paused arrive AVANT que le système stoppe AudioTrack.
// C'est donc ici qu'on sauvegarde le vrai état "était en lecture".
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
// Ces callbacks arrivent assez tôt pour refléter l'état réel choisi
// par l'utilisateur. Si la vidéo est déjà en pause, on mémorise false.
_continuePlaybackOnScreenOff = playingNow;
} else if (state == AppLifecycleState.paused &&
playingNow) {
// Si Android passe directement par paused alors que le Player joue
// encore, on conserve true. En revanche un paused=false tardif ne doit
// jamais écraser un true capturé juste avant l'arrêt système d'AudioTrack.
_continuePlaybackOnScreenOff = true;
}
if (state == AppLifecycleState.resumed &&
!_backgroundAudioActive) {
_continuePlaybackOnScreenOff = false;
}
debugPrint(
'[BACKGROUND] lifecycle=$state '
'pip=$_androidPip '
'playing=$playingNow '
'continueOnLock=$_continuePlaybackOnScreenOff',
);
}
Future<void> _switchToDedicatedBackgroundAudio() async {
if (_backgroundAudioActive ||
_loading ||
_error != null ||
_playableUrl == null) {
return;
}
final shouldPlay =
_continuePlaybackOnScreenOff ||
_player.state.playing;
final position =
_latestPosition > _player.state.position
? _latestPosition
: _player.state.position;
debugPrint(
'[BACKGROUND] start dedicated audio '
'at ${position.inMilliseconds} ms '
'(playerPlaying=${_player.state.playing} '
'continueOnLock=$_continuePlaybackOnScreenOff)',
);
try {
await BackgroundPlaybackService.instance.enterBackgroundAudio(
url: _playableUrl!,
video: widget.video,
position: position,
shouldPlay: shouldPlay,
rate: _playbackRate,
);
_backgroundAudioActive = true;
_continuePlaybackOnScreenOff = false;
// Le Player vidéo a déjà perdu sa Surface ou est sur le point de
// la perdre. On le laisse en pause : le Player de fond est désormais
// la source audio officielle pour la MediaSession.
if (_player.state.playing) {
await _player.pause();
}
debugPrint(
'[BACKGROUND] dedicated audio active',
);
} catch (error) {
debugPrint(
'[BACKGROUND] dedicated audio failed: $error',
);
}
}
Future<void> _restoreVideoFromBackgroundAudio() async {
if (!_backgroundAudioActive) {
return;
}
debugPrint(
'[BACKGROUND] restore video from dedicated audio',
);
try {
final snapshot =
await BackgroundPlaybackService.instance.exitBackgroundAudio(
foregroundPlayer: _player,
video: widget.video,
);
_backgroundAudioActive = false;
_continuePlaybackOnScreenOff = false;
if (snapshot == null) {
return;
}
// Laisse Android recréer complètement la Surface du FlutterSurfaceView.
await Future<void>.delayed(
Duration(milliseconds: 650),
);
if (!mounted) {
return;
}
await _seekAndConfirm(snapshot.position);
await _player.setRate(snapshot.rate);
_latestPosition = snapshot.position;
_lastSavedPosition = snapshot.position;
debugPrint(
'[BACKGROUND] foreground seek confirmed '
'at ${snapshot.position.inMilliseconds} ms',
);
if (snapshot.playing) {
await _player.play();
} else {
await _player.pause();
}
debugPrint(
'[BACKGROUND] video restored '
'at ${snapshot.position.inMilliseconds} ms '
'playing=${snapshot.playing}',
);
} catch (error) {
debugPrint(
'[BACKGROUND] restore video failed: $error',
);
}
}
Future<dynamic> _handlePipMethodCall(
MethodCall call,
) async {
if (call.method == 'pipChanged' && mounted) {
setState(() {
_androidPip = call.arguments == true;
});
return null;
}
if (call.method == 'screenInteractiveChanged') {
final interactive = call.arguments == true;
_screenInteractive = interactive;
debugPrint(
'[BACKGROUND] native screen interactive=$interactive '
'pip=$_androidPip '
'playing=${_player.state.playing}',
);
if (!interactive) {
await _switchToDedicatedBackgroundAudio();
} else {
await _restoreVideoFromBackgroundAudio();
}
return null;
}
if (call.method == 'pauseScreenSleepReleased') {
// Le timer natif vient réellement de retirer KEEP_SCREEN_ON.
_androidKeepScreenOnApplied = false;
debugPrint(
'[ANDROID-SCREEN][18.7.19] timer natif -> keepOn=false',
);
return null;
}
if (call.method == 'orientationChanged') {
final orientation = call.arguments?.toString();
if (orientation == 'landscape' || orientation == 'portrait') {
_scheduleAndroidFullscreenForOrientation(
orientation == 'landscape',
);
}
return null;
}
return null;
}
Future<void> _togglePlayPause() async {
if (_loading || _error != null) {
return;
}
if (_player.state.playing) {
_continuePlaybackOnScreenOff = false;
await _player.pause();
_showTapFeedback(Icons.pause_rounded);
} else {
_continuePlaybackOnScreenOff = true;
await _player.play();
_showTapFeedback(Icons.play_arrow_rounded);
}
}
void _showTapFeedback(IconData icon) {
_tapFeedbackTimer?.cancel();
if (!mounted) {
return;
}
setState(() {
_tapFeedbackIcon = icon;
});
_tapFeedbackTimer = Timer(
Duration(milliseconds: 500),
() {
if (!mounted) {
return;
}
setState(() {
_tapFeedbackIcon = null;
});
},
);
}
Future<void> _togglePictureInPicture() async {
if (Platform.isAndroid) {
try {
setState(() {
_androidPip = true;
});
final entered = await _pipChannel.invokeMethod<bool>(
'enterPip',
);
if (entered != true && mounted) {
setState(() {
_androidPip = false;
});
}
} catch (error) {
if (mounted) {
setState(() {
_androidPip = false;
});
}
debugPrint('[PIP][ANDROID] $error');
}
return;
}
if (Platform.isWindows) {
if (_desktopPip) {
await _exitDesktopPip();
} else {
await _enterDesktopPip();
}
}
}
Future<void> _enterDesktopPip() async {
_desktopPreviousBounds = await windowManager.getBounds();
_desktopWasMaximized = await windowManager.isMaximized();
if (_desktopWasMaximized) {
await windowManager.unmaximize();
}
if (mounted) {
setState(() {
_desktopPip = true;
});
}
await windowManager.setAlwaysOnTop(true);
await windowManager.setResizable(true);
await windowManager.setSize(
Size(480, 300),
animate: true,
);
await windowManager.setAlignment(
Alignment.bottomRight,
animate: true,
);
}
Future<void> _exitDesktopPip() async {
await windowManager.setAlwaysOnTop(false);
final previousBounds = _desktopPreviousBounds;
if (previousBounds != null) {
await windowManager.setBounds(
previousBounds,
animate: true,
);
}
if (_desktopWasMaximized) {
await windowManager.maximize();
}
if (mounted) {
setState(() {
_desktopPip = false;
});
}
}
Future<void> _loadSponsorSegments() async {
final segments = await SponsorBlockService.instance
.getSponsorSegments(widget.video.id);
if (!mounted) {
return;
}
_sponsorSegments = segments;
if (segments.isNotEmpty) {
debugPrint(
'[SPONSORBLOCK] ${segments.length} segment(s) sponsor trouvé(s) '
'pour ${widget.video.id}',
);
unawaited(_maybeSkipSponsor(_player.state.position));
}
}
Future<void> _maybeSkipSponsor(Duration position) async {
if (_sponsorSeekInProgress ||
_restoringPosition ||
_backgroundAudioActive ||
_sponsorSegments.isEmpty) {
return;
}
SponsorSegment? match;
for (final segment in _sponsorSegments) {
if (segment.contains(position)) {
match = segment;
break;
}
}
if (match == null) {
_activeSponsorSegmentId = null;
return;
}
if (_activeSponsorSegmentId == match.id) {
return;
}
_activeSponsorSegmentId = match.id;
_sponsorSeekInProgress = true;
final wasPlaying = _player.state.playing;
final target = match.end + Duration(milliseconds: 80);
try {
debugPrint(
'[SPONSORBLOCK] skip '
'${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms',
);
await _player.seek(target);
if (wasPlaying) {
await _player.play();
}
_latestPosition = target;
_lastSavedPosition = target;
unawaited(
LibraryService.instance.savePlaybackPosition(
widget.video.id,
target,
duration: widget.video.duration,
),
);
if (mounted) {
final seconds = match.duration.inSeconds;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Sponsor ignoré automatiquement'
'${seconds > 0 ? ' • ${seconds}s' : ''}',
),
duration: Duration(milliseconds: 1400),
),
);
}
} catch (error) {
debugPrint('[SPONSORBLOCK] skip impossible: $error');
} finally {
_sponsorSeekInProgress = false;
}
}
void _onPositionChanged(Duration position) {
_latestPosition = position;
if (_restoringPosition) {
return;
}
unawaited(_maybeSkipSponsor(position));
if ((position - _lastSavedPosition).abs() >=
Duration(seconds: 5)) {
_lastSavedPosition = position;
unawaited(
LibraryService.instance.savePlaybackPosition(
widget.video.id,
position,
duration: widget.video.duration,
),
);
}
}
List<Map<String, dynamic>> _parseAvailableAudioTracks(
Map<dynamic, dynamic>? info,
) {
final byKey = <String, Map<String, dynamic>>{};
final rawTracks = info?['availableAudioTracks'];
if (rawTracks is List) {
for (final raw in rawTracks) {
if (raw is! Map) {
continue;
}
final map = raw.map(
(key, value) => MapEntry(key.toString(), value),
);
final key = (map['key'] ?? map['language'] ?? '')
.toString()
.trim()
.toLowerCase();
if (key.isEmpty || key == 'und' || key == 'auto') {
continue;
}
final original = map['original'] == true || key == 'original';
final normalizedKey = original ? 'original' : key;
final rawLabel = (map['label'] ?? '').toString().trim();
byKey.putIfAbsent(
normalizedKey,
() => <String, dynamic>{
'key': normalizedKey,
'label': original
? (rawLabel.isEmpty ? 'Originale' : 'Originale • $rawLabel')
: (rawLabel.isEmpty ? _audioLanguageLabel(key) : rawLabel),
'original': original,
},
);
}
}
// Compatibilité avec l'extracteur Android 18.7.25.
final rawLanguages = info?['availableAudioLanguages'];
if (rawLanguages is List) {
for (final raw in rawLanguages) {
final value = raw?.toString().trim() ?? '';
if (value.isEmpty) {
continue;
}
final lower = value.toLowerCase();
String key = lower.split(RegExp(r'[-_]')).first;
if (lower.contains('original') ||
lower.contains('الأصلية') ||
lower.contains('أصلي') ||
lower.contains('اصلي')) {
key = 'original';
}
if (key == 'und' || key == 'auto') {
continue;
}
byKey.putIfAbsent(
key,
() => <String, dynamic>{
'key': key,
'label': key == 'original'
? 'Originale'
: _audioLanguageLabel(key),
'original': key == 'original',
},
);
}
}
final values = byKey.values.toList(growable: false);
values.sort((a, b) {
if (a['original'] == true && b['original'] != true) return -1;
if (b['original'] == true && a['original'] != true) return 1;
return (a['label'] as String).compareTo(b['label'] as String);
});
return values;
}
Future<Map<dynamic, dynamic>?> _discoverAudioTrackInfo() async {
final currentHeight = _selectedQualityHeight;
final targetHeight = currentHeight <= 360
? 720
: currentHeight <= 0
? 1080
: currentHeight;
if (Platform.isAndroid) {
return _hdChannel.invokeMapMethod<dynamic, dynamic>(
'getHdStream',
<String, dynamic>{
'videoId': widget.video.id,
'targetHeight': targetHeight,
// La découverte ne doit pas dépendre de la piste déjà choisie.
'preferredLanguage': 'auto',
'originalLanguageHint': _inferOriginalAudioLanguageHint(),
},
);
}
if (Platform.isWindows) {
return WindowsHdService.instance.getEmbeddedHdStream(
videoId: widget.video.id,
targetHeight: targetHeight,
preferredLanguage: 'auto',
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
}
return null;
}
Future<void> _showAudioTrackPicker() async {
if (!(Platform.isAndroid || Platform.isWindows) ||
_audioTrackLoading ||
_loading) {
return;
}
setState(() {
_audioTrackLoading = true;
});
Map<dynamic, dynamic>? info;
try {
info = await _discoverAudioTrackInfo().timeout(
const Duration(seconds: 15),
);
} catch (error) {
debugPrint(
'[AUDIO-TRACK][18.7.26] découverte impossible: $error',
);
} finally {
if (mounted) {
setState(() {
_audioTrackLoading = false;
});
}
}
if (!mounted) {
return;
}
final tracks = _parseAvailableAudioTracks(info);
final choices = <Map<String, dynamic>>[
<String, dynamic>{
'key': 'settings',
'label': 'Auto (selon les réglages)',
'original': false,
},
...tracks,
];
if (!choices.any((item) => item['key'] == 'original')) {
choices.insert(
1,
<String, dynamic>{
'key': 'original',
'label': 'Originale',
'original': true,
},
);
}
final selectedKey = _audioTrackOverride ?? 'settings';
final picked = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 12),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 10),
child: Text(
'Piste audio',
style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
for (final choice in choices)
RadioListTile<String>(
value: choice['key'] as String,
groupValue: selectedKey,
title: Text(choice['label'] as String),
subtitle: choice['key'] == 'settings'
? Text(
'Réglage global : ${_preferredAudioLanguage == 'auto' ? 'originale prioritaire' : _audioLanguageLabel(_preferredAudioLanguage)}',
)
: null,
onChanged: (value) {
if (value != null) {
Navigator.of(sheetContext).pop(value);
}
},
),
],
),
);
},
);
if (picked == null || !mounted) {
return;
}
await _applyAudioTrackChoice(picked);
}
Future<void> _applyAudioTrackChoice(String choice) async {
final override = choice == 'settings' ? null : choice;
if (_audioTrackOverride == override) {
return;
}
setState(() {
_audioTrackOverride = override;
});
final currentHeight = _selectedQualityHeight;
final targetHeight = currentHeight == 360
? 720
: currentHeight;
if (currentHeight == 360 && mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text(
'Le changement de piste audio nécessite un flux HD séparé.',
),
duration: Duration(seconds: 2),
),
);
}
debugPrint(
'[AUDIO-TRACK][18.7.26] choix=${override ?? 'settings'} '
'préférence=$_effectiveAudioLanguagePreference '
'cible=${targetHeight}p',
);
await _changeQuality(targetHeight, force: true);
}
Future<void> _loadVideo() async {
await _loadVideoInternal();
}
Future<void> _changeQuality(int height, {bool force = false}) async {
if (!(Platform.isAndroid || Platform.isWindows) ||
_loading ||
(!force && height == _selectedQualityHeight)) {
return;
}
// 18.7.9 Android uniquement : la bascule de qualité ne repasse plus
// par _loadVideoInternal. Cette ancienne voie rechargeait aussi le flux
// stable, effectuait un second seek et gardait l'overlay _loading actif
// bien après que MediaCodec avait déjà confirmé le 720p.
//
// Windows ne passe JAMAIS ici et conserve intégralement sa baseline
// protégée 18.7.5.
if (Platform.isAndroid) {
await _changeAndroidQuality(height, force: force);
return;
}
// Windows : ne jamais faire semblant qu'une 4K/1440p existe.
//
// Le log précédent montrait cible=2160 puis format 303 1920x1080.
// Cela signifie que web_embedded n'exposait pas de 2160p pour cette
// vidéo. On vérifie donc AVANT d'interrompre la lecture actuelle.
if (Platform.isWindows && height > 1080) {
if (_windowsUnavailableQualityHeights.contains(height)) {
_showUnavailableWindowsQuality(height);
return;
}
debugPrint(
'[HD-WINDOWS][17.21] vérification stricte '
'${_qualityOptions[height] ?? '${height}p'} '
'avant changement',
);
try {
final preflight =
await WindowsHdService.instance.getEmbeddedHdStream(
videoId: widget.video.id,
targetHeight: height,
preferredLanguage: _effectiveAudioLanguagePreference,
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
if (!mounted) {
return;
}
final actualHeight =
preflight?['height'];
final rawAvailable =
preflight?['availableHeights'];
final available = rawAvailable is List
? rawAvailable
.whereType<num>()
.map((value) => value.toInt())
.toSet()
: <int>{};
final actualWidth = preflight?['width'];
final tierMatched =
preflight != null &&
actualHeight is int &&
_matchesQualityTier(
targetHeight: height,
width: actualWidth is int ? actualWidth : null,
height: actualHeight,
);
if (mounted) {
setState(() {
if (tierMatched) {
_windowsUnavailableQualityHeights.remove(height);
} else {
_windowsUnavailableQualityHeights.add(height);
}
});
}
if (!tierMatched) {
debugPrint(
'[HD-WINDOWS][17.21] '
'${height}p indisponible; '
'résolution réelle max obtenue='
'${actualHeight ?? 'aucune'} '
'disponibles=$available',
);
_showUnavailableWindowsQuality(
height,
actualHeight:
actualHeight is int ? actualHeight : null,
);
// Très important : on garde la vidéo actuelle telle quelle.
// Pas de retour 360p, pas de rechargement, pas de faux "4K".
return;
}
_windowsHdPreflight = preflight;
_windowsHdPreflightTarget = height;
} catch (error) {
debugPrint(
'[HD-WINDOWS][17.21] vérification qualité '
'${height}p impossible: $error',
);
if (mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Impossible de vérifier cette qualité pour le moment.',
),
duration: Duration(seconds: 2),
),
);
}
return;
}
} else if (Platform.isWindows) {
_windowsHdPreflight = null;
_windowsHdPreflightTarget = null;
}
final currentPosition =
_player.state.position > _latestPosition
? _player.state.position
: _latestPosition;
final wasPlaying = _player.state.playing;
if (Platform.isWindows) {
_windowsHdUpgradeGeneration++;
unawaited(
_windowsEmbeddedProgressive.stop(),
);
}
setState(() {
_selectedQualityHeight = height;
});
debugPrint(
'[HD] changement qualité -> '
'${_qualityOptions[height] ?? height}',
);
await _loadVideoInternal(
forcedResumePosition: currentPosition,
forcePlay: wasPlaying,
);
}
Future<void> _changeAndroidQuality(int height, {bool force = false}) async {
if (!Platform.isAndroid ||
_loading ||
(!force && height == _selectedQualityHeight)) {
return;
}
final previousHeight = _selectedQualityHeight;
final previousLabel = _qualityLabel;
final previousDetails = _qualityDetails;
final switchPosition =
_player.state.position > _latestPosition
? _player.state.position
: _latestPosition;
final wasPlaying = _player.state.playing;
debugPrint(
'[HD-ANDROID][18.7.10] changement qualité '
'${_qualityOptions[previousHeight] ?? previousHeight} -> '
'${_qualityOptions[height] ?? height} '
'à ${switchPosition.inMilliseconds}ms',
);
// 360p est déjà un flux muxé audio+vidéo. On peut revenir dessus sans
// interroger de nouveau l'extracteur natif HD.
if (height == 360) {
try {
final stableUrl =
(_playableUrl != null && _playableUrl!.isNotEmpty)
? _playableUrl!
: await _youtubeService.getPlayableMuxedUrl(
widget.video.id,
);
if (!mounted) {
return;
}
setState(() {
_selectedQualityHeight = 360;
_loading = true;
_error = null;
_qualityLabel = '360p';
_qualityDetails = 'Bascule Android…';
});
await _player.open(
Media(
stableUrl,
start: switchPosition,
),
play: false,
);
await _player.setRate(_playbackRate).timeout(
Duration(seconds: 3),
onTimeout: () {},
);
if (wasPlaying) {
await _player.play().timeout(
Duration(seconds: 3),
onTimeout: () {},
);
} else {
await _player.pause().timeout(
Duration(seconds: 3),
onTimeout: () {},
);
}
_hdActive = false;
_foregroundPlayableUrl = stableUrl;
if (mounted) {
setState(() {
_loading = false;
_qualityLabel = '360p';
_qualityDetails = 'Qualité choisie';
});
}
debugPrint(
'[HD-ANDROID][18.7.10] retour 360p confirmé '
'à ${switchPosition.inMilliseconds}ms',
);
return;
} catch (error) {
debugPrint(
'[HD-ANDROID][18.7.10] retour 360p échoué: $error',
);
if (mounted) {
setState(() {
_selectedQualityHeight = previousHeight;
_loading = false;
_qualityLabel = previousLabel;
_qualityDetails = previousDetails;
});
}
return;
}
}
// Préparer le flux HD pendant que la qualité actuelle continue à jouer.
// Aucun overlay plein écran tant que l'extraction native n'est pas prête.
if (mounted) {
setState(() {
_selectedQualityHeight = height;
_qualityDetails =
'Préparation ${_qualityOptions[height] ?? '${height}p'}…';
});
}
Map<dynamic, dynamic>? hd;
try {
hd = await _requestPlatformHdStream(height).timeout(
Duration(seconds: 15),
);
} catch (error) {
debugPrint(
'[HD-ANDROID][18.7.10] extraction ${height}p échouée: $error',
);
if (mounted) {
setState(() {
_selectedQualityHeight = previousHeight;
_qualityLabel = previousLabel;
_qualityDetails = previousDetails;
_loading = false;
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'${_qualityOptions[height] ?? '${height}p'} indisponible pour le moment.',
),
duration: Duration(seconds: 2),
),
);
}
return;
}
final videoUrl = hd?['videoUrl'];
final audioUrl = hd?['audioUrl'];
final width = hd?['width'];
final resolvedHeight = hd?['height'];
final fps = hd?['fps'];
final videoCodec = hd?['videoCodec'];
final audioCodec = hd?['audioCodec'];
final audioLanguage = hd?['audioLanguage'];
final audioTrackName = hd?['audioTrackName'];
final preferredAudioMatched =
hd?['preferredAudioMatched'] == true;
final exactTargetMatched =
hd?['exactTargetMatched'] == true;
final availableHeightsRaw = hd?['availableHeights'];
final availableHeights = <int>[];
if (availableHeightsRaw is List) {
for (final value in availableHeightsRaw) {
if (value is int) {
availableHeights.add(value);
}
}
availableHeights.sort();
}
debugPrint(
'[HD-ANDROID][18.7.10] extracteur cible=$height '
'résolue=${resolvedHeight ?? '?'} '
'exact=$exactTargetMatched '
'disponibles=$availableHeights',
);
if (videoUrl is! String ||
videoUrl.isEmpty ||
audioUrl is! String ||
audioUrl.isEmpty ||
resolvedHeight is! int ||
resolvedHeight < 720) {
debugPrint(
'[HD-ANDROID][18.7.10] paire ${height}p invalide',
);
if (mounted) {
setState(() {
_selectedQualityHeight = previousHeight;
_qualityLabel = previousLabel;
_qualityDetails = previousDetails;
_loading = false;
});
}
return;
}
// Sur Android, un choix 1440p/2160p ne doit jamais être validé par
// un flux 1080p. Si la vidéo ne propose pas la hauteur demandée,
// on conserve la qualité précédente et on l'indique clairement.
if (height > 1080 &&
!_matchesQualityTier(
targetHeight: height,
width: width is int ? width : null,
height: resolvedHeight,
)) {
final maxAvailable = availableHeights.isEmpty
? resolvedHeight
: availableHeights.last;
debugPrint(
'[HD-ANDROID][18.7.10] ${height}p refusée: '
'extracteur=${resolvedHeight}p max=$maxAvailable '
'disponibles=$availableHeights',
);
if (mounted) {
setState(() {
_selectedQualityHeight = previousHeight;
_qualityLabel = previousLabel;
_qualityDetails = previousDetails;
_loading = false;
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'${_qualityOptions[height] ?? '${height}p'} '
'n’est pas disponible pour cette vidéo'
'${maxAvailable > 0 ? ' (max ${maxAvailable}p).' : '.'}',
),
duration: Duration(seconds: 3),
),
);
}
return;
}
final resolvedWidth = width is int ? width : 0;
final resolvedFps = fps is int ? fps : 0;
final resolvedAudioLanguage =
audioLanguage is String && audioLanguage.trim().isNotEmpty
? audioLanguage.trim()
: 'und';
final resolvedAudioTitle =
audioTrackName is String && audioTrackName.trim().isNotEmpty
? audioTrackName.trim()
: preferredAudioMatched
? 'YouTube audio • langue préférée'
: 'YouTube audio';
try {
// Le flux est déjà résolu. L'overlay ne couvre l'écran que pendant la
// vraie bascule du Player, pas pendant l'extraction NewPipe.
if (mounted) {
setState(() {
_loading = true;
_error = null;
_restoringPosition = switchPosition > Duration.zero;
_qualityLabel = '${resolvedHeight}p';
_qualityDetails = 'Bascule Android…';
});
}
// Point clé du correctif : media_kit ouvre directement le nouveau média
// au timestamp courant. On évite le cycle 0s -> lecture -> seek -> flush
// observé dans les logs 18.7.8.
await _player.open(
Media(
videoUrl,
start: switchPosition,
),
play: false,
);
await _player.setAudioTrack(
AudioTrack.uri(
audioUrl,
title: resolvedAudioTitle,
language: resolvedAudioLanguage,
),
);
await _player.setRate(_playbackRate).timeout(
Duration(seconds: 3),
onTimeout: () {},
);
// Un play bref force MediaCodec à publier la résolution réellement
// décodée. Le timeout protège l'UI : il ne peut plus rester bloqué en
// chargement infini à cause d'un Future natif qui tarde à se terminer.
await _player.play().timeout(
Duration(seconds: 3),
onTimeout: () {},
);
final actualHeight =
await _waitForAndroidSelectedResolution(
resolvedHeight,
).timeout(
Duration(seconds: 12),
);
final actualWidth = _player.state.width ?? resolvedWidth;
_hdActive = true;
_foregroundPlayableUrl = videoUrl;
_latestPosition = switchPosition;
_lastSavedPosition = switchPosition;
_restoringPosition = false;
// IMPORTANT : on libère l'overlay dès que le média HD est réellement
// prêt. Historique, compteur de vues et métadonnées ne participent pas
// à une simple bascule de qualité.
if (mounted) {
setState(() {
_loading = false;
final fpsSuffix = resolvedFps > 30 ? '$resolvedFps' : '';
_qualityLabel = '${actualHeight}p$fpsSuffix';
final languageText = resolvedAudioLanguage != 'und'
? ' • audio ${resolvedAudioLanguage.toUpperCase()}'
: ' • audio séparé';
final preferredText = preferredAudioMatched
? ' • langue préférée'
: '';
_qualityDetails =
'${actualWidth}x$actualHeight'
'$languageText'
'$preferredText';
});
}
if (!wasPlaying) {
await _player.pause().timeout(
Duration(seconds: 2),
onTimeout: () {},
);
}
debugPrint(
'[HD-ANDROID][18.7.10] bascule confirmée '
'${actualWidth}x$actualHeight '
'video=${videoCodec ?? '?'} '
'audio=${audioCodec ?? '?'} '
'position=${switchPosition.inMilliseconds}ms',
);
} catch (error) {
debugPrint(
'[HD-ANDROID][18.7.10] bascule ${height}p échouée: $error',
);
// Si l'ouverture HD a déjà remplacé le média courant, on restaure le
// 360p connu plutôt que de laisser un écran noir ou un spinner infini.
try {
final stableUrl = _playableUrl;
if (stableUrl != null && stableUrl.isNotEmpty) {
await _player.open(
Media(
stableUrl,
start: switchPosition,
),
play: wasPlaying,
).timeout(Duration(seconds: 8));
_foregroundPlayableUrl = stableUrl;
_hdActive = false;
}
} catch (restoreError) {
debugPrint(
'[HD-ANDROID][18.7.10] restauration 360p échouée: $restoreError',
);
}
_restoringPosition = false;
if (mounted) {
setState(() {
_selectedQualityHeight = previousHeight;
_loading = false;
_qualityLabel = previousLabel;
_qualityDetails = previousDetails;
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'La bascule HD a échoué. La lecture précédente a été conservée.',
),
duration: Duration(seconds: 2),
),
);
}
}
}
void _showUnavailableWindowsQuality(
int requestedHeight, {
int? actualHeight,
}) {
if (!mounted) {
return;
}
final requested =
_qualityOptions[requestedHeight] ??
'${requestedHeight}p';
final suffix = actualHeight != null &&
actualHeight > 0
? ' Cette vidéo est disponible ici jusqu’à '
'${actualHeight}p.'
: '';
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'$requested n’est pas disponible pour cette vidéo.$suffix',
),
duration: Duration(seconds: 3),
),
);
}
Future<void> _loadVideoInternal({
Duration? forcedResumePosition,
bool? forcePlay,
}) async {
if (Platform.isAndroid) {
await _configureAndroidMediaCache();
}
final shouldPlayAfterLoad =
forcePlay ?? true;
final windowsStartupWatch =
Platform.isWindows
? (Stopwatch()..start())
: null;
if (mounted) {
setState(() {
_loading = true;
_error = null;
_hdActive = false;
if (Platform.isWindows) {
_qualityLabel =
_selectedQualityHeight <= 0
? 'Auto'
: '${_selectedQualityHeight}p';
_qualityDetails =
'Démarrage rapide 360p • secours yt-dlp automatique';
} else {
_qualityLabel =
'360p stable';
_qualityDetails =
'Flux audio + vidéo';
}
});
}
try {
late final String stableUrl;
if (Platform.isWindows) {
// 18.7.5 : restauration du chemin 17.18/17.19 qui était le seul
// démarrage réellement immédiat et déjà validé par l'utilisateur.
//
// On tente d'abord le flux muxé 360p de youtube_explode. Cette voie
// donne directement image + son au Player et évite d'attendre le
// prébuffer yt-dlp web_embedded.
//
// IMPORTANT : 18.7.2 avait supprimé cette voie parce que /watch avait
// été temporairement rate-limité. On ne revient pas à cette régression :
// en cas d'erreur ou de timeout court, le pipeline yt-dlp-only robuste
// reste automatiquement disponible comme fallback.
try {
stableUrl =
await _youtubeService
.getPlayableMuxedUrl(
widget.video.id,
).timeout(
Duration(seconds: 3),
);
_playableUrl = stableUrl;
debugPrint(
'[HD-WINDOWS][18.7.5] '
'muxed 360p rapide résolu en '
'${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms',
);
} on Object catch (fastError) {
debugPrint(
'[HD-WINDOWS][18.7.5] '
'muxed 360p rapide indisponible -> '
'fallback yt-dlp-only: $fastError',
);
await _loadWindowsWithoutPublicWatchPage(
forcedResumePosition:
forcedResumePosition,
shouldPlayAfterLoad:
shouldPlayAfterLoad,
);
windowsStartupWatch?.stop();
debugPrint(
'[HD-WINDOWS][18.7.5] '
'fallback yt-dlp terminé en '
'${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms',
);
return;
}
} else {
stableUrl =
await _youtubeService
.getPlayableMuxedUrl(
widget.video.id,
);
_playableUrl = stableUrl;
}
var hdLoaded = false;
if (Platform.isWindows &&
_selectedQualityHeight != 360) {
final targetHeight =
_selectedQualityHeight <= 0
? 1080
: _selectedQualityHeight;
// Le 360p muxé est ouvert immédiatement par cette méthode, puis le
// pipeline web_embedded prépare la résolution demandée en arrière-plan.
// C'est le comportement validé des étapes 17.18/17.19.
hdLoaded =
await _tryOpenWindowsEmbeddedDirect(
targetHeight: targetHeight,
shouldPlayAfterLoad:
shouldPlayAfterLoad,
);
} else if (Platform.isAndroid &&
_selectedQualityHeight != 360) {
hdLoaded =
await _tryOpenDirectHd(
targetHeight:
_selectedQualityHeight,
shouldPlayAfterLoad:
shouldPlayAfterLoad,
);
if (!hdLoaded &&
(_selectedQualityHeight == 0 ||
_selectedQualityHeight >
1080)) {
debugPrint(
'[HD] essai de secours 1080p AVC',
);
hdLoaded =
await _tryOpenDirectHd(
targetHeight: 1080,
shouldPlayAfterLoad:
shouldPlayAfterLoad,
fallbackFromHeight:
_selectedQualityHeight,
);
}
}
if (!hdLoaded) {
_hdActive = false;
_foregroundPlayableUrl =
stableUrl;
if (_selectedQualityHeight ==
360) {
_qualityLabel = '360p';
_qualityDetails =
'Qualité choisie';
} else {
_qualityLabel =
'360p stable';
_qualityDetails =
'Fallback automatique';
}
await _player.open(
Media(stableUrl),
play:
shouldPlayAfterLoad,
);
}
if (!mounted) {
return;
}
final storedResumePosition =
LibraryService.instance
.getPlaybackPosition(
widget.video.id,
);
final resumePosition =
forcedResumePosition ??
storedResumePosition;
final shouldResume =
resumePosition != null &&
(forcedResumePosition != null
? resumePosition >
Duration.zero
: resumePosition >=
Duration(
seconds: 5,
));
_restoringPosition =
shouldResume;
if (shouldResume) {
await _waitForMediaReady(
resumePosition,
);
await _seekAndConfirm(
resumePosition,
);
_latestPosition =
resumePosition;
_lastSavedPosition =
resumePosition;
}
_restoringPosition = false;
await _player.setRate(
_playbackRate,
);
if (shouldPlayAfterLoad) {
await _player.play();
} else {
await _player.pause();
}
await LibraryService.instance
.addToHistory(
widget.video,
incrementWatchCount:
!_historyRecordedForThisOpen,
);
_historyRecordedForThisOpen =
true;
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
if (Platform.isWindows) {
windowsStartupWatch?.stop();
debugPrint(
'[HD-WINDOWS][18.7.5] '
'360p Player prêt en '
'${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms '
'cible=${_selectedQualityHeight <= 0 ? 1080 : _selectedQualityHeight}',
);
}
if (shouldResume &&
forcedResumePosition ==
null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Reprise confirmée à '
'${_formatDuration(resumePosition)}',
),
duration:
Duration(
seconds: 2,
),
),
);
}
} catch (error) {
_restoringPosition = false;
windowsStartupWatch?.stop();
debugPrint(
'[YT-PLAYER] $error',
);
if (!mounted) {
return;
}
setState(() {
_loading = false;
_error =
'Impossible de charger cette vidéo.\n'
'Essaie une autre vidéo ou réessaie dans quelques instants.';
});
}
}
Future<void> _loadWindowsWithoutPublicWatchPage({
required Duration? forcedResumePosition,
required bool shouldPlayAfterLoad,
}) async {
final storedResumePosition =
LibraryService.instance
.getPlaybackPosition(
widget.video.id,
);
final resumePosition =
forcedResumePosition ??
storedResumePosition;
final shouldResume =
resumePosition != null &&
(forcedResumePosition != null
? resumePosition >
Duration.zero
: resumePosition >=
Duration(
seconds: 5,
));
final startPosition =
shouldResume
? resumePosition
: Duration.zero;
final generation =
++_windowsHdUpgradeGeneration;
await _windowsRelay.stop();
await _windowsEmbeddedProgressive
.stop();
final requestedHeight =
_selectedQualityHeight <= 0
? 1080
: _selectedQualityHeight;
debugPrint(
'[HD-WINDOWS][18.7.2] '
'yt-dlp-only start '
'video=${widget.video.id} '
'cible=$requestedHeight '
'resume=${startPosition.inMilliseconds}ms',
);
Map<String, dynamic>? hd;
if (_windowsHdPreflightTarget ==
requestedHeight &&
_windowsHdPreflight != null) {
hd = _windowsHdPreflight;
_windowsHdPreflight = null;
_windowsHdPreflightTarget =
null;
debugPrint(
'[HD-WINDOWS][18.7.2] '
'préflight réutilisé',
);
} else {
hd = await WindowsHdService.instance
.getEmbeddedHdStream(
videoId: widget.video.id,
targetHeight: requestedHeight,
preferredLanguage: _effectiveAudioLanguagePreference,
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
}
if (!mounted ||
generation !=
_windowsHdUpgradeGeneration) {
return;
}
if (hd == null) {
throw StateError(
'web_embedded ne fournit aucun flux exploitable.',
);
}
final width = hd['width'];
final height = hd['height'];
final fps = hd['fps'];
final videoCodec =
hd['videoCodec'];
final audioCodec =
hd['audioCodec'];
final videoFormatId =
hd['videoFormatId'];
final audioFormatId =
hd['audioFormatId'];
final videoLength =
hd['videoLength'];
final audioLength =
hd['audioLength'];
final videoExt =
hd['videoExt'];
final audioExt =
hd['audioExt'];
final videoMimeType =
hd['videoMimeType'];
final ytDlpPath =
hd['ytDlpPath'];
final denoPath =
hd['denoPath'];
final watchUrl =
hd['watchUrl'];
if (height is! int ||
height < 240 ||
videoFormatId is! String ||
videoFormatId.isEmpty ||
audioFormatId is! String ||
audioFormatId.isEmpty ||
videoLength is! int ||
videoLength <= 0 ||
audioLength is! int ||
audioLength <= 0 ||
ytDlpPath is! String ||
ytDlpPath.isEmpty ||
denoPath is! String ||
denoPath.isEmpty ||
watchUrl is! String ||
watchUrl.isEmpty) {
throw StateError(
'Métadonnées web_embedded incomplètes.',
);
}
if (requestedHeight > 1080 &&
!_matchesQualityTier(
targetHeight: requestedHeight,
width: width is int ? width : null,
height: height,
)) {
_windowsUnavailableQualityHeights.add(requestedHeight);
throw StateError(
'Qualité ${requestedHeight}p demandée mais le flux '
'${width is int ? '${width}x' : ''}${height} '
'n’appartient pas à ce palier.',
);
}
final expectedWidth =
width is int ? width : 0;
final expectedFps =
fps is int ? fps : 0;
final session =
await _windowsEmbeddedProgressive
.start(
videoId: widget.video.id,
ytDlpPath: ytDlpPath,
denoPath: denoPath,
watchUrl: watchUrl,
videoFormatId:
videoFormatId,
audioFormatId:
audioFormatId,
videoExt:
videoExt is String
? videoExt
: 'mp4',
audioExt:
audioExt is String
? audioExt
: 'webm',
videoMimeType:
videoMimeType is String
? videoMimeType
: 'video/mp4',
videoLength: videoLength,
audioLength: audioLength,
);
if (!mounted ||
generation !=
_windowsHdUpgradeGeneration) {
return;
}
final mediaDuration =
widget.video.duration ??
Duration.zero;
final startedAt =
DateTime.now();
var lastPercent = -1;
while (mounted &&
generation ==
_windowsHdUpgradeGeneration) {
final ready =
await _windowsEmbeddedProgressive
.hasEnoughBuffer(
position: startPosition,
mediaDuration:
mediaDuration,
ahead:
Duration(
seconds: 12,
),
);
final status =
await _windowsEmbeddedProgressive
.status(
mediaDuration:
mediaDuration,
);
final percent =
((status.videoBytes /
videoLength) *
100)
.floor()
.clamp(0, 100);
if (percent != lastPercent &&
mounted) {
lastPercent = percent;
setState(() {
_qualityLabel =
'${height}p';
_qualityDetails =
'Prébuffer Windows '
'$percent% • audio '
'${status.audioComplete ? 'prêt' : '...'}';
});
}
if (ready) {
debugPrint(
'[HD-WINDOWS][18.7.2] '
'prébuffer prêt '
'video=${status.videoBytes}/${status.videoLength} '
'audio=${status.audioBytes}/${status.audioLength}',
);
break;
}
if (DateTime.now()
.difference(startedAt) >
Duration(
seconds: 45,
)) {
throw TimeoutException(
'Prébuffer Windows > 45 s',
);
}
await Future<void>.delayed(
Duration(
milliseconds: 120,
),
);
}
if (!mounted ||
generation !=
_windowsHdUpgradeGeneration) {
return;
}
_restoringPosition =
startPosition >
Duration.zero;
await _player.open(
Media(
session.videoUrl,
start: startPosition,
),
play: false,
);
await _player.setAudioTrack(
AudioTrack.uri(
session.audioUri,
title:
'YouTube audio $audioFormatId',
language: 'und',
),
);
await _player.setRate(
_playbackRate,
);
await _player.play();
final actualHeight =
await _waitForWindowsExpectedHeight(
height,
);
if (!mounted ||
generation !=
_windowsHdUpgradeGeneration) {
_restoringPosition = false;
return;
}
final actualPosition =
_player.state.position;
if (startPosition >
Duration.zero &&
(actualPosition -
startPosition)
.abs() >
Duration(
seconds: 2,
)) {
await _seekAndConfirm(
startPosition,
);
}
_latestPosition =
startPosition;
_lastSavedPosition =
startPosition;
_restoringPosition = false;
if (shouldPlayAfterLoad) {
await _player.play();
} else {
await _player.pause();
}
_playableUrl = null;
_foregroundPlayableUrl =
session.videoUrl;
_hdActive = true;
final actualWidth =
_player.state.width ??
expectedWidth;
final fpsSuffix =
expectedFps > 30
? '$expectedFps'
: '';
if (mounted) {
setState(() {
_qualityLabel =
'${actualHeight}p$fpsSuffix';
_qualityDetails =
'${actualWidth}x$actualHeight'
' • yt-dlp progressif'
' • ${audioCodec ?? 'audio'}';
});
}
debugPrint(
'[HD-WINDOWS][18.7.2] '
'lecture confirmée '
'${actualWidth}x$actualHeight '
'video=$videoFormatId/${videoCodec ?? '?'} '
'audio=$audioFormatId/${audioCodec ?? '?'}',
);
await LibraryService.instance
.addToHistory(
widget.video,
incrementWatchCount:
!_historyRecordedForThisOpen,
);
_historyRecordedForThisOpen =
true;
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
if (shouldResume &&
forcedResumePosition == null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Reprise confirmée à '
'${_formatDuration(startPosition)}',
),
duration:
Duration(
seconds: 2,
),
),
);
}
}
Future<bool> _tryOpenDirectHd({
required int targetHeight,
required bool shouldPlayAfterLoad,
int? fallbackFromHeight,
}) async {
if (Platform.isWindows) {
return _tryOpenWindowsMpvHd(
targetHeight: targetHeight,
shouldPlayAfterLoad: shouldPlayAfterLoad,
fallbackFromHeight: fallbackFromHeight,
);
}
try {
final hd =
await _requestPlatformHdStream(
targetHeight,
);
final videoUrl = hd?['videoUrl'];
final audioUrl = hd?['audioUrl'];
final width = hd?['width'];
final height = hd?['height'];
final fps = hd?['fps'];
final videoCodec = hd?['videoCodec'];
final audioCodec = hd?['audioCodec'];
final audioLanguage = hd?['audioLanguage'];
final audioTrackName = hd?['audioTrackName'];
final preferredAudioMatched =
hd?['preferredAudioMatched'] == true;
if (videoUrl is! String ||
videoUrl.isEmpty ||
audioUrl is! String ||
audioUrl.isEmpty ||
height is! int ||
height < 720) {
debugPrint(
'[HD] paire directe invalide pour cible=$targetHeight',
);
return false;
}
final expectedHeight = height;
final expectedWidth =
width is int ? width : 0;
final expectedFps =
fps is int ? fps : 0;
_hdActive = true;
_foregroundPlayableUrl = videoUrl;
final fpsSuffix =
expectedFps > 30 ? '${expectedFps}' : '';
_qualityLabel =
'${expectedHeight}p$fpsSuffix';
_qualityDetails =
'Vidéo ${videoCodec is String ? videoCodec : 'HD'}'
' + audio ${audioCodec is String ? audioCodec : 'séparé'}';
debugPrint(
'[HD] paire directe sélectionnée: '
'${_qualityLabel} '
'${expectedWidth}x$expectedHeight '
'video=${videoCodec ?? 'inconnu'} '
'audio=${audioCodec ?? 'inconnu'} '
'cible=$targetHeight',
);
await _player.open(
Media(videoUrl),
play: false,
);
final resolvedAudioLanguage =
audioLanguage is String && audioLanguage.trim().isNotEmpty
? audioLanguage.trim()
: 'und';
final resolvedAudioTitle =
audioTrackName is String && audioTrackName.trim().isNotEmpty
? audioTrackName.trim()
: preferredAudioMatched
? 'YouTube audio • langue préférée'
: 'YouTube audio';
await _player.setAudioTrack(
AudioTrack.uri(
audioUrl,
title: resolvedAudioTitle,
language: resolvedAudioLanguage,
),
);
// On démarre brièvement pour forcer MediaCodec à annoncer
// la vraie résolution décodée.
await _player.play();
// Laisse au hook un court instant pour publier le chemin yt-dlp,
// puis journalise-le. Cela nous permet de distinguer immédiatement
// "hook non exécuté" de "yt-dlp exécuté mais flux refusé".
await Future<void>.delayed(
Duration(milliseconds: 1200),
);
try {
final platform = _player.platform;
if (platform is NativePlayer) {
final ytdlPath = await platform.getProperty(
'user-data/mpv/ytdl/path',
);
debugPrint(
'[HD-WINDOWS] ytdl_hook path=$ytdlPath',
);
}
} catch (error) {
debugPrint(
'[HD-WINDOWS] lecture propriété ytdl/path impossible: $error',
);
}
final actualHeight =
await _waitForDirectHdReady(
expectedHeight,
);
final actualWidth =
_player.state.width ?? 0;
debugPrint(
'[HD] lecture confirmée: '
'${actualWidth}x$actualHeight '
'(sélection=${_qualityLabel}, cible=$targetHeight)',
);
if (!shouldPlayAfterLoad) {
await _player.pause();
}
if (mounted) {
setState(() {
final actualFpsSuffix =
expectedFps > 30
? '${expectedFps}'
: '';
_qualityLabel =
'${actualHeight}p$actualFpsSuffix';
final fallbackText =
fallbackFromHeight != null
? ' • secours 1080p'
: '';
final languageText =
resolvedAudioLanguage != 'und'
? ' • audio ${resolvedAudioLanguage.toUpperCase()}'
: ' • audio séparé';
final preferredText =
preferredAudioMatched
? ' • langue préférée'
: '';
_qualityDetails =
'${actualWidth}x$actualHeight'
'$languageText'
'$preferredText'
'$fallbackText';
});
}
return true;
} catch (error) {
debugPrint(
'[HD] cible=$targetHeight non lisible: $error',
);
return false;
}
}
Future<bool> _tryOpenWindowsEmbeddedDirect({
required int targetHeight,
required bool shouldPlayAfterLoad,
int? fallbackFromHeight,
}) async {
final stableUrl = _playableUrl;
if (stableUrl == null || stableUrl.isEmpty) {
return false;
}
final generation =
++_windowsHdUpgradeGeneration;
try {
await _windowsRelay.stop();
await _windowsEmbeddedProgressive.stop();
// IMPORTANT 17.18 :
// on ne bloque PLUS l'écran pendant le téléchargement HD.
//
// Le flux 360p muxé est déjà disponible à ce stade. On le lance
// immédiatement avec image + son, puis la HD se prépare en arrière-plan.
_hdActive = false;
_foregroundPlayableUrl = stableUrl;
if (mounted) {
setState(() {
_qualityLabel = '360p';
_qualityDetails =
'Lecture immédiate • HD en préparation 0%';
});
}
debugPrint(
'[HD-WINDOWS][17.19] lecture immédiate 360p '
'pendant préparation cible=$targetHeight',
);
await _player.open(
Media(stableUrl),
play: shouldPlayAfterLoad,
);
unawaited(
_prepareAndSwitchWindowsHd(
generation: generation,
targetHeight: targetHeight,
fallbackFromHeight: fallbackFromHeight,
),
);
// On retourne true car le Player est déjà ouvert sur le fallback
// stable. _loadVideoInternal peut donc restaurer immédiatement la
// position, la vitesse et l'état play/pause sans attendre la HD.
return true;
} on Object catch (error) {
debugPrint(
'[HD-WINDOWS][17.19] démarrage immédiat impossible: $error',
);
return false;
}
}
Future<void> _prepareAndSwitchWindowsHd({
required int generation,
required int targetHeight,
int? fallbackFromHeight,
}) async {
try {
debugPrint(
'[HD-WINDOWS][17.19] préparation progressive '
'cible=$targetHeight génération=$generation',
);
Map<String, dynamic>? hd;
if (_windowsHdPreflightTarget == targetHeight &&
_windowsHdPreflight != null) {
hd = _windowsHdPreflight;
_windowsHdPreflight = null;
_windowsHdPreflightTarget = null;
debugPrint(
'[HD-WINDOWS][17.21] résultat préflight réutilisé '
'pour cible=$targetHeight',
);
} else {
hd =
await WindowsHdService.instance.getEmbeddedHdStream(
videoId: widget.video.id,
targetHeight: targetHeight,
preferredLanguage: _effectiveAudioLanguagePreference,
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
}
if (!_isWindowsUpgradeCurrent(generation) ||
hd == null) {
return;
}
final width = hd['width'];
final height = hd['height'];
final fps = hd['fps'];
final videoCodec = hd['videoCodec'];
final audioCodec = hd['audioCodec'];
final videoFormatId = hd['videoFormatId'];
final audioFormatId = hd['audioFormatId'];
final videoLength = hd['videoLength'];
final audioLength = hd['audioLength'];
final videoExt = hd['videoExt'];
final audioExt = hd['audioExt'];
final videoMimeType = hd['videoMimeType'];
final ytDlpPath = hd['ytDlpPath'];
final denoPath = hd['denoPath'];
final watchUrl = hd['watchUrl'];
if (height is! int ||
height < 720 ||
videoFormatId is! String ||
videoFormatId.isEmpty ||
audioFormatId is! String ||
audioFormatId.isEmpty ||
videoLength is! int ||
videoLength <= 0 ||
audioLength is! int ||
audioLength <= 0 ||
ytDlpPath is! String ||
ytDlpPath.isEmpty ||
denoPath is! String ||
denoPath.isEmpty ||
watchUrl is! String ||
watchUrl.isEmpty) {
throw StateError(
'Métadonnées web_embedded incomplètes.',
);
}
// Deuxième garde-fou : une demande 1440p/2160p ne doit jamais
// être validée silencieusement par un flux 1080p.
if (targetHeight > 1080 &&
!_matchesQualityTier(
targetHeight: targetHeight,
width: width is int ? width : null,
height: height,
)) {
if (mounted) {
setState(() {
_windowsUnavailableQualityHeights.add(targetHeight);
});
}
throw StateError(
'Qualité ${targetHeight}p demandée mais le flux '
'${width is int ? '${width}x' : ''}${height} '
'n’appartient pas à ce palier.',
);
}
final expectedWidth =
width is int ? width : 0;
final expectedFps =
fps is int ? fps : 0;
debugPrint(
'[HD-WINDOWS][17.19] flux progressif: '
'${expectedWidth}x$height '
'${expectedFps > 0 ? '${expectedFps}fps ' : ''}'
'video=$videoFormatId/${videoCodec ?? '?'} '
'audio=$audioFormatId/${audioCodec ?? '?'}',
);
final session =
await _windowsEmbeddedProgressive.start(
videoId: widget.video.id,
ytDlpPath: ytDlpPath,
denoPath: denoPath,
watchUrl: watchUrl,
videoFormatId: videoFormatId,
audioFormatId: audioFormatId,
videoExt:
videoExt is String ? videoExt : 'mp4',
audioExt:
audioExt is String ? audioExt : 'webm',
videoMimeType:
videoMimeType is String
? videoMimeType
: 'video/mp4',
videoLength: videoLength,
audioLength: audioLength,
);
if (!_isWindowsUpgradeCurrent(generation)) {
return;
}
var lastUiAhead = -1;
// 360p continue à jouer. On n'attend qu'un tampon HD autour du
// timestamp courant, pas les 378 Mo de la vidéo complète.
while (_isWindowsUpgradeCurrent(generation)) {
final duration =
_player.state.duration;
final currentPosition =
_player.state.position > _latestPosition
? _player.state.position
: _latestPosition;
final ready =
await _windowsEmbeddedProgressive.hasEnoughBuffer(
position: currentPosition,
mediaDuration: duration,
ahead: Duration(seconds: 20),
);
final status =
await _windowsEmbeddedProgressive.status(
mediaDuration: duration,
);
final aheadSeconds = math.max(
0,
status.estimatedBufferedDuration.inSeconds -
currentPosition.inSeconds,
);
if (aheadSeconds != lastUiAhead &&
mounted) {
lastUiAhead = aheadSeconds;
setState(() {
_qualityDetails =
'Lecture 360p • buffer HD '
'${aheadSeconds}s '
'• audio '
'${status.audioComplete ? 'prêt' : '...'}';
});
}
if (ready) {
debugPrint(
'[HD-WINDOWS][17.19] buffer suffisant: '
'video=${status.videoBytes}/${status.videoLength} '
'≈${status.estimatedBufferedDuration.inSeconds}s '
'audio=${status.audioComplete}',
);
break;
}
final videoFailed =
!status.videoComplete &&
status.videoBytes == 0 &&
status.audioComplete &&
false;
if (videoFailed) {
throw StateError(
'Téléchargement vidéo progressif interrompu.',
);
}
await Future<void>.delayed(
Duration(milliseconds: 120),
);
}
if (!_isWindowsUpgradeCurrent(generation)) {
return;
}
// _loadVideoInternal doit avoir fini de restaurer le resume 360p.
for (var i = 0;
i < 100 &&
_loading &&
_isWindowsUpgradeCurrent(generation);
i++) {
await Future<void>.delayed(
Duration(milliseconds: 50),
);
}
if (!_isWindowsUpgradeCurrent(generation)) {
return;
}
final switchPosition =
_player.state.position > _latestPosition
? _player.state.position
: _latestPosition;
final wasPlaying =
_player.state.playing;
debugPrint(
'[HD-WINDOWS][17.19] bascule vers HD bufferisée '
'à ${switchPosition.inMilliseconds} ms',
);
_restoringPosition = true;
// media_kit 1.2.6 accepte un start directement dans Media.
// Le nouveau média se prépare donc AU timestamp courant au lieu
// d'afficher la première image de la vidéo avant un seek.
await _player.open(
Media(
session.videoUrl,
start: switchPosition,
),
play: false,
);
await _player.setAudioTrack(
AudioTrack.uri(
session.audioUri,
title:
'YouTube audio $audioFormatId',
language: 'und',
),
);
await _player.setRate(
_playbackRate,
);
// Lancer permet à mpv de décoder la première frame HD demandée.
await _player.play();
final actualHeight =
await _waitForWindowsExpectedHeight(
height,
);
final actualWidth =
_player.state.width ?? 0;
if (!_isWindowsUpgradeCurrent(generation)) {
_restoringPosition = false;
return;
}
// Vérification légère seulement. Normalement Media(start:) a déjà
// positionné mpv avant la première frame rendue.
final actualPosition =
_player.state.position;
if ((actualPosition - switchPosition).abs() >
Duration(seconds: 2)) {
await _seekAndConfirm(
switchPosition,
);
}
_latestPosition =
switchPosition;
_lastSavedPosition =
switchPosition;
_restoringPosition = false;
if (wasPlaying) {
await _player.play();
} else {
await _player.pause();
}
_hdActive = true;
_foregroundPlayableUrl =
session.videoUrl;
final fpsSuffix =
expectedFps > 30
? '$expectedFps'
: '';
if (mounted &&
_isWindowsUpgradeCurrent(generation)) {
setState(() {
_qualityLabel =
'${actualHeight}p$fpsSuffix';
final fallbackText =
fallbackFromHeight != null
? ' • secours ${actualHeight}p'
: '';
_qualityDetails =
'${actualWidth}x$actualHeight'
' • streaming progressif'
' • ${audioCodec ?? 'audio'}'
'$fallbackText';
});
}
debugPrint(
'[HD-WINDOWS][17.19] HD progressive confirmée: '
'${actualWidth}x$actualHeight '
'audio=$audioFormatId/${audioCodec ?? '?'}',
);
} on Object catch (error) {
_restoringPosition = false;
if (!_isWindowsUpgradeCurrent(generation)) {
return;
}
debugPrint(
'[HD-WINDOWS][17.19] HD progressive échouée '
'-> maintien 360p: $error',
);
if (mounted) {
setState(() {
_hdActive = false;
_qualityLabel = '360p stable';
_qualityDetails =
'Lecture continue • HD progressive indisponible';
});
}
}
}
bool _isWindowsUpgradeCurrent(
int generation,
) {
return mounted &&
Platform.isWindows &&
generation == _windowsHdUpgradeGeneration &&
_selectedQualityHeight != 360;
}
Future<int> _waitForWindowsExpectedHeight(
int expectedHeight,
) async {
var actualHeight = _player.state.height ?? 0;
if (actualHeight >= expectedHeight) {
return actualHeight;
}
final streamedHeight =
await _player.stream.height
.firstWhere(
(height) =>
(height ?? 0) >= expectedHeight,
)
.timeout(
Duration(seconds: 30),
);
actualHeight = streamedHeight ?? 0;
if (_player.state.duration <= Duration.zero) {
await _player.stream.duration
.firstWhere(
(duration) => duration > Duration.zero,
)
.timeout(
Duration(seconds: 30),
);
}
return actualHeight;
}
Future<bool> _tryOpenWindowsMuxedHls({
required int targetHeight,
required bool shouldPlayAfterLoad,
int? fallbackFromHeight,
}) async {
try {
final hls =
await WindowsHdService.instance.getMuxedHlsStream(
videoId: widget.video.id,
targetHeight: targetHeight,
preferredLanguage: _effectiveAudioLanguagePreference,
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
if (hls == null) {
return false;
}
final url = hls['url'];
final expectedHeight = hls['height'];
final expectedWidth = hls['width'];
final fps = hls['fps'];
final videoCodec = hls['videoCodec'];
final audioCodec = hls['audioCodec'];
if (url is! String ||
url.isEmpty ||
expectedHeight is! int ||
expectedHeight < 720) {
return false;
}
final headers = <String, String>{};
final rawHeaders = hls['httpHeaders'];
if (rawHeaders is Map) {
for (final entry in rawHeaders.entries) {
final key = entry.key.toString().trim();
final value =
entry.value?.toString().trim() ?? '';
if (key.isNotEmpty && value.isNotEmpty) {
headers[key] = value;
}
}
}
await _windowsRelay.stop();
final platform = _player.platform;
if (platform is NativePlayer) {
await WindowsHdService.instance.applyHttpHeaders(
platform,
headers,
);
}
debugPrint(
'[HD-WINDOWS][17.13] ouverture HLS muxé: '
'${expectedWidth ?? 0}x$expectedHeight '
'${fps is int && fps > 0 ? '${fps}fps ' : ''}'
'video=${videoCodec ?? 'inconnu'} '
'audio=${audioCodec ?? 'inconnu'}',
);
_hdActive = true;
_foregroundPlayableUrl = url;
await _player.open(
Media(
url,
httpHeaders: headers,
),
play: false,
);
await _player.play();
final actualHeight =
await _waitForDirectHdReady(
expectedHeight,
);
final actualWidth =
_player.state.width ?? 0;
if (actualHeight < 720) {
throw StateError(
'HLS muxé décodé en '
'${actualWidth}x$actualHeight.',
);
}
final fpsSuffix =
fps is int && fps > 30 ? '$fps' : '';
debugPrint(
'[HD-WINDOWS][17.13] HLS AV confirmé: '
'${actualWidth}x$actualHeight '
'(vidéo + audio muxés)',
);
if (!shouldPlayAfterLoad) {
await _player.pause();
} else {
await _player.play();
}
if (mounted) {
setState(() {
_qualityLabel =
'${actualHeight}p$fpsSuffix';
final fallbackText =
fallbackFromHeight != null
? ' • secours HLS 1080p'
: '';
_qualityDetails =
'${actualWidth}x$actualHeight'
' • HLS vidéo+audio'
'$fallbackText';
});
}
return true;
} on Object catch (error) {
debugPrint(
'[HD-WINDOWS][17.13] HLS muxé non lisible: $error',
);
return false;
}
}
Future<bool> _tryOpenWindowsMpvHd({
required int targetHeight,
required bool shouldPlayAfterLoad,
int? fallbackFromHeight,
}) async {
StreamSubscription<String>? errorSubscription;
StreamSubscription<PlayerLog>? logSubscription;
try {
await _windowsEmbeddedProgressive.stop();
final embeddedOk =
await _tryOpenWindowsEmbeddedDirect(
targetHeight: targetHeight,
shouldPlayAfterLoad: shouldPlayAfterLoad,
fallbackFromHeight: fallbackFromHeight,
);
if (embeddedOk) {
return true;
}
debugPrint(
'[HD-WINDOWS][17.14] web_embedded indisponible/illisible '
'-> anciens secours Windows',
);
// Priorité Windows <=1080p : HLS pré-muxé web_safari.
// Une seule URL contient vidéo + audio, donc aucun AudioTrack.uri()
// et aucun relais DASH/PO Token n'est nécessaire.
if (targetHeight <= 1080) {
final hlsOk = await _tryOpenWindowsMuxedHls(
targetHeight: targetHeight,
shouldPlayAfterLoad: shouldPlayAfterLoad,
fallbackFromHeight: fallbackFromHeight,
);
if (hlsOk) {
return true;
}
debugPrint(
'[HD-WINDOWS][17.13] HLS muxé indisponible/illisible '
'-> secours mweb adaptatif',
);
}
await _windowsRelay.stop();
debugPrint(
'[HD-WINDOWS][17.12] yt-dlp -> relais yt-dlp natif -> libmpv '
'pour ${widget.video.id} cible=$targetHeight',
);
final hd =
await WindowsHdService.instance.getHdStream(
videoId: widget.video.id,
targetHeight: targetHeight,
preferredLanguage: _effectiveAudioLanguagePreference,
originalLanguageHint: _inferOriginalAudioLanguageHint(),
);
final videoUrl = hd?['videoUrl'];
final audioUrl = hd?['audioUrl'];
final width = hd?['width'];
final height = hd?['height'];
final fps = hd?['fps'];
final videoCodec = hd?['videoCodec'];
final audioCodec = hd?['audioCodec'];
final videoLength = hd?['videoLength'];
final audioLength = hd?['audioLength'];
final videoChunkSize = hd?['videoChunkSize'];
final audioChunkSize = hd?['audioChunkSize'];
final videoMimeType = hd?['videoMimeType'];
final audioMimeType = hd?['audioMimeType'];
final ytDlpPath = hd?['ytDlpPath'];
Map<String, String> stringMap(dynamic raw) {
final output = <String, String>{};
if (raw is Map) {
for (final entry in raw.entries) {
final key = entry.key.toString().trim();
final value =
entry.value?.toString().trim() ?? '';
if (key.isNotEmpty && value.isNotEmpty) {
output[key] = value;
}
}
}
return output;
}
final videoHeaders =
stringMap(hd?['videoHeaders']);
final audioHeaders =
stringMap(hd?['audioHeaders']);
if (videoUrl is! String ||
videoUrl.isEmpty ||
audioUrl is! String ||
audioUrl.isEmpty ||
height is! int ||
height < 720 ||
videoLength is! int ||
videoLength <= 0 ||
audioLength is! int ||
audioLength <= 0 ||
ytDlpPath is! String ||
ytDlpPath.isEmpty) {
throw StateError(
'Paire vidéo/audio Windows invalide '
'ou taille de flux inconnue.',
);
}
final expectedHeight = height;
final expectedWidth =
width is int ? width : 0;
final expectedFps =
fps is int ? fps : 0;
final relay = await _windowsRelay.start(
ytDlpPath: ytDlpPath,
videoUrl: videoUrl,
audioUrl: audioUrl,
videoHeaders: videoHeaders,
audioHeaders: audioHeaders,
videoLength: videoLength,
audioLength: audioLength,
videoChunkSize:
videoChunkSize is int
? videoChunkSize
: 10 * 1024 * 1024,
audioChunkSize:
audioChunkSize is int
? audioChunkSize
: 10 * 1024 * 1024,
videoMimeType:
videoMimeType is String
? videoMimeType
: 'video/mp4',
audioMimeType:
audioMimeType is String
? audioMimeType
: 'audio/mp4',
);
debugPrint(
'[HD-WINDOWS] paire relayée: '
'${expectedWidth}x$expectedHeight '
'${expectedFps > 0 ? '${expectedFps}fps ' : ''}'
'video=${videoCodec ?? 'inconnu'} '
'audio=${audioCodec ?? 'inconnu'}',
);
errorSubscription = _player.stream.error.listen(
(error) {
debugPrint(
'[HD-WINDOWS][MPV-ERROR] $error',
);
},
);
logSubscription = _player.stream.log.listen(
(entry) {
final prefix = entry.prefix.toLowerCase();
final text = entry.text.toLowerCase();
if (prefix.contains('ffmpeg') ||
prefix.contains('lavf') ||
prefix.contains('file') ||
text.contains('http error') ||
text.contains('403') ||
text.contains('forbidden') ||
text.contains('failed to open')) {
debugPrint(
'[HD-WINDOWS][MPV] '
'${entry.prefix}: ${entry.text.trim()}',
);
}
},
);
_hdActive = true;
_foregroundPlayableUrl = relay.videoUrl;
final fpsSuffix =
expectedFps > 30 ? '$expectedFps' : '';
_qualityLabel =
'${expectedHeight}p$fpsSuffix';
_qualityDetails =
'Relais yt-dlp • '
'${videoCodec is String ? videoCodec : 'HD'}'
' + ${audioCodec is String ? audioCodec : 'audio'}';
// libmpv n'accède plus directement à googlevideo.com.
// Il lit une URL 127.0.0.1, tandis que notre relais reproduit le
// transport yt-dlp avec des requêtes Range <= 10 MiB.
await _player.open(
Media(relay.videoUrl),
play: false,
);
await _player.play();
final videoOnlyHeight =
await _waitForDirectHdReady(
expectedHeight,
);
final videoOnlyWidth =
_player.state.width ?? 0;
debugPrint(
'[HD-WINDOWS] vidéo relayée confirmée: '
'${videoOnlyWidth}x$videoOnlyHeight',
);
await _player.setAudioTrack(
AudioTrack.uri(
relay.audioUrl,
title: 'YouTube audio',
language: 'und',
),
);
await Future<void>.delayed(
Duration(milliseconds: 1200),
);
final actualHeight =
_player.state.height ?? videoOnlyHeight;
final actualWidth =
_player.state.width ?? videoOnlyWidth;
if (actualHeight < 720) {
throw StateError(
'La vidéo relayée est retombée à '
'${actualWidth}x$actualHeight après ajout audio.',
);
}
debugPrint(
'[HD-WINDOWS] lecture confirmée: '
'${actualWidth}x$actualHeight '
'(relais local)',
);
if (!shouldPlayAfterLoad) {
await _player.pause();
} else {
await _player.play();
}
if (mounted) {
setState(() {
_qualityLabel =
'${actualHeight}p$fpsSuffix';
final fallbackText =
fallbackFromHeight != null
? ' • secours 1080p'
: '';
_qualityDetails =
'${actualWidth}x$actualHeight'
' • relais yt-dlp'
'$fallbackText';
});
}
return true;
} catch (error) {
debugPrint(
'[HD-WINDOWS][17.12] cible=$targetHeight '
'non lisible: $error',
);
await _windowsRelay.stop();
return false;
} finally {
await errorSubscription?.cancel();
await logSubscription?.cancel();
}
}
Future<Map<dynamic, dynamic>?> _requestPlatformHdStream(
int targetHeight,
) async {
if (Platform.isAndroid) {
debugPrint(
'[HD-ANDROID][18.7.8] demande native pour ${widget.video.id} '
'cible=$targetHeight',
);
return _hdChannel.invokeMapMethod<dynamic, dynamic>(
'getHdStream',
<String, dynamic>{
'videoId': widget.video.id,
'targetHeight': targetHeight,
'preferredLanguage': _effectiveAudioLanguagePreference,
'originalLanguageHint': _inferOriginalAudioLanguageHint(),
},
);
}
return null;
}
Future<int> _waitForAndroidSelectedResolution(
int expectedHeight,
) async {
if (!Platform.isAndroid) {
return _player.state.height ?? 0;
}
final current = _player.state.height ?? 0;
if (current == expectedHeight) {
return current;
}
final streamedHeight = await _player.stream.height
.firstWhere(
(height) => (height ?? 0) == expectedHeight,
)
.timeout(Duration(seconds: 12));
return streamedHeight ?? 0;
}
Future<int> _waitForDirectHdReady(
int expectedHeight,
) async {
if (Platform.isAndroid && expectedHeight >= 720) {
return _waitForAndroidSelectedResolution(expectedHeight);
}
final minimumHeight =
expectedHeight >= 720 ? 720 : expectedHeight;
var actualHeight = _player.state.height ?? 0;
final readyTimeout = Platform.isWindows
? Duration(seconds: 30)
: Duration(seconds: 12);
if (actualHeight < minimumHeight) {
final streamedHeight = await _player.stream.height
.firstWhere(
(height) => (height ?? 0) >= minimumHeight,
)
.timeout(
readyTimeout,
);
actualHeight = streamedHeight ?? 0;
}
if (_player.state.duration <= Duration.zero) {
await _player.stream.duration
.firstWhere(
(duration) => duration > Duration.zero,
)
.timeout(
readyTimeout,
);
}
return actualHeight;
}
Future<void> _waitForMediaReady(Duration resumePosition) async {
final currentDuration = _player.state.duration;
if (currentDuration > resumePosition) {
return;
}
await _player.stream.duration
.firstWhere(
(duration) =>
duration > Duration.zero && duration > resumePosition,
)
.timeout(Duration(seconds: 10));
}
Future<void> _seekAndConfirm(Duration target) async {
const tolerance = Duration(seconds: 2);
for (var attempt = 1; attempt <= 3; attempt++) {
debugPrint(
'[YT-PLAYER] Resume seek attempt $attempt -> '
'${target.inMilliseconds} ms',
);
await _player.seek(target);
final current = _player.state.position;
if (_isCloseToTarget(current, target, tolerance)) {
debugPrint(
'[YT-PLAYER] Resume seek confirmed immediately at '
'${current.inMilliseconds} ms',
);
return;
}
try {
final confirmed = await _player.stream.position
.firstWhere(
(position) =>
_isCloseToTarget(position, target, tolerance),
)
.timeout(Duration(seconds: 3));
debugPrint(
'[YT-PLAYER] Resume seek confirmed at '
'${confirmed.inMilliseconds} ms',
);
return;
} on TimeoutException {
debugPrint(
'[YT-PLAYER] Resume seek attempt $attempt not confirmed',
);
await Future<void>.delayed(
Duration(milliseconds: 350),
);
}
}
throw TimeoutException(
'La position de reprise n’a pas pu être appliquée.',
);
}
bool _isCloseToTarget(
Duration position,
Duration target,
Duration tolerance,
) {
final difference =
(position.inMilliseconds - target.inMilliseconds).abs();
return difference <= tolerance.inMilliseconds;
}
Future<void> _seekRelative(int seconds) async {
final current = _player.state.position;
final duration = _player.state.duration;
var target = current + Duration(seconds: seconds);
if (target < Duration.zero) {
target = Duration.zero;
}
if (duration > Duration.zero && target > duration) {
target = duration;
}
await _player.seek(target);
_latestPosition = target;
_lastSavedPosition = target;
}
Future<void> _adjustVolume(
double delta,
) async {
final current =
_player.state.volume;
final next =
(current + delta)
.clamp(0.0, 100.0)
.toDouble();
await _player.setVolume(next);
if (next <= 0) {
_showTapFeedback(
Icons.volume_off_rounded,
);
} else if (delta > 0) {
_showTapFeedback(
Icons.volume_up_rounded,
);
} else {
_showTapFeedback(
Icons.volume_down_rounded,
);
}
debugPrint(
'[FULLSCREEN-KEYS] volume='
'${next.toStringAsFixed(0)}%',
);
}
Future<void> _fullscreenSeek(
int seconds,
) async {
await _seekRelative(seconds);
_showTapFeedback(
seconds < 0
? Icons.replay_10_rounded
: Icons.forward_10_rounded,
);
debugPrint(
'[FULLSCREEN-KEYS] seek '
'${seconds > 0 ? '+' : ''}${seconds}s',
);
}
Future<void> _toggleFullscreenFromKeyboard() async {
final state =
_videoKey.currentState;
if (state == null) {
return;
}
await state.toggleFullscreen();
}
Future<void> _exitFullscreenFromKeyboard() async {
final state =
_videoKey.currentState;
if (state == null ||
!state.isFullscreen()) {
return;
}
await state.exitFullscreen();
}
Map<ShortcutActivator, VoidCallback>
_fullscreenKeyboardShortcuts() {
return <ShortcutActivator, VoidCallback>{
SingleActivator(
LogicalKeyboardKey.space,
): () {
unawaited(
_togglePlayPause(),
);
},
SingleActivator(
LogicalKeyboardKey.arrowLeft,
): () {
unawaited(
_fullscreenSeek(-10),
);
},
SingleActivator(
LogicalKeyboardKey.arrowRight,
): () {
unawaited(
_fullscreenSeek(10),
);
},
SingleActivator(
LogicalKeyboardKey.arrowUp,
): () {
unawaited(
_adjustVolume(5),
);
},
SingleActivator(
LogicalKeyboardKey.arrowDown,
): () {
unawaited(
_adjustVolume(-5),
);
},
// On conserve aussi les raccourcis essentiels du fullscreen
// natif de media_kit.
SingleActivator(
LogicalKeyboardKey.keyF,
): () {
unawaited(
_toggleFullscreenFromKeyboard(),
);
},
SingleActivator(
LogicalKeyboardKey.escape,
): () {
unawaited(
_exitFullscreenFromKeyboard(),
);
},
};
}
bool _isAndroidVideoFullscreen() {
if (!Platform.isAndroid) {
return false;
}
final state = _videoKey.currentState;
return state != null &&
state.isFullscreen();
}
void _handleAndroidDoubleTapDown(
TapDownDetails details,
double width,
) {
if (!_isAndroidVideoFullscreen()) {
_androidDoubleTapSeekLeft = null;
return;
}
_androidDoubleTapSeekLeft =
details.localPosition.dx <
width / 2;
}
void _handleAndroidDoubleTap() {
if (!_isAndroidVideoFullscreen()) {
return;
}
final seekLeft =
_androidDoubleTapSeekLeft;
if (seekLeft == null) {
return;
}
unawaited(
_seekRelative(
seekLeft ? -10 : 10,
),
);
_showTapFeedback(
seekLeft
? Icons.replay_10_rounded
: Icons.forward_10_rounded,
);
debugPrint(
'[ANDROID-FULLSCREEN] '
'double tap ${seekLeft ? '-10' : '+10'}s',
);
}
Future<void> _restartVideo() async {
await _player.seek(Duration.zero);
_latestPosition = Duration.zero;
_lastSavedPosition = Duration.zero;
await LibraryService.instance.clearPlaybackPosition(
widget.video.id,
);
}
Future<void> _setPlaybackRate(double rate) async {
await _player.setRate(rate);
if (!mounted) {
return;
}
setState(() {
_playbackRate = rate;
});
}
Future<void> _toggleFavorite() async {
final isFavorite = await LibraryService.instance.toggleFavorite(
widget.video,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
isFavorite
? 'Ajouté aux favoris'
: 'Retiré des favoris',
),
duration: Duration(seconds: 1),
),
);
}
String _formatDuration(Duration value) {
final hours = value.inHours;
final minutes = value.inMinutes.remainder(60);
final seconds = value.inSeconds.remainder(60);
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
@override
void dispose() {
_positionSubscription?.cancel();
_playingSubscription?.cancel();
_completedSubscription?.cancel();
_tapFeedbackTimer?.cancel();
_androidGestureFeedbackTimer?.cancel();
_androidGestureFeedbackNotifier.dispose();
_androidPauseSleepTimer?.cancel();
_androidScreenPolicyWatchdog?.cancel();
_androidOrientationDebounce?.cancel();
_androidPointerPositions.clear();
WidgetsBinding.instance.removeObserver(this);
if (Platform.isAndroid) {
_pipChannel.setMethodCallHandler(null);
unawaited(_setAndroidKeepScreenOn(false));
unawaited(
_deviceChannel.invokeMethod<bool>(
'resetWindowBrightness',
),
);
}
if (Platform.isWindows && _desktopPip) {
unawaited(windowManager.setAlwaysOnTop(false));
}
if (!_restoringPosition &&
_latestPosition >= Duration(seconds: 5)) {
unawaited(
LibraryService.instance.savePlaybackPosition(
widget.video.id,
_latestPosition,
duration: widget.video.duration,
),
);
}
unawaited(
BackgroundPlaybackService.instance.detachPlayer(),
);
if (Platform.isWindows) {
_windowsHdUpgradeGeneration++;
unawaited(_windowsRelay.stop());
unawaited(_windowsEmbeddedProgressive.stop());
}
_videoFocusNode.dispose();
_player.dispose();
_youtubeService.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final compactPip = _androidPip || _desktopPip;
if (compactPip) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
_buildPlayerSurface(
compact: true,
),
if (_desktopPip)
Positioned(
top: 8,
right: 8,
child: Material(
color: Colors.black54,
shape: CircleBorder(),
child: IconButton(
tooltip: 'Quitter le mode PiP',
onPressed: _exitDesktopPip,
icon: Icon(
Icons.close_fullscreen,
color: Colors.white,
),
),
),
),
],
),
),
);
}
final width = MediaQuery.sizeOf(context).width;
final isDesktop = width >= 800;
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
titleSpacing: 0,
title: Text(
widget.video.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 16),
),
),
body: SafeArea(
child: SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 1200),
child: Padding(
padding: EdgeInsets.fromLTRB(
isDesktop ? 28 : 0,
isDesktop ? 18 : 0,
isDesktop ? 28 : 0,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPlayer(),
Padding(
padding: EdgeInsets.fromLTRB(
isDesktop ? 0 : 16,
18,
isDesktop ? 0 : 16,
0,
),
child: _buildMetadata(),
),
],
),
),
),
),
),
),
);
}
Widget _buildPlayer() {
return AspectRatio(
aspectRatio: 16 / 9,
child: _buildPlayerSurface(),
);
}
Widget _buildPlayerSurface({
bool compact = false,
}) {
return ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
if (_error == null)
Platform.isWindows
? MaterialDesktopVideoControlsTheme(
normal:
MaterialDesktopVideoControlsThemeData(),
fullscreen:
MaterialDesktopVideoControlsThemeData(
keyboardShortcuts:
_fullscreenKeyboardShortcuts(),
),
child: Video(
key: _videoKey,
controller: _videoController,
fit: BoxFit.contain,
focusNode: _videoFocusNode,
),
)
: ClipRect(
child: Video(
key: _videoKey,
controller: _videoController,
fit: BoxFit.contain,
focusNode: _videoFocusNode,
// 18.7.18: le builder de contrôles est recopié par
// media_kit dans sa route fullscreen. Les gestes Android
// sont donc disponibles en vue normale ET plein écran.
controls: Platform.isAndroid
? _buildAndroidVideoControls
: AdaptiveVideoControls,
// Android: le maintien d'écran est piloté par
// l'application afin d'autoriser la veille en pause/fin.
wakelock: !Platform.isAndroid,
),
),
if (_loading)
DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(widget.video.thumbnailUrl),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black54,
BlendMode.darken,
),
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
if (_restoringPosition) ...[
SizedBox(height: 14),
Text(
'Reprise de la lecture…',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
// Sur Android les PointerEvents sont observés directement
// autour de Video afin de ne jamais masquer les contrôles natifs.
// Cette couche de taps personnalisés reste uniquement hors Android.
if (!_loading && _error == null && !Platform.isAndroid)
Positioned(
left: compact ? 0 : 30,
right: compact ? 0 : 30,
top: compact ? 0 : 18,
bottom: compact ? 0 : 64,
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_videoFocusNode.requestFocus();
_togglePlayPause();
},
onDoubleTapDown: (details) {
_handleAndroidDoubleTapDown(
details,
constraints.maxWidth,
);
},
onDoubleTap: _handleAndroidDoubleTap,
);
},
),
),
if (_tapFeedbackIcon != null)
IgnorePointer(
child: Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(
_tapFeedbackIcon,
size: 42,
color: Colors.white,
),
),
),
),
if (_error != null)
_PlayerError(
message: _error!,
onRetry: _loadVideo,
),
],
),
);
}
Future<void> _loadInlineChannelDetails() async {
try {
String? channelId =
widget.video.channelId?.trim();
if (channelId == null ||
channelId.isEmpty) {
// Old persisted items may not have channelId yet. Resolve only this
// missing relation; modern Search/Radar/Channel cards already carry
// the ID and therefore make no extra lookup here.
final channel =
await _youtubeService
.getChannelForVideo(
widget.video.id,
);
channelId =
channel.channelId;
}
final details =
await _youtubeService
.getPublicChannelDetails(
channelId,
);
if (!mounted) {
return;
}
setState(() {
_inlineChannelDetails =
details;
_inlineVideoDetails =
widget.video;
_inlineChannelDetailsLoading =
false;
});
} catch (error) {
debugPrint(
'[CHANNEL-INLINE][18.7.1] '
'infos chaîne partielles: $error',
);
if (mounted) {
setState(() {
// Keep the metadata already present in widget.video even if the
// channel lookup fails.
_inlineVideoDetails =
widget.video;
_inlineChannelDetailsLoading =
false;
});
}
}
}
Future<void> _openCurrentChannel() async {
if (_openingChannel) {
return;
}
setState(() {
_openingChannel = true;
});
try {
SubscriptionChannel channel;
final channelId =
widget.video.channelId?.trim();
if (channelId != null &&
channelId.isNotEmpty) {
channel =
await _youtubeService.getChannel(
channelId,
);
} else {
channel =
await _youtubeService
.getChannelForVideo(
widget.video.id,
);
}
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelVideosPage(
channel: channel,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Impossible d’ouvrir la chaîne : $error',
),
),
);
} finally {
if (mounted) {
setState(() {
_openingChannel = false;
});
}
}
}
Widget _buildInlineChannelAvatar() {
final thumbnailUrl =
_inlineChannelDetails?.thumbnailUrl.trim() ?? '';
if (thumbnailUrl.isEmpty) {
return Icon(
Icons.account_circle,
size: 32,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
return ClipOval(
child: Image.network(
thumbnailUrl,
width: 32,
height: 32,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.account_circle,
size: 32,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
},
),
);
}
Future<void> _rateCurrentVideo(int rating) async {
final service = InterestProfileService.instance;
await service.ensureLoaded();
final category = service.classifyVideo(widget.video);
await service.rateVideo(
widget.video,
rating,
category: category,
);
if (!mounted) {
return;
}
setState(() {});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text('$rating★ enregistré dans $category.'),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _showCategoryEditor() async {
final service = InterestProfileService.instance;
await service.ensureLoaded();
if (!mounted) {
return;
}
const autoValue = '__auto__';
var videoValue = service.videoCategoryOverride(widget.video) ?? autoValue;
var channelValue =
service.channelCategoryOverride(widget.video) ?? autoValue;
final shouldSave = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (dialogContext, setDialogState) {
final items = <DropdownMenuItem<String>>[
const DropdownMenuItem<String>(
value: autoValue,
child: Text('Automatique'),
),
for (final category in service.assignableCategories)
DropdownMenuItem<String>(
value: category,
child: Text(category),
),
];
Widget selector({
required String label,
required String helper,
required String value,
required ValueChanged<String?> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(dialogContext)
.colorScheme
.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(dialogContext)
.colorScheme
.outlineVariant,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isExpanded: true,
items: items,
onChanged: onChanged,
),
),
),
const SizedBox(height: 4),
Text(
helper,
style: TextStyle(
color: Theme.of(dialogContext)
.colorScheme
.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
return AlertDialog(
title: const Text('Catégoriser'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Détection automatique : '
'${service.classifyVideoAutomatically(widget.video)}',
style: TextStyle(
color: Theme.of(dialogContext)
.colorScheme
.onSurfaceVariant,
fontSize: 12,
),
),
const SizedBox(height: 16),
selector(
label: 'Cette vidéo',
helper:
'Prioritaire sur la catégorie attribuée à la chaîne.',
value: videoValue,
onChanged: (value) {
if (value == null) {
return;
}
setDialogState(() {
videoValue = value;
});
},
),
const SizedBox(height: 18),
selector(
label: 'Toute la chaîne · ${widget.video.author}',
helper:
'Les vidéos de cette chaîne seront rattachées à ce centre d’intérêt, sauf correction vidéo individuelle.',
value: channelValue,
onChanged: (value) {
if (value == null) {
return;
}
setDialogState(() {
channelValue = value;
});
},
),
const SizedBox(height: 14),
Text(
'Priorité : vidéo > chaîne > détection automatique.',
style: TextStyle(
color: Theme.of(dialogContext)
.colorScheme
.onSurfaceVariant,
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Enregistrer'),
),
],
);
},
);
},
);
if (shouldSave != true || !mounted) {
return;
}
await service.setChannelCategoryOverride(
widget.video,
channelValue == autoValue ? null : channelValue,
);
await service.setVideoCategoryOverride(
widget.video,
videoValue == autoValue ? null : videoValue,
);
if (!mounted) {
return;
}
final effective = service.classifyVideo(widget.video);
setState(() {});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text('Catégorie active pour cette vidéo : $effective.'),
duration: const Duration(seconds: 2),
),
);
}
Widget _buildPersonalFeedbackCard() {
final service = InterestProfileService.instance;
final effective = service.classifyVideo(widget.video);
final automatic = service.classifyVideoAutomatically(widget.video);
final videoOverride = service.videoCategoryOverride(widget.video);
final channelOverride = service.channelCategoryOverride(widget.video);
final rating = service.ratingFor(
widget.video,
category: effective,
);
final source = videoOverride != null
? 'Catégorie choisie pour cette vidéo'
: channelOverride != null
? 'Catégorie héritée de la chaîne'
: automatic == 'Autres'
? 'Non classée automatiquement'
: 'Détection automatique';
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(13, 11, 13, 11),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(13),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Wrap(
spacing: 18,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Ta note',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 1; index <= 5; index++)
InkResponse(
onTap: () => _rateCurrentVideo(index),
radius: 19,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
vertical: 2,
),
child: Icon(
index <= rating
? Icons.star_rounded
: Icons.star_border_rounded,
size: 24,
color: const Color(0xFFFFC145),
),
),
),
if (rating > 0) ...[
const SizedBox(width: 5),
Text(
'$rating/5',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
],
],
),
],
),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 220, maxWidth: 520),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.category_outlined,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
effective,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Text(
source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 10.5,
),
),
],
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _showCategoryEditor,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('Modifier'),
),
],
),
),
],
),
);
}
Widget _buildMetadata() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.video.title,
style: TextStyle(
fontSize: 20,
height: 1.25,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 10),
_buildPersonalFeedbackCard(),
SizedBox(height: 10),
Material(
color: Colors.transparent,
child: InkWell(
onTap:
_openingChannel
? null
: _openCurrentChannel,
borderRadius:
BorderRadius.circular(12),
child: Padding(
padding:
EdgeInsets.symmetric(
vertical: 7,
horizontal: 5,
),
child: Row(
children: [
_buildInlineChannelAvatar(),
SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Text(
widget.video.author,
style:
TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
fontSize: 15,
fontWeight:
FontWeight
.w600,
),
),
Text(
'Ouvrir la chaîne et toutes ses vidéos',
style:
TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 11,
),
),
],
),
),
if (_openingChannel)
SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
)
else
Icon(
Icons
.chevron_right_rounded,
color:
Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
),
],
),
),
),
),
SizedBox(height: 8),
_buildInlineChannelStats(),
SizedBox(height: 12),
_buildVideoDescriptionCard(),
SizedBox(height: 16),
_buildQuickControls(),
SizedBox(height: 16),
_buildQualityControls(),
SizedBox(height: 16),
_buildAudioTrackControls(),
SizedBox(height: 16),
_buildSpeedControls(),
SizedBox(height: 16),
ValueListenableBuilder<List<VideoItem>>(
valueListenable: LibraryService.instance.favorites,
builder: (context, favorites, child) {
final isFavorite = favorites.any(
(item) => item.id == widget.video.id,
);
return FilledButton.tonalIcon(
onPressed: _toggleFavorite,
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
),
label: Text(
isFavorite
? 'Retirer des favoris'
: 'Ajouter aux favoris',
),
);
},
),
SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(
icon: Icons.hd_outlined,
label: _qualityLabel,
),
_InfoChip(
icon: _hdActive
? Icons.auto_awesome
: Icons.merge_type,
label: _qualityDetails,
),
_InfoChip(
icon: Icons.restore,
label: 'Reprise automatique',
),
],
),
],
);
}
Widget _buildVideoDescriptionCard() {
final details = _inlineVideoDetails ?? widget.video;
final description = details.description.trim();
final stats = <String>[];
if (details.viewCount != null) {
stats.add(
'${_formatCompactNumber(details.viewCount!)} vues',
);
}
if (details.likeCount != null) {
stats.add(
'${_formatCompactNumber(details.likeCount!)} likes',
);
}
if (details.commentCount != null) {
stats.add(
'${_formatCompactNumber(details.commentCount!)} commentaires',
);
}
if (details.uploadDate != null) {
stats.add(
_formatCompactDate(details.uploadDate!),
);
}
if (_inlineChannelDetailsLoading &&
description.isEmpty &&
stats.isEmpty) {
return _VideoDescriptionShell();
}
if (description.isEmpty && stats.isEmpty) {
return SizedBox.shrink();
}
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(
13,
11,
13,
11,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(13),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (stats.isNotEmpty)
Text(
stats.join(' • '),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
if (stats.isNotEmpty &&
description.isNotEmpty)
SizedBox(height: 8),
if (description.isNotEmpty)
Text(
description,
maxLines: _descriptionExpanded ? null : 3,
overflow: _descriptionExpanded
? TextOverflow.visible
: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 12.5,
height: 1.42,
),
),
if (description.isNotEmpty &&
description.length > 180) ...[
SizedBox(height: 6),
InkWell(
onTap: () {
setState(() {
_descriptionExpanded =
!_descriptionExpanded;
});
},
borderRadius: BorderRadius.circular(7),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 2,
vertical: 3,
),
child: Text(
_descriptionExpanded
? 'Afficher moins'
: 'Afficher plus',
style: TextStyle(
color: Color(0xFFFF9D97),
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
),
),
],
],
),
);
}
Widget _buildInlineChannelStats() {
if (_inlineChannelDetailsLoading) {
return Padding(
padding: EdgeInsets.only(left: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 13,
height: 13,
child: CircularProgressIndicator(
strokeWidth: 1.5,
),
),
SizedBox(width: 7),
Text(
'Infos de la chaîne…',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 11,
),
),
],
),
);
}
final details = _inlineChannelDetails;
final videoDetails =
_inlineVideoDetails;
if (details == null &&
videoDetails?.likeCount == null) {
return SizedBox.shrink();
}
final chips = <Widget>[];
final hasChannelStats =
details?.subscriberCount != null ||
details?.viewCount != null ||
details?.publishedAt != null ||
(details?.joinDateLabel?.isNotEmpty ?? false) ||
details?.videoCount != null;
if (details?.subscriberCount != null) {
chips.add(
_InlineChannelChip(
icon: Icons.people_alt_outlined,
text:
'${_formatCompactNumber(details!.subscriberCount!)} abonnés',
),
);
}
if (details?.viewCount != null) {
chips.add(
_InlineChannelChip(
icon: Icons.visibility_outlined,
text:
'${_formatCompactNumber(details!.viewCount!)} vues chaîne',
),
);
}
if (details?.videoCount != null) {
chips.add(
_InlineChannelChip(
icon: Icons.video_library_outlined,
text:
'${_formatCompactNumber(details!.videoCount!)} vidéos',
),
);
}
if (details?.publishedAt != null) {
chips.add(
_InlineChannelChip(
icon: Icons.calendar_month_outlined,
text:
'Créée ${_formatCompactDate(details!.publishedAt!)}',
),
);
} else if (details?.joinDateLabel != null &&
details!.joinDateLabel!.isNotEmpty) {
chips.add(
_InlineChannelChip(
icon: Icons.calendar_month_outlined,
text:
'Créée ${details.joinDateLabel!}',
),
);
}
if (videoDetails?.likeCount != null) {
chips.add(
_InlineChannelChip(
icon: Icons.thumb_up_alt_outlined,
text:
'${_formatCompactNumber(videoDetails!.likeCount!)} likes vidéo',
),
);
}
if (!hasChannelStats) {
chips.add(
_InlineChannelChip(
icon: Icons.info_outline,
text: 'Stats chaîne indisponibles',
),
);
}
if (chips.isEmpty) {
return SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(
left: 5,
right: 5,
),
child: Wrap(
spacing: 7,
runSpacing: 7,
children: chips,
),
);
}
String _formatCompactNumber(
int value,
) {
if (value >= 1000000000) {
return '${(value / 1000000000).toStringAsFixed(
value >= 10000000000 ? 0 : 1,
)} Md';
}
if (value >= 1000000) {
return '${(value / 1000000).toStringAsFixed(
value >= 10000000 ? 0 : 1,
)} M';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(
value >= 10000 ? 0 : 1,
)} k';
}
return '$value';
}
String _formatCompactDate(
DateTime value,
) {
return '${value.day.toString().padLeft(2, '0')}/'
'${value.month.toString().padLeft(2, '0')}/'
'${value.year}';
}
Widget _buildQuickControls() {
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _loading ? null : () => _seekRelative(-10),
icon: Icon(Icons.replay_10),
label: Text('-10 s'),
),
OutlinedButton.icon(
onPressed: _loading ? null : () => _seekRelative(10),
icon: Icon(Icons.forward_10),
label: Text('+10 s'),
),
OutlinedButton.icon(
onPressed: _loading ? null : _restartVideo,
icon: Icon(Icons.restart_alt),
label: Text('Recommencer'),
),
OutlinedButton.icon(
onPressed:
_loading ? null : _togglePictureInPicture,
icon: Icon(Icons.picture_in_picture_alt),
label: Text(
Platform.isWindows && _desktopPip
? 'Quitter PiP'
: 'Picture-in-Picture',
),
),
],
);
}
Widget _buildAudioTrackControls() {
if (!(Platform.isAndroid || Platform.isWindows)) {
return const SizedBox.shrink();
}
return Row(
children: [
Expanded(
child: Text(
'Piste audio',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
OutlinedButton.icon(
onPressed: (_loading || _audioTrackLoading)
? null
: _showAudioTrackPicker,
icon: _audioTrackLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.audiotrack_rounded),
label: Text(
_audioTrackLoading
? 'Détection…'
: _audioTrackDisplayLabel,
),
),
],
);
}
Widget _buildQualityControls() {
if (!(Platform.isAndroid || Platform.isWindows)) {
return Row(
children: [
Expanded(
child: Text(
'Qualité vidéo',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Text(
'360p',
style: TextStyle(
fontWeight: FontWeight.w700,
),
),
),
],
);
}
return Row(
children: [
Expanded(
child: Text(
'Qualité vidéo',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 10,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedQualityHeight,
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
items: _qualityOptions.entries
.map(
(entry) {
final unavailable =
Platform.isWindows &&
_windowsUnavailableQualityHeights
.contains(entry.key);
return DropdownMenuItem<int>(
value: entry.key,
enabled: !unavailable,
child: Text(
unavailable
? '${entry.value} • indisponible'
: entry.value,
),
);
},
)
.toList(growable: false),
onChanged: _loading
? null
: (value) {
if (value == null) {
return;
}
unawaited(
_changeQuality(value),
);
},
),
),
),
],
);
}
Widget _buildSpeedControls() {
const minRate = 0.5;
const maxRate = 2.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Vitesse',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Text(
'${_playbackRate.toStringAsFixed(2)}x',
style: TextStyle(
fontWeight: FontWeight.w700,
),
),
),
],
),
SizedBox(height: 8),
Row(
children: [
IconButton(
tooltip: 'Ralentir de 0,05x',
onPressed: _loading
? null
: () => _adjustPlaybackRate(-0.05),
icon: Icon(Icons.remove_circle_outline),
),
Expanded(
child: Slider(
min: minRate,
max: maxRate,
divisions: 30,
value: _playbackRate.clamp(minRate, maxRate),
label: '${_playbackRate.toStringAsFixed(2)}x',
onChanged: _loading
? null
: (value) {
final rounded = _roundRate(value);
setState(() {
_playbackRate = rounded;
});
unawaited(_player.setRate(rounded));
},
),
),
IconButton(
tooltip: 'Accélérer de 0,05x',
onPressed: _loading
? null
: () => _adjustPlaybackRate(0.05),
icon: Icon(Icons.add_circle_outline),
),
],
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'0.50x',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 12,
),
),
Text(
'1.00x',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 12,
),
),
Text(
'1.50x',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 12,
),
),
Text(
'2.00x',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 12,
),
),
],
),
),
SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: _loading
? null
: () => _setPlaybackRate(0.5),
child: Text('0.50x'),
),
OutlinedButton(
onPressed: _loading
? null
: () => _setPlaybackRate(1.0),
child: Text('1.00x'),
),
OutlinedButton(
onPressed: _loading
? null
: () => _setPlaybackRate(1.5),
child: Text('1.50x'),
),
OutlinedButton(
onPressed: _loading
? null
: () => _setPlaybackRate(2.0),
child: Text('2.00x'),
),
],
),
],
);
}
double _roundRate(double value) {
final rounded = (value * 20).round() / 20;
return rounded.clamp(0.5, 2.0);
}
Future<void> _adjustPlaybackRate(double delta) async {
final next = _roundRate(_playbackRate + delta);
await _setPlaybackRate(next);
}
}
class _VideoDescriptionShell extends StatelessWidget {
const _VideoDescriptionShell();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(13),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(13),
),
child: Row(
children: [
SizedBox(
width: 13,
height: 13,
child: CircularProgressIndicator(
strokeWidth: 1.5,
),
),
SizedBox(width: 8),
Text(
'Chargement de la description…',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
fontSize: 11,
),
),
],
),
);
}
}
class _InlineChannelChip extends StatelessWidget {
const _InlineChannelChip({
required this.icon,
required this.text,
});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: 9,
vertical: 5,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: Color(0xFFFF9D97),
),
SizedBox(width: 5),
Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
fontSize: 10.5,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
class _InfoChip extends StatelessWidget {
const _InfoChip({
required this.icon,
required this.label,
});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: 11,
vertical: 7,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
class _PlayerError extends StatelessWidget {
const _PlayerError({
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(22),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
),
SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: Icon(Icons.refresh),
label: Text('Réessayer'),
),
],
),
),
);
}
}