Files
plezy/lib/widgets/video_controls/player_chrome_controller.dart
T

237 lines
6.6 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'
show BuildContext, ListenableBuilder, MouseRegion, StatelessWidget, SystemMouseCursors, Widget;
/// Reasons that keep the video-player chrome visible and suppress auto-hide.
enum PlayerChromeHold { pip, contentStrip, promptInteraction, scrub }
/// Focus target to request after chrome has rebuilt visible controls.
enum PlayerChromeFocusTarget { playPause, timeline }
/// Owns video-player chrome visibility and auto-hide policy for one player route.
class PlayerChromeController extends ChangeNotifier implements ValueListenable<bool> {
PlayerChromeController({this._controlsVisible = true});
bool _controlsVisible;
bool _controlsPresented = true;
bool _contentStripVisible = false;
bool _playing = false;
bool _hasFirstFrame = true;
Duration _hideDelay = const Duration(seconds: 3);
Timer? _hideTimer;
PlayerChromeFocusTarget? _pendingFocusTarget;
final Set<PlayerChromeHold> _holds = <PlayerChromeHold>{};
final Stopwatch _pointerActivityStopwatch = Stopwatch()..start();
int _lastPointerActivityMs = -1000;
@override
bool get value => _controlsVisible;
bool get controlsVisible => _controlsVisible;
/// Whether controls may still be visibly rendered during their fade-out.
bool get controlsPresented => _controlsPresented;
bool get contentStripVisible => _contentStripVisible;
bool get hasVisibleHold => _holds.isNotEmpty;
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget;
void configure({Duration? hideDelay, bool? hasFirstFrame}) {
var restartTimer = false;
if (hideDelay != null && hideDelay != _hideDelay) {
_hideDelay = hideDelay;
restartTimer = true;
}
if (hasFirstFrame != null && hasFirstFrame != _hasFirstFrame) {
_hasFirstFrame = hasFirstFrame;
restartTimer = true;
}
if (restartTimer) _startAutoHideForCurrentPlaybackState();
}
void setPlaying(bool playing) {
if (_playing == playing) return;
_playing = playing;
if (!_controlsVisible) return;
if (playing) {
startAutoHide();
} else {
startPausedAutoHide();
}
}
void setHasFirstFrame(bool hasFirstFrame) {
if (_hasFirstFrame == hasFirstFrame) return;
_hasFirstFrame = hasFirstFrame;
if (!_hasFirstFrame) {
cancelAutoHide();
return;
}
_startAutoHideForCurrentPlaybackState();
}
void setContentStripVisible(bool visible) {
if (_contentStripVisible == visible) return;
_contentStripVisible = visible;
if (visible) {
hold(PlayerChromeHold.contentStrip);
} else {
release(PlayerChromeHold.contentStrip);
}
}
void show({bool restartAutoHide = true, PlayerChromeFocusTarget? focusTarget}) {
_controlsPresented = true;
var shouldNotify = false;
if (focusTarget != null) {
_pendingFocusTarget = focusTarget;
shouldNotify = true;
}
if (!_controlsVisible) {
_controlsVisible = true;
shouldNotify = true;
}
if (shouldNotify) notifyListeners();
if (restartAutoHide) _startAutoHideForCurrentPlaybackState();
}
PlayerChromeFocusTarget? takeFocusTarget() {
final target = _pendingFocusTarget;
_pendingFocusTarget = null;
return target;
}
bool hide({bool ignoreHolds = false}) {
if (!_controlsVisible) return false;
if (!ignoreHolds && _holds.isNotEmpty) return false;
cancelAutoHide();
_controlsVisible = false;
if (_contentStripVisible) {
_contentStripVisible = false;
_holds.remove(PlayerChromeHold.contentStrip);
}
notifyListeners();
return true;
}
/// Called when the controls opacity animation reaches its hidden target.
void markControlsHidden() {
if (_controlsVisible) return;
_controlsPresented = false;
}
void toggle() {
if (_controlsVisible) {
hide();
} else {
show();
}
}
bool recordPointerActivity() {
final nowMs = _pointerActivityStopwatch.elapsedMilliseconds;
final shouldThrottle = _controlsVisible && nowMs - _lastPointerActivityMs < 120;
if (shouldThrottle) return false;
_lastPointerActivityMs = nowMs;
show(restartAutoHide: false);
_startAutoHideForCurrentPlaybackState();
return true;
}
void startAutoHide() {
_hideTimer?.cancel();
if (!_hasFirstFrame || _holds.isNotEmpty || !_playing) return;
_hideTimer = Timer(_hideDelay, () {
if (_playing && _hasFirstFrame) hide();
});
}
void startPausedAutoHide() {
_hideTimer?.cancel();
if (!_controlsVisible || !_hasFirstFrame || _holds.isNotEmpty) return;
_hideTimer = Timer(_hideDelay, hide);
}
void _startAutoHideForCurrentPlaybackState() {
if (!_controlsVisible) {
cancelAutoHide();
return;
}
if (_playing) {
startAutoHide();
} else {
startPausedAutoHide();
}
}
void restartAutoHideForCurrentPlaybackState() => _startAutoHideForCurrentPlaybackState();
void hideForPointerExit() {
if (_holds.contains(PlayerChromeHold.pip)) return;
hide(ignoreHolds: true);
}
void cancelAutoHide() {
_hideTimer?.cancel();
_hideTimer = null;
}
void hold(PlayerChromeHold hold) {
if (!_holds.add(hold)) return;
cancelAutoHide();
if (!_controlsVisible) {
_controlsVisible = true;
}
_controlsPresented = true;
notifyListeners();
}
void release(PlayerChromeHold hold, {bool notify = true, bool restartAutoHide = true}) {
if (!_holds.remove(hold)) return;
if (notify) notifyListeners();
if (restartAutoHide && _holds.isEmpty) _startAutoHideForCurrentPlaybackState();
}
@override
void dispose() {
cancelAutoHide();
super.dispose();
}
}
/// Defines the pointer boundary for all interactive video-player chrome.
class PlayerChromeInteractionRegion extends StatelessWidget {
final PlayerChromeController controller;
final bool hideOnExit;
final Widget child;
const PlayerChromeInteractionRegion({
super.key,
required this.controller,
required this.hideOnExit,
required this.child,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return MouseRegion(
cursor: controller.controlsVisible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => controller.recordPointerActivity(),
onExit: (_) {
if (!hideOnExit) return;
controller.cancelAutoHide();
controller.hideForPointerExit();
},
child: child,
);
},
);
}
}