Files
sync_replay_project/apps/desktop/lib/src/controls/frame.dart
T
foxyzandClaude Opus 5.5 fd8064611a Player controls: one model, two skins (Standard, Minimal); conventional layout; controls settings
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>
2026-10-15 17:03:15 +03:00

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);
}