A show/hide inside one frame left the chrome flagged presented with no fade to clear it, so back and Escape did nothing until a later full cycle repaired the flag; hide now retires presentation directly when the chrome never became opaque. The sync-offset rows and slider seed also read the global pref while writes and the player used the scoped store, so a title/library-scoped offset displayed as 0ms and the first slider tap jumped from the wrong baseline.
253 lines
7.4 KiB
Dart
253 lines
7.4 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 }
|
|
|
|
/// Owns video-player chrome visibility and auto-hide policy for one player route.
|
|
class PlayerChromeController extends ChangeNotifier implements ValueListenable<bool> {
|
|
PlayerChromeController({bool initiallyVisible = true})
|
|
: _controlsVisible = initiallyVisible,
|
|
_controlsPresented = initiallyVisible,
|
|
_controlsOpaque = initiallyVisible;
|
|
|
|
bool _controlsVisible;
|
|
bool _controlsPresented;
|
|
bool _controlsOpaque;
|
|
bool _contentStripVisible = false;
|
|
bool _playing = false;
|
|
bool _hasFirstFrame = true;
|
|
Duration _hideDelay = const Duration(seconds: 3);
|
|
Timer? _hideTimer;
|
|
bool _pendingPlayPauseFocus = false;
|
|
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 isHeld(PlayerChromeHold hold) => _holds.contains(hold);
|
|
bool get pendingPlayPauseFocus => _pendingPlayPauseFocus;
|
|
|
|
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, bool focusPlayPause = false}) {
|
|
_controlsPresented = true;
|
|
var shouldNotify = false;
|
|
if (focusPlayPause) {
|
|
_pendingPlayPauseFocus = true;
|
|
shouldNotify = true;
|
|
}
|
|
if (!_controlsVisible) {
|
|
_controlsVisible = true;
|
|
shouldNotify = true;
|
|
}
|
|
if (shouldNotify) notifyListeners();
|
|
if (restartAutoHide) _startAutoHideForCurrentPlaybackState();
|
|
}
|
|
|
|
/// Returns whether a play/pause focus request was queued by [show], and clears it.
|
|
bool takePlayPauseFocus() {
|
|
final requested = _pendingPlayPauseFocus;
|
|
_pendingPlayPauseFocus = false;
|
|
return requested;
|
|
}
|
|
|
|
bool hide({bool ignoreHolds = false}) {
|
|
if (!_controlsVisible) return false;
|
|
if (!ignoreHolds && _holds.isNotEmpty) return false;
|
|
cancelAutoHide();
|
|
_controlsVisible = false;
|
|
// A chrome that never reached full opacity has no fade-out to run — a
|
|
// freshly inserted AnimatedOpacity sits at its hidden target and never
|
|
// fires onEnd, so markControlsHidden would never arrive. Retire the
|
|
// presented flag now; the controls host drops its subtree in response.
|
|
// A chrome that did fade in keeps the flag until markControlsHidden.
|
|
if (!_controlsOpaque) _controlsPresented = false;
|
|
_controlsOpaque = false;
|
|
if (_contentStripVisible) {
|
|
_contentStripVisible = false;
|
|
_holds.remove(PlayerChromeHold.contentStrip);
|
|
}
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
/// Called when the controls subtree is actually rendered at full opacity,
|
|
/// so a later [hide] can rely on a real fade-out (and its
|
|
/// [markControlsHidden] completion) to retire [controlsPresented].
|
|
void markControlsOpaque() {
|
|
if (!_controlsVisible) return;
|
|
_controlsOpaque = 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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|