A television raised the whole OSD and timebar on every playback start. The chrome controller is born visible, and its auto-hide clock cannot arm until the first frame lands, so the controls did not merely appear early: they appeared exactly when the picture did, and then sat over the opening five seconds of every movie and episode. The timeline is gated behind the first frame, so the bar materialised on top of the video rather than over the loading spinner, which is what makes it read as a pop-up rather than as chrome that was already there. The route now opens with no chrome on TV. Nothing is lost: the loading spinner and buffering overlay are their own overlays, the screen focus node owns back, and the first D-pad press raises the controls the way it already does after every auto-hide. Pointer and touch platforms keep the chrome, where the viewer's hand is on the surface and the title and back affordance belong over the spinner. Initial presentation now follows initial visibility. They were separate: seeding only visibility would leave the route claiming its chrome was still presented, so PlayerNavigationCoordinator would read back as "hide the chrome", hide() would no-op against chrome that was never up, and the press would be swallowed instead of leaving the player. Controls that mount with the chrome already down now claim focus themselves. Focus normally reaches them through the hide transition, and their own autofocus cannot win it back because the screen node took it during the loading phase. Left alone, the screen node kept primary focus and its self-heal raised the entire OSD on the first D-pad press, which put the chrome straight back over the picture and bypassed the transient seek and transport indicators. Both player spinners now carry a label. They were bare progress indicators, so a screen reader announced nothing at all while the picture was coming up, and the TV Maestro flows had no way left to tell a loading player from a playing one once the Pause button stopped appearing on its own. The two TV flows are repaired to match. They waited on that button, and now wait for the labelled spinner to clear, which cannot happen before the media is opened. 05 additionally reaches Search by D-pad rather than a percentage coordinate, because a tap flips InputModeTracker to pointer mode and collapses the rail it is aiming at, and it gates on the play-next prompt's own Cancel action: "Next Episode" is also the credits skip button, so the old assertion could pass without the prompt ever opening. close #1765
238 lines
6.6 KiB
Dart
238 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({bool initiallyVisible = true})
|
|
: _controlsVisible = initiallyVisible,
|
|
_controlsPresented = initiallyVisible;
|
|
|
|
bool _controlsVisible;
|
|
bool _controlsPresented;
|
|
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 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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|