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