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>
This commit is contained in:
1 parent
5f22be6c20
commit
8effa573a0
38 files changed
+1529
-303
No files matched your search
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
|
||||
import '../../shaders/shader_menu.dart';
|
||||
import '../../shaders/shader_presets.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../volume.dart';
|
||||
import 'controls.dart';
|
||||
import 'menu.dart';
|
||||
import 'style.dart';
|
||||
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
||||
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||
/// [maxVolume], the wheel steps 5 %.
|
||||
class UoscVolumeButton extends StatefulWidget {
|
||||
/// 0..100.
|
||||
/// 0..[maxVolume].
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||
@@ -179,29 +181,55 @@ class UoscVolumeButton extends StatefulWidget {
|
||||
|
||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = widget.volume;
|
||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||
return Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
||||
}
|
||||
},
|
||||
child: UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
widget.onChanged(0);
|
||||
} else {
|
||||
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||
child: VolumeBar(
|
||||
key: const ValueKey('uosc-volume-bar'),
|
||||
volume: v,
|
||||
onChanged: widget.onChanged,
|
||||
color: UoscStyle.text,
|
||||
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-volume'),
|
||||
icon: icon,
|
||||
color: v > 100 ? boostColor : null,
|
||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
widget.onChanged(0);
|
||||
} else {
|
||||
widget.onChanged(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
|
||||
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,
|
||||
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
|
||||
this.topLeft,
|
||||
this.onDoubleTap,
|
||||
this.hideAfter = const Duration(seconds: 3),
|
||||
this.shown,
|
||||
});
|
||||
|
||||
static const buttonRowHeight = 40.0;
|
||||
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
@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();
|
||||
}
|
||||
|
||||
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
|
||||
|
||||
/// Show the controls and restart the hide countdown.
|
||||
void wake() {
|
||||
if (!_visible && mounted) setState(() => _visible = true);
|
||||
if (!_visible && mounted) {
|
||||
setState(() => _visible = true);
|
||||
widget.shown?.value = true;
|
||||
}
|
||||
_hideLater();
|
||||
}
|
||||
|
||||
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
|
||||
void _hide() {
|
||||
if (!mounted || _holds > 0 || !_visible) return;
|
||||
setState(() => _visible = false);
|
||||
widget.shown?.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../playback/playback_controller.dart' show PlaybackController;
|
||||
|
||||
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
|
||||
const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||
|
||||
/// The part of the volume bar above 100 %.
|
||||
const boostColor = Color(0xFFFFB74D);
|
||||
|
||||
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
|
||||
|
||||
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the
|
||||
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
|
||||
/// it.
|
||||
class VolumeBar extends StatelessWidget {
|
||||
final double volume;
|
||||
final ValueChanged<double> onChanged;
|
||||
final double width;
|
||||
final Color color;
|
||||
final Color trackColor;
|
||||
const VolumeBar({
|
||||
super.key,
|
||||
required this.volume,
|
||||
required this.onChanged,
|
||||
this.width = 64,
|
||||
this.color = Colors.white,
|
||||
this.trackColor = Colors.white30,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble());
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (e) => at(e.localPosition.dx),
|
||||
onHorizontalDragUpdate: (e) => at(e.localPosition.dx),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: 24,
|
||||
child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VolumePainter extends CustomPainter {
|
||||
final double volume;
|
||||
final Color color, trackColor;
|
||||
_VolumePainter(this.volume, this.color, this.trackColor);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final y = size.height / 2;
|
||||
double x(double v) => size.width * v / maxVolume;
|
||||
final line = Paint()
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||
final full = x(100);
|
||||
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint()
|
||||
..color = trackColor
|
||||
..strokeWidth = 1.5);
|
||||
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
|
||||
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_VolumePainter old) =>
|
||||
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||
}
|
||||
|
||||
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||
/// wheel steps 5 %.
|
||||
class VolumeButton extends StatefulWidget {
|
||||
final Player player;
|
||||
final FocusNode? focusNode;
|
||||
const VolumeButton({super.key, required this.player, this.focusNode});
|
||||
|
||||
@override
|
||||
State<VolumeButton> createState() => _VolumeButtonState();
|
||||
}
|
||||
|
||||
class _VolumeButtonState extends State<VolumeButton> {
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = widget.player;
|
||||
return StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
initialData: player.state.volume,
|
||||
builder: (context, snap) {
|
||||
final v = snap.data ?? 100;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
key: const ValueKey('volume-button'),
|
||||
focusNode: widget.focusNode,
|
||||
tooltip: 'Volume ${v.round()} %',
|
||||
iconSize: 22,
|
||||
color: Colors.white,
|
||||
onPressed: () {
|
||||
if (v > 0) {
|
||||
_beforeMute = v;
|
||||
player.setVolume(0);
|
||||
} else {
|
||||
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||
}
|
||||
},
|
||||
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _hover
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user