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 createState() => _VideoPlayerPageState(); } class _VideoPlayerPageState extends State 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 _videoKey = GlobalKey(); // 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 _androidGestureFeedbackNotifier = ValueNotifier(null); Timer? _androidGestureFeedbackTimer; Timer? _androidPauseSleepTimer; Timer? _androidScreenPolicyWatchdog; DateTime? _androidPausedSince; bool? _androidKeepScreenOnApplied; int? _androidScheduledPauseSleepMinutes; Timer? _androidOrientationDebounce; bool _androidOrientationTransitionInProgress = false; bool? _androidPendingLandscape; final Map _androidPointerPositions = {}; int? _androidPrimaryPointer; Offset _androidGestureStartPosition = Offset.zero; double _androidPinchStartDistance = 0.0; bool _androidGestureActivated = false; bool _androidPlaybackCompleted = false; bool _androidMediaCacheConfigured = false; StreamSubscription? _positionSubscription; StreamSubscription? _playingSubscription; StreamSubscription? _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 _sponsorSegments = []; 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 _windowsUnavailableQualityHeights = {}; // 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? _windowsHdPreflight; int? _windowsHdPreflightTarget; // 0 = Auto. Android et Windows. int _selectedQualityHeight = AppSettingsService.instance.defaultResolution.value; static const Map _qualityOptions = { 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( '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 _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 _primeAndroidDeviceControls() async { if (!Platform.isAndroid) { return; } try { final volume = await _deviceChannel.invokeMethod( 'getSystemVolume', ); final brightness = await _deviceChannel.invokeMethod( '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 _primeAndroidOrientation() async { if (!Platform.isAndroid) { return; } try { final orientation = await _pipChannel.invokeMethod( '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 _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( [], ); 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( [], ); } catch (error) { debugPrint( '[ANDROID-ORIENTATION][18.7.19] sync: $error', ); } finally { await Future.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( 'schedulePauseScreenSleep', { '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 _setAndroidKeepScreenOn(bool value) async { if (!Platform.isAndroid) { return; } if (_androidKeepScreenOnApplied == value) { return; } try { await _deviceChannel.invokeMethod( 'setKeepScreenOn', {'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 _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( 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 _applyAndroidSystemVolume(double value) async { try { await _deviceChannel.invokeMethod( 'setSystemVolume', {'value': value}, ); } catch (error) { debugPrint( '[ANDROID-GESTURE][18.7.18] volume natif: $error', ); } } Future _applyAndroidWindowBrightness(double value) async { try { await _deviceChannel.invokeMethod( 'setWindowBrightness', {'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( 'setSystemVolume', {'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( 'setWindowBrightness', {'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 _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 _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.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 _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 _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 _togglePictureInPicture() async { if (Platform.isAndroid) { try { setState(() { _androidPip = true; }); final entered = await _pipChannel.invokeMethod( '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 _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 _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 _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 _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> _parseAvailableAudioTracks( Map? info, ) { final byKey = >{}; 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, () => { '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, () => { '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?> _discoverAudioTrackInfo() async { final currentHeight = _selectedQualityHeight; final targetHeight = currentHeight <= 360 ? 720 : currentHeight <= 0 ? 1080 : currentHeight; if (Platform.isAndroid) { return _hdChannel.invokeMapMethod( 'getHdStream', { '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 _showAudioTrackPicker() async { if (!(Platform.isAndroid || Platform.isWindows) || _audioTrackLoading || _loading) { return; } setState(() { _audioTrackLoading = true; }); Map? 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 = >[ { 'key': 'settings', 'label': 'Auto (selon les réglages)', 'original': false, }, ...tracks, ]; if (!choices.any((item) => item['key'] == 'original')) { choices.insert( 1, { 'key': 'original', 'label': 'Originale', 'original': true, }, ); } final selectedKey = _audioTrackOverride ?? 'settings'; final picked = await showModalBottomSheet( 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( 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 _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 _loadVideo() async { await _loadVideoInternal(); } Future _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() .map((value) => value.toInt()) .toSet() : {}; 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 _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? 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 = []; 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 _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 _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? 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.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 _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.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 _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 _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? 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.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.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 _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 _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 = {}; 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 _tryOpenWindowsMpvHd({ required int targetHeight, required bool shouldPlayAfterLoad, int? fallbackFromHeight, }) async { StreamSubscription? errorSubscription; StreamSubscription? 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 stringMap(dynamic raw) { final output = {}; 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.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?> _requestPlatformHdStream( int targetHeight, ) async { if (Platform.isAndroid) { debugPrint( '[HD-ANDROID][18.7.8] demande native pour ${widget.video.id} ' 'cible=$targetHeight', ); return _hdChannel.invokeMapMethod( 'getHdStream', { 'videoId': widget.video.id, 'targetHeight': targetHeight, 'preferredLanguage': _effectiveAudioLanguagePreference, 'originalLanguageHint': _inferOriginalAudioLanguageHint(), }, ); } return null; } Future _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 _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 _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 _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.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 _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 _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 _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 _toggleFullscreenFromKeyboard() async { final state = _videoKey.currentState; if (state == null) { return; } await state.toggleFullscreen(); } Future _exitFullscreenFromKeyboard() async { final state = _videoKey.currentState; if (state == null || !state.isFullscreen()) { return; } await state.exitFullscreen(); } Map _fullscreenKeyboardShortcuts() { return { 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 _restartVideo() async { await _player.seek(Duration.zero); _latestPosition = Duration.zero; _lastSavedPosition = Duration.zero; await LibraryService.instance.clearPlaybackPosition( widget.video.id, ); } Future _setPlaybackRate(double rate) async { await _player.setRate(rate); if (!mounted) { return; } setState(() { _playbackRate = rate; }); } Future _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( '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 _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 _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( 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 _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 _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( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (dialogContext, setDialogState) { final items = >[ const DropdownMenuItem( value: autoValue, child: Text('Automatique'), ), for (final category in service.assignableCategories) DropdownMenuItem( value: category, child: Text(category), ), ]; Widget selector({ required String label, required String helper, required String value, required ValueChanged 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( 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>( 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 = []; 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 = []; 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( 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( 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 _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'), ), ], ), ), ); } }