PlayerControlsModel holds the controls' state and actions once; the Standard skin (our own, no more media_kit controls or private-state reach-ins) and Minimal (the former uosc design, made the app's own and credited) only draw it. One layout spec for both: play first, then previous/next, volume, time; subtitles, audio, settings, chat, fullscreen last; everything else in the settings menu. Settings -> Player -> Controls: time display, size, hide delay, optional buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
6.1 KiB
Dart
217 lines
6.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'controls_probe.dart' show ControlsHold;
|
|
|
|
// The frame both control skins live in: when the controls are up, and the
|
|
// double-click on the picture. Mouse movement shows them; [hideAfter]
|
|
// without any (or the mouse leaving) hides them and the cursor. Holds keep
|
|
// them up: a seek drag (no hover events arrive), an open menu (a route
|
|
// takes the mouse), and the session's [ControlsHold] (the mouse on a
|
|
// floating pill or card, the Up next popover).
|
|
|
|
class ControlsFrame extends StatefulWidget {
|
|
/// Draws the controls; [visible] false while they are hidden.
|
|
final Widget Function(BuildContext context, bool visible) builder;
|
|
final VoidCallback? onDoubleClick;
|
|
|
|
/// Bands at the top and bottom where a double-click is the bars', not
|
|
/// the picture's.
|
|
final double barTop;
|
|
final double barBottom;
|
|
final Duration hideAfter;
|
|
|
|
/// Follows whether the controls are up (for overlays outside them).
|
|
final ValueNotifier<bool>? shown;
|
|
|
|
/// Keeps them up while held.
|
|
final ControlsHold? holdBy;
|
|
const ControlsFrame({
|
|
super.key,
|
|
required this.builder,
|
|
this.onDoubleClick,
|
|
this.barTop = 0,
|
|
this.barBottom = 0,
|
|
this.hideAfter = const Duration(seconds: 3),
|
|
this.shown,
|
|
this.holdBy,
|
|
});
|
|
|
|
/// The frame around [context], for widgets that keep it up (a drag, a
|
|
/// menu).
|
|
static ControlsFrameState? maybeOf(BuildContext context) => context.findAncestorStateOfType<ControlsFrameState>();
|
|
|
|
/// Runs [open] (a menu, a popover) with the controls around [context]
|
|
/// held up, if there are any.
|
|
static Future<T> holdWhile<T>(BuildContext context, Future<T> Function() open) {
|
|
final frame = maybeOf(context);
|
|
return frame == null ? open() : frame.holdDuring(open);
|
|
}
|
|
|
|
/// The fade of the controls.
|
|
static const fade = Duration(milliseconds: 150);
|
|
|
|
@override
|
|
State<ControlsFrame> createState() => ControlsFrameState();
|
|
}
|
|
|
|
class ControlsFrameState extends State<ControlsFrame> {
|
|
bool _visible = true;
|
|
int _holds = 0;
|
|
bool _heldBy = false;
|
|
Timer? _timer;
|
|
|
|
bool get visible => _visible;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.holdBy?.addListener(_holdByChanged);
|
|
// Not during this build: an overlay listening to [shown] may be built.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) widget.shown?.value = true;
|
|
});
|
|
_hideLater();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ControlsFrame oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.holdBy != widget.holdBy) {
|
|
oldWidget.holdBy?.removeListener(_holdByChanged);
|
|
widget.holdBy?.addListener(_holdByChanged);
|
|
_holdByChanged();
|
|
}
|
|
if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.holdBy?.removeListener(_holdByChanged);
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _holdByChanged() {
|
|
final on = widget.holdBy?.held ?? false;
|
|
if (on == _heldBy || !mounted) return;
|
|
_heldBy = on;
|
|
hold(on);
|
|
}
|
|
|
|
/// Show the controls and restart the hide countdown.
|
|
void wake() {
|
|
if (!_visible && mounted) {
|
|
setState(() => _visible = true);
|
|
widget.shown?.value = true;
|
|
}
|
|
_hideLater();
|
|
}
|
|
|
|
/// Keep the controls up while [on] (paired calls).
|
|
void hold(bool on) {
|
|
_holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20);
|
|
wake();
|
|
}
|
|
|
|
/// Runs [open] (a menu) with the controls held up.
|
|
Future<T> holdDuring<T>(Future<T> Function() open) async {
|
|
hold(true);
|
|
try {
|
|
return await open();
|
|
} finally {
|
|
if (mounted) hold(false);
|
|
}
|
|
}
|
|
|
|
void _hideLater() {
|
|
_timer?.cancel();
|
|
_timer = Timer(widget.hideAfter, _hide);
|
|
}
|
|
|
|
void _hide() {
|
|
if (!mounted || _holds > 0 || !_visible) return;
|
|
setState(() => _visible = false);
|
|
widget.shown?.value = false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MouseRegion(
|
|
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
|
onEnter: (_) => wake(),
|
|
onHover: (_) => wake(),
|
|
onExit: (_) {
|
|
_timer?.cancel();
|
|
_hide();
|
|
},
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (widget.onDoubleClick != null)
|
|
Positioned.fill(
|
|
child: DoubleClickListener(
|
|
onDoubleClick: widget.onDoubleClick!,
|
|
skipTop: _visible ? widget.barTop : 0,
|
|
skipBottom: _visible ? widget.barBottom : 0,
|
|
child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()),
|
|
),
|
|
),
|
|
widget.builder(context, _visible),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Double-click on the picture (not on the bars) without joining the
|
|
/// gesture arena, so single clicks on buttons stay instant.
|
|
class DoubleClickListener extends StatefulWidget {
|
|
final VoidCallback onDoubleClick;
|
|
final double skipTop, skipBottom;
|
|
final Widget child;
|
|
const DoubleClickListener({
|
|
super.key,
|
|
required this.onDoubleClick,
|
|
this.skipTop = 0,
|
|
this.skipBottom = 0,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
|
}
|
|
|
|
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
|
DateTime? _last;
|
|
Offset? _lastAt;
|
|
|
|
void _down(PointerDownEvent e) {
|
|
if (e.buttons != kPrimaryMouseButton) return;
|
|
final h = context.size?.height ?? 0;
|
|
final y = e.localPosition.dy;
|
|
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
|
_last = null;
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
final last = _last, at = _lastAt;
|
|
if (last != null &&
|
|
at != null &&
|
|
now.difference(last) < const Duration(milliseconds: 400) &&
|
|
(e.localPosition - at).distance < 24) {
|
|
_last = null;
|
|
widget.onDoubleClick();
|
|
return;
|
|
}
|
|
_last = now;
|
|
_lastAt = e.localPosition;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
|
}
|