Files
sync_replay_project/apps/desktop/lib/src/controls/uosc/controls.dart
T
foxyzandClaude Opus 5.5 8effa573a0 Protocol 10 presence gate, drop/paste anywhere, quick UI fixes
Ready gate: whoever is in the server while it is not playing counts (joining
while playing does not pause anyone); a swap counts everyone present and
keeps ready flags; the quorum rule is gone. Optional 'Unready when the
video changes'. No 'Skip 1:30' guess when the media has chapters.

Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a
small card asking how to open them. Files series start collapsed.

UI: Tab never moves focus and Space is always ready outside text fields;
chat focuses its field; unread badge on uosc and a chip in fullscreen;
people-only chat with an Activity toggle; skip pills show 5 s then with
the controls; volume up to 150%; own avatar same size as the party.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:06:48 +03:00

266 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
// The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, the title top left and a
// corner widget top right.
// Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
// line when hidden" is off) and the cursor hides. Our own frame: no
// media_kit controls state to reach into.
class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden.
final Widget Function(BuildContext context, bool expanded) timeline;
/// Buttons left and right of the row above the timeline.
final List<Widget> left;
final List<Widget> right;
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
/// Follows whether the controls are up (for overlays outside them).
final ValueNotifier<bool>? shown;
const UoscControls({
super.key,
required this.timeline,
this.left = const [],
this.right = const [],
this.top,
this.topLeft,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
this.shown,
});
static const buttonRowHeight = 40.0;
/// The controls around [context], for widgets that need them kept up
/// (a timeline drag, an open menu).
static UoscControlsState? maybeOf(BuildContext context) =>
context.findAncestorStateOfType<UoscControlsState>();
@override
State<UoscControls> createState() => UoscControlsState();
}
class UoscControlsState extends State<UoscControls> {
bool _visible = true;
int _holds = 0;
Timer? _timer;
bool get visible => _visible;
@override
void initState() {
super.initState();
// Not during this build: an overlay listening to [shown] may be built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.shown?.value = true;
});
_hideLater();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
/// 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): a drag sends no
/// hover events and a menu route takes the mouse.
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) {
final visible = _visible;
const fade = Duration(milliseconds: 150);
return MouseRegion(
cursor: visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
onEnter: (_) => wake(),
onHover: (_) => wake(),
onExit: (_) {
_timer?.cancel();
_hide();
},
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onDoubleTap: widget.onDoubleTap,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0x8C000000)],
),
),
),
),
),
),
if (widget.topLeft != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: 120,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Color(0x00000000)],
),
),
),
),
),
),
if (widget.top != null || widget.topLeft != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: widget.topLeft == null
? const SizedBox.shrink()
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
),
),
?widget.top,
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
IgnorePointer(
key: const ValueKey('uosc-buttons'),
ignoring: !visible,
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 4),
child: SizedBox(
height: UoscControls.buttonRowHeight,
child: Row(children: [...widget.left, const Spacer(), ...widget.right]),
),
),
),
),
// Hidden: the collapsed timeline is the progress line,
// unless the player settings turn it off.
SignalBuilder(
builder: (context) {
final line = AppSettings.instance.progressLine.value;
return AnimatedOpacity(
key: const ValueKey('uosc-timeline-wrap'),
duration: fade,
opacity: visible || line ? 1 : 0,
child: widget.timeline(context, visible),
);
},
),
],
),
),
],
),
);
}
}
/// The session's controls: the standard ones ([material]) or, with the
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// when the setting changes.
class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material;
final WidgetBuilder mpv;
const SessionControlsSwitch({super.key, required this.material, required this.mpv});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) =>
AppSettings.instance.mpvControls.value ? mpv(context) : material(context),
);
}
}