diff --git a/apps/desktop/lib/src/controls/uosc/buttons.dart b/apps/desktop/lib/src/controls/uosc/buttons.dart index 6260bb4..4805e09 100644 --- a/apps/desktop/lib/src/controls/uosc/buttons.dart +++ b/apps/desktop/lib/src/controls/uosc/buttons.dart @@ -1,4 +1,3 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart' show JellyfinStreamType; @@ -168,7 +167,7 @@ class _UoscBadgeButtonState extends State { } /// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to -/// [maxVolume], the wheel steps 5 %. +/// [maxVolume], the wheel steps [volumeStep]. class UoscVolumeButton extends StatefulWidget { /// 0..[maxVolume]. final double volume; @@ -180,6 +179,7 @@ class UoscVolumeButton extends StatefulWidget { } class _UoscVolumeButtonState extends State { + final _steps = WheelSteps(); double _beforeMute = 100; bool _hover = false; @@ -191,11 +191,8 @@ class _UoscVolumeButtonState extends State { 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)); - } - }, + // Claimed here, so the player's own wheel handler skips it. + onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index bcdc688..ea68a6e 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -10,8 +10,8 @@ import '../controls_probe.dart' show ControlsHold; // 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 +// they fade out, the timeline shrinks to uosc's progress 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 { diff --git a/apps/desktop/lib/src/controls/uosc/style.dart b/apps/desktop/lib/src/controls/uosc/style.dart index 906927d..88591c3 100644 --- a/apps/desktop/lib/src/controls/uosc/style.dart +++ b/apps/desktop/lib/src/controls/uosc/style.dart @@ -4,9 +4,7 @@ import 'package:flutter/material.dart'; // owner runs it: flat black panels, white icons, a muted blue fill. abstract final class UoscStyle { - /// Timeline background and the boxes on it. - static const bar = Color(0xCC000000); - /// Played part of the timeline; also the active menu row. + /// The active menu row. static const fill = Color(0xFF4C6A8E); /// Menus and tooltips. static const panel = Color(0xF0101010); @@ -51,8 +49,8 @@ class UoscTooltip extends StatelessWidget { } } -/// A small dark box with one line of text (the timeline's times, the -/// hover chapter title). +/// A small dark box with one line of text (the timeline's hover time and +/// chapter title). class UoscBox extends StatelessWidget { final String text; final Color color; diff --git a/apps/desktop/lib/src/controls/uosc/timeline.dart b/apps/desktop/lib/src/controls/uosc/timeline.dart index 4e01d9e..c6c8098 100644 --- a/apps/desktop/lib/src/controls/uosc/timeline.dart +++ b/apps/desktop/lib/src/controls/uosc/timeline.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -6,6 +7,66 @@ import '../../playback/media_marks.dart'; import 'controls.dart'; import 'style.dart'; +// A port of uosc's timeline (`elements/Timeline.lua`, defaults from +// `uosc.conf` / `main.lua`), style `line`, scale 1: a translucent black +// band, the position a thin white line (no filled progress), the +// uncached part hatched, chapter diamonds on the top edge, elapsed time +// left and the time remaining right, the buffered seconds next to the +// position while under the threshold, and a tooltip with the time (and +// the chapter title) under the mouse. + +/// uosc's defaults at scale 1. +abstract final class UoscTimelineSpec { + /// `timeline_size`: the band's height when expanded. + static const size = 40.0; + + /// `timeline_border`: band above the line, also the diamonds' border. + static const border = 1.0; + + /// `timeline_line_width`: the position line. + static const lineWidth = 2.0; + + /// `progress_size`: what is left when the controls hide. + static const progressSize = 2.0; + + /// `progress_line_width`: the position line once minimized. + static const progressLineWidth = 20.0; + + /// floor(min((size + 60) * 0.2, size * 0.96)). + static const fontSize = 20.0; + + /// max(floor((size - fontSize) / 2.5), 4): the times' inset. + static const spacing = 8.0; + + /// `text_border`: the outline of text drawn on the band. + static const textBorder = 1.2; + + /// `opacity.timeline` (the band) and `opacity.chapters` (diamond fill). + static const opacity = 0.9; + static const chaptersOpacity = 0.8; + + /// Gap between the band and its tooltips, and between tooltips. + static const tooltipGap = 2.0; + + /// `buffered_time_threshold`: the cache label shows below this. + static const bufferedThreshold = Duration(seconds: 60); + + /// max((size + border) / 10, 3): diamond radius cap. + static const chapterSize = (size + border) / 10; +} + +/// uosc's `format_time(seconds, duration)`: mpv's "HH:MM:SS", trimmed to +/// "MM:SS" for media under an hour and "SS" under a minute; negative +/// values keep their minus ("-01:47:30"). +String uoscClock(Duration d, Duration max) { + final neg = d.isNegative && d.inSeconds != 0; + final t = d.abs(); + String two(int n) => n.toString().padLeft(2, '0'); + final full = '${two(t.inHours)}:${two(t.inMinutes % 60)}:${two(t.inSeconds % 60)}'; + final trim = max < const Duration(minutes: 1) ? 6 : (max < const Duration(hours: 1) ? 3 : 0); + return '${neg ? '-' : ''}${full.substring(trim)}'; +} + /// Fraction of [total] at [d], 0..1 (0 without a duration). double timelineFraction(Duration d, Duration total) { final t = total.inMicroseconds; @@ -13,36 +74,104 @@ double timelineFraction(Duration d, Duration total) { return (d.inMicroseconds / t).clamp(0.0, 1.0); } -/// The cached-ahead part of a timeline of [size]: from the playing -/// position to the end of mpv's cache. Null when nothing is cached ahead. -Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) { - final from = timelineFraction(position, duration) * size.width; - final to = timelineFraction(buffered, duration) * size.width; - if (to - from < 0.5) return null; - return Rect.fromLTRB(from, 0, to, size.height); +/// Where the uncached part starts (it runs to the end), as a fraction; +/// null when nothing known is uncached. We know only the end of mpv's +/// cache ahead ([buffered]); without it (0) uosc draws no ranges either, +/// and a cache that reaches the end (within uosc's 0.5 s) leaves none. +double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) { + if (duration <= Duration.zero || buffered <= Duration.zero) return null; + final from = buffered > position ? buffered : position; + if (duration - from <= const Duration(milliseconds: 500)) return null; + return timelineFraction(from, duration); } -/// The hatching of [r]: 45° strokes from bottom-left to top-right, -/// [spacing] apart along x, across all of [r] (the painter clips them). -/// The first starts half a step in, so none just grazes the corner. -List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) { - if (r.isEmpty || spacing <= 0) return const []; - final h = r.height; - return [ - for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)), - ]; +/// The cache label next to the position ("12s"): the seconds buffered +/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the +/// cache does not reach the end (uosc drops it at eof-cached). +String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) { + if (duration <= Duration.zero || buffered <= Duration.zero) return null; + if (duration - buffered <= const Duration(milliseconds: 500)) return null; + final ahead = buffered > position ? buffered - position : Duration.zero; + if (ahead >= UoscTimelineSpec.bufferedThreshold) return null; + return '${(ahead.inMilliseconds / 1000).round()}s'; } -/// The uosc timeline: full width, elapsed part filled, the cached range -/// ahead hatched, chapter starts as white diamonds on the top edge, the -/// current time in a box at the left and the duration at the right. -/// Hover shows the chapter title over the hover time. When the controls -/// hide ([expanded] false) it shrinks to a thin line with chapter ticks. -/// Like the media_kit bar it reports a seek on every drag move and on -/// release ([onSeek]); the session sends those through the user-seek path. +/// The band's geometry at one size, as uosc lays it out: the position +/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no +/// time lies inside the line. +class UoscTimelineGeometry { + final double width; + + /// The foreground height (without the top border). + final double size; + + /// The playing position, 0..1. + final double progress; + final double lineWidth; + const UoscTimelineGeometry({ + required this.width, + required this.size, + required this.progress, + this.lineWidth = UoscTimelineSpec.lineWidth, + }); + + /// [size] while the band animates between hidden (0) and shown (1) + /// (`get_effective_size`). + static double sizeAt(double visibility) => + UoscTimelineSpec.progressSize + + ((UoscTimelineSpec.size - UoscTimelineSpec.progressSize) * visibility).ceilToDouble(); + + /// The position line's width at [size]: it grows to + /// [UoscTimelineSpec.progressLineWidth] over the last eighth of the + /// collapse. + static double lineWidthAt(double size) { + const p = UoscTimelineSpec.progressSize; + final minimized = 1 - math.min((size - p) / ((UoscTimelineSpec.size - p) / 8), 1.0); + return UoscTimelineSpec.lineWidth + (UoscTimelineSpec.progressLineWidth - UoscTimelineSpec.lineWidth) * minimized; + } + + double get height => size + UoscTimelineSpec.border; + + /// Top of the foreground (below the border). + double get top => UoscTimelineSpec.border; + double get lineLeft => ((width - lineWidth) * progress).roundToDouble(); + double get lineRight => ((width - lineWidth) * progress + lineWidth).roundToDouble(); + + /// x of [fraction] (uosc's `t2x`): past the position, shifted over the line. + double x(double fraction) { + final lw = lineWidth - 1; + final at = 0.5 + (width - lw - 1) * fraction; + return fraction <= progress ? at : at + lw; + } + + /// The fraction at [x] (uosc's `get_time_at_x`): x on the line is the + /// position. + double fractionAt(double x) { + const lw = UoscTimelineSpec.lineWidth - 1; + final timeWidth = width - lw - 1; + if (timeWidth <= 0) return 0; + final a = timeWidth * progress, b = a + lw; + var at = x - 0.5; + if (at > b) { + at -= lw; + } else if (at > a) { + at = a; + } + return (at / timeWidth).clamp(0.0, 1.0); + } + + /// Chapter diamond radius at this size; doubled while hovered. + double get diamondRadius => math.min(math.max(1.0, size * 0.8), UoscTimelineSpec.chapterSize); +} + +/// The uosc timeline. [expanded] false (the controls hidden) shrinks it, +/// animated, to the progress line. Seeks ([onSeek]) on press, on every +/// drag move and on release; a press on a hovered chapter diamond jumps +/// to that chapter. The session sends those through the user-seek path. class UoscTimeline extends StatefulWidget { final Duration position; final Duration duration; + /// End of the cached range ([PlaybackController.buffered]). final Duration buffered; final List chapters; @@ -58,24 +187,32 @@ class UoscTimeline extends StatefulWidget { required this.onSeek, }); - static const height = 30.0; - static const collapsedHeight = 2.0; + static const height = UoscTimelineSpec.size + UoscTimelineSpec.border; + static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border; + + /// uosc's `animation_duration`. + static const animation = Duration(milliseconds: 100); @override State createState() => _UoscTimelineState(); } class _UoscTimelineState extends State { - double? _hoverX; + Offset? _hover; bool _dragging = false; double _dragFraction = 0; + Duration? _lastSent; + // A released seek, shown until the position catches up (else the line + // jumps back to the old position for a moment). + double? _pending; + Timer? _pendingTimer; Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0); - void _seekTo(double x, double width) { - if (widget.duration <= Duration.zero || width <= 0) return; - _dragFraction = (x / width).clamp(0.0, 1.0); - widget.onSeek(_at(_dragFraction)); + void _send(Duration d) { + if (d == _lastSent) return; + _lastSent = d; + widget.onSeek(d); } void _drag(bool on) { @@ -84,204 +221,425 @@ class _UoscTimelineState extends State { UoscControls.maybeOf(context)?.hold(on); } + void _settle(double fraction) { + _pending = fraction; + _pendingTimer?.cancel(); + _pendingTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setState(() => _pending = null); + }); + } + @override void didUpdateWidget(UoscTimeline old) { super.didUpdateWidget(old); - if (!widget.expanded && old.expanded) _hoverX = null; + if (!widget.expanded && old.expanded) _hover = null; + final p = _pending; + if (p != null && widget.duration > Duration.zero) { + final off = (widget.position - _at(p)).abs(); + if (off < const Duration(milliseconds: 1500)) { + _pending = null; + _pendingTimer?.cancel(); + } + } } @override void dispose() { + _pendingTimer?.cancel(); // A drag cut short by a rebuild must not keep the controls up. if (_dragging) UoscControls.maybeOf(context)?.hold(false); super.dispose(); } + /// The chapter whose diamond is within twice its radius of [at]. + int? _hoveredChapter(UoscTimelineGeometry geo, List marks, Offset? at) { + if (at == null || _dragging) return null; + final reach = geo.diamondRadius * 2; + int? best; + var bestD = double.infinity; + for (var i = 0; i < marks.length; i++) { + final cx = geo.x(timelineFraction(marks[i].start, widget.duration)); + final d = (Offset(cx, 0) - at).distance; + if (d <= reach && d < bestD) { + best = i; + bestD = d; + } + } + return best; + } + @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraints) { - final width = constraints.maxWidth; - final expanded = widget.expanded; - final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight; - final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration); - final hoverX = expanded ? _hoverX : null; - final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width); - final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt); - final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title; - final withHours = widget.duration >= const Duration(hours: 1); - final marks = [ - for (final c in widget.chapters) - if (c.start > Duration.zero && widget.duration > Duration.zero) c, - ]; - final bar = SizedBox( - key: const ValueKey('uosc-timeline'), - width: width, - height: h, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: CustomPaint( - painter: UoscTimelinePainter( - played: played, - cache: (size) => cacheRect(size, - position: widget.position, buffered: widget.buffered, duration: widget.duration), - cacheKey: (widget.position, widget.buffered, widget.duration), - hoverX: _dragging ? null : hoverX, - hatched: expanded, + final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily; + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return TweenAnimationBuilder( + tween: Tween(end: widget.expanded ? 1 : 0), + duration: UoscTimeline.animation, + builder: (context, visibility, _) => _band(width, visibility, font), + ); + }, + ); + } + + Widget _band(double width, double visibility, String? font) { + final expanded = widget.expanded; + final duration = widget.duration; + final size = UoscTimelineGeometry.sizeAt(visibility); + final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration)); + final shown = _at(progress); + final geo = UoscTimelineGeometry( + width: width, + size: size, + progress: progress, + lineWidth: UoscTimelineGeometry.lineWidthAt(size), + ); + final marks = duration > Duration.zero ? widget.chapters : const []; + final hover = expanded ? _hover : null; + final hovered = _hoveredChapter(geo, marks, hover); + final hoverFraction = hovered != null + ? timelineFraction(marks[hovered].start, duration) + : (hover == null || duration <= Duration.zero ? null : geo.fractionAt(hover.dx)); + final hoverAt = hoverFraction == null ? null : _at(hoverFraction); + final cursorX = hovered != null ? geo.x(hoverFraction!) : hover?.dx; + final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt); + final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title; + // Text fades out just before it would overflow the shrinking band. + const hideBelow = UoscTimelineSpec.fontSize * 0.8; + final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2); + final radius = geo.diamondRadius; + final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble(); + final painter = UoscTimelinePainter( + geometry: geo, + visibility: visibility, + uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration), + chapters: [for (final c in marks) timelineFraction(c.start, duration)], + hoveredChapter: hovered, + hoverX: cursorX, + textOpacity: textOpacity, + elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null, + remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null, + cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration), + fontFamily: font, + ); + final bar = SizedBox( + key: const ValueKey('uosc-timeline'), + width: width, + height: geo.height, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: RepaintBoundary(child: CustomPaint(painter: painter)), + ), + if (hoverAt != null && cursorX != null) + Positioned( + left: 0, + right: 0, + bottom: geo.height + timestampGap, + height: 160, + child: IgnorePointer( + child: CustomMultiChildLayout( + delegate: _TooltipLayout(x: cursorX), + children: [ + LayoutId( + id: #time, + child: UoscBox( + uoscClock(hoverAt, duration), + key: const ValueKey('uosc-hover-time'), + color: Colors.black, + style: _tipStyle, + ), + ), + if (hoverTitle.isNotEmpty) + LayoutId( + id: #chapter, + child: UoscBox( + hoverTitle, + key: const ValueKey('uosc-hover-chapter'), + color: Colors.black, + style: _tipStyle.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], ), ), ), - for (final c in marks) - if (expanded) - Positioned( - key: ValueKey('uosc-chapter-${c.index}'), - left: width * timelineFraction(c.start, widget.duration) - 4, - top: -4, - width: 8, - height: 8, - child: IgnorePointer( - child: Transform.rotate( - angle: math.pi / 4, - child: Container( - margin: const EdgeInsets.all(1.2), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.black54, width: 0.6), - ), - ), - ), - ), - ) - else - Positioned( - key: ValueKey('uosc-chapter-tick-${c.index}'), - left: width * timelineFraction(c.start, widget.duration) - 1, - top: -2, - width: 2, - height: h + 2, - child: const IgnorePointer(child: ColoredBox(color: Colors.white70)), - ), - if (expanded) ...[ - Positioned( - left: 4, - top: 0, - bottom: 0, - child: IgnorePointer( - child: Center( - child: UoscBox(formatUoscTime(_at(played), withHours: withHours), - key: const ValueKey('uosc-time'), color: UoscStyle.bar), - ), - ), - ), - Positioned( - right: 4, - top: 0, - bottom: 0, - child: IgnorePointer( - child: Center( - child: UoscBox(formatUoscTime(widget.duration, withHours: withHours), - key: const ValueKey('uosc-duration'), color: UoscStyle.bar), - ), - ), - ), - ], - if (hoverAt != null) - Positioned( - // Kept clear of the window edges (the tip is centred). - left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)), - bottom: h + 8, - child: IgnorePointer( - child: FractionalTranslation( - translation: const Offset(-0.5, 0), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')), - UoscBox(formatUoscTime(hoverAt, withHours: withHours), - key: const ValueKey('uosc-hover-time')), - ], - ), - ), - ), - ), - ], - ), - ); - if (!expanded) return IgnorePointer(child: bar); - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)), - onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)), - onExit: (_) => setState(() => _hoverX = null), - child: Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (e) { - _dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0); - _drag(true); - }, - onPointerMove: (e) { - setState(() => _hoverX = e.localPosition.dx.clamp(0, width)); - _seekTo(e.localPosition.dx, width); - }, - onPointerUp: (e) { - _seekTo(e.localPosition.dx, width); - _drag(false); - }, - onPointerCancel: (_) => _drag(false), - child: bar, - ), - ); - }); + ], + ), + ); + if (!expanded) return IgnorePointer(child: bar); + Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy); + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)), + onHover: (e) => setState(() => _hover = clampTo(e.localPosition)), + onExit: (_) { + if (!_dragging) setState(() => _hover = null); + }, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (e) { + if (duration <= Duration.zero || width <= 0) return; + final at = clampTo(e.localPosition); + final chapter = _hoveredChapter(geo, marks, at); + _lastSent = null; + if (chapter != null) { + final f = timelineFraction(marks[chapter].start, duration); + _settle(f); + _send(marks[chapter].start); + setState(() {}); + return; + } + _pending = null; + _dragFraction = geo.fractionAt(at.dx); + _drag(true); + _send(_at(_dragFraction)); + }, + onPointerMove: (e) { + if (!_dragging) return; + final at = clampTo(e.localPosition); + setState(() { + _hover = at; + _dragFraction = geo.fractionAt(at.dx); + }); + _send(_at(_dragFraction)); + }, + onPointerUp: (e) { + if (!_dragging) return; + _send(_at(_dragFraction)); + _settle(_dragFraction); + final p = e.localPosition; + if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null; + _drag(false); + }, + onPointerCancel: (_) => _drag(false), + child: bar, + ), + ); } + + static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1); } -/// Background, cached range (hatched, or a faint band when collapsed), -/// the played part and the hover line. +/// The hover tooltips: the time centred on [x], the chapter title over +/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends. +class _TooltipLayout extends MultiChildLayoutDelegate { + final double x; + _TooltipLayout({required this.x}); + + @override + void performLayout(Size size) { + const gap = UoscTimelineSpec.tooltipGap; + double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w)); + final loose = BoxConstraints.loose(size); + final time = layoutChild(#time, loose); + final timeTop = size.height - time.height; + positionChild(#time, Offset(left(time.width), timeTop)); + if (hasChild(#chapter)) { + final chapter = layoutChild(#chapter, loose); + positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height)); + } + } + + @override + bool shouldRelayout(_TooltipLayout old) => old.x != x; +} + +/// Paints the band as uosc does: background, the position line, the +/// uncached hatching, chapter diamonds, the times, the hover line. class UoscTimelinePainter extends CustomPainter { - final double played; - final Rect? Function(Size size) cache; - /// What [cache] depends on (repaint when it changes). - final Object cacheKey; + final UoscTimelineGeometry geometry; + + /// 0 hidden .. 1 shown (picks the hatching texture and its strength). + final double visibility; + + /// Start of the uncached part (to the end), as a fraction. + final double? uncached; + + /// Chapter starts, as fractions. + final List chapters; + final int? hoveredChapter; + + /// The hover line (the mouse, or the hovered chapter). final double? hoverX; - final bool hatched; + final double textOpacity; + final String? elapsed; + final String? remaining; + final String? cache; + final String? fontFamily; const UoscTimelinePainter({ - required this.played, - required this.cache, - required this.cacheKey, + required this.geometry, + this.visibility = 1, + this.uncached, + this.chapters = const [], + this.hoveredChapter, this.hoverX, - this.hatched = true, + this.textOpacity = 1, + this.elapsed, + this.remaining, + this.cache, + this.fontFamily, }); + double get played => geometry.progress; + @override void paint(Canvas canvas, Size size) { - final all = Offset.zero & size; - canvas.drawRect(all, Paint()..color = UoscStyle.bar); - final c = cache(size); - if (c != null) { - if (hatched) { - canvas.save(); - canvas.clipRect(c); - final stroke = Paint() - ..color = const Color(0x40FFFFFF) - ..strokeWidth = 1.5; - for (final (a, b) in hatchLines(c)) { - canvas.drawLine(a, b, stroke); - } - canvas.restore(); - } else { - canvas.drawRect(c, Paint()..color = const Color(0x40FFFFFF)); - } - } - canvas.drawRect(Rect.fromLTWH(0, 0, size.width * played, size.height), Paint()..color = UoscStyle.fill); + final g = geometry; + final w = size.width, bottom = size.height, top = g.top; + final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom); + // Background (`opacity.timeline`) around the position line. + final bg = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addRect(line); + canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity)); + canvas.drawRect(line, Paint()..color = Colors.white); + _hatch(canvas, w, top, bottom); + _chapters(canvas); + if (textOpacity > 0) _times(canvas, w, top, bottom, line); final x = hoverX; if (x != null) { - canvas.drawRect(Rect.fromLTWH(x - 0.5, 0, 1, size.height), Paint()..color = Colors.white70); + final onLine = line.left - 0.5 < x && x < line.right + 0.5; + canvas.drawRect( + Rect.fromLTRB(x - 0.5, top, x + 0.5, bottom), + Paint()..color = (onLine ? Colors.black : Colors.white).withValues(alpha: 0.33), + ); } } + /// uosc's texture "b" (shown) or "a" (hidden) at 80 px: "/" stripes + /// 1.6 px thick, white then black shifted by size / 24 (or / 28). + void _hatch(Canvas canvas, double w, double top, double bottom) { + final from = uncached; + if (from == null) return; + final ax = from * (geometry.width) < 0.5 ? 0.0 : geometry.x(from).floorToDouble(); + if (ax >= w) return; + final shown = visibility > 0; + const tile = 80.0, unit = tile / 100; + final period = (shown ? 100 / 12 : 100 / 14) * unit; + final phase = shown ? 0.0 : 5.727 * unit; + final offset = tile / (shown ? 24 : 28); + final h = bottom - top; + canvas.save(); + canvas.clipRect(Rect.fromLTRB(ax, top, w, bottom)); + void stripes(Color color, double opacity, double anchor) { + final paint = Paint() + ..color = color.withValues(alpha: opacity) + ..strokeWidth = 2 * unit; + // x + (y - bottom) = anchor + phase + k * period. + final base = anchor + phase; + final k0 = ((ax - h - base) / period).floor() - 1; + for (var k = k0; base + k * period <= w + 1; k++) { + final c = base + k * period; + canvas.drawLine(Offset(c, bottom), Offset(c + h, top), paint); + } + } + + stripes(Colors.white, 0.4 - 0.2 * visibility, 0); + stripes(Colors.black, 0.6 - 0.2 * visibility, offset); + canvas.restore(); + } + + void _chapters(Canvas canvas) { + final g = geometry; + final r = g.diamondRadius; + if (r <= 0) return; + void diamond(double fraction, double radius) { + final c = Offset(g.x(fraction), 0); + Path shape(double r) => Path() + ..moveTo(c.dx - r, c.dy) + ..lineTo(c.dx, c.dy - r) + ..lineTo(c.dx + r, c.dy) + ..lineTo(c.dx, c.dy + r) + ..close(); + final inner = shape(radius); + // A 1 px border outside the fill (`\bord`), the fill translucent. + final outer = shape(radius + UoscTimelineSpec.border * math.sqrt2); + canvas.drawPath(Path.combine(PathOperation.difference, outer, inner), Paint()..color = Colors.black); + canvas.drawPath(inner, Paint()..color = Colors.white.withValues(alpha: UoscTimelineSpec.chaptersOpacity)); + } + + for (var i = 0; i < chapters.length; i++) { + if (i != hoveredChapter) diamond(chapters[i], r); + } + final h = hoveredChapter; + if (h != null && h < chapters.length) diamond(chapters[h], r * 2); + } + + TextPainter _text(String s, double fontSize, Color color, {Paint? foreground}) => TextPainter( + text: TextSpan( + text: s, + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + height: 1, + color: foreground == null ? color : null, + foreground: foreground, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + /// Text on the band: white with a black outline, inverted where it + /// crosses the position line (uosc clips one copy to each). + void _label(Canvas canvas, String s, double fontSize, double opacity, Offset at, Rect line, Size size) { + void copy(Color fill, Color outline) { + _text( + s, + fontSize, + fill, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = UoscTimelineSpec.textBorder * 2 + ..strokeJoin = StrokeJoin.round + ..color = outline.withValues(alpha: opacity), + ).paint(canvas, at); + _text(s, fontSize, fill.withValues(alpha: opacity)).paint(canvas, at); + } + + canvas.save(); + canvas.clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.fromLTWH(-20, -20, size.width + 40, size.height + 40)) + ..addRect(line), + ); + copy(Colors.white, Colors.black); + canvas.restore(); + canvas.save(); + canvas.clipRect(line); + copy(Colors.black, Colors.white); + canvas.restore(); + } + + void _times(Canvas canvas, double w, double top, double bottom, Rect line) { + const fs = UoscTimelineSpec.fontSize, spacing = UoscTimelineSpec.spacing; + final size = Size(w, bottom); + final cy = top + (bottom - top) / 2; + final e = elapsed, r = remaining; + final ew = e == null ? 0.0 : _text(e, fs, Colors.white).width; + final rw = r == null ? 0.0 : _text(r, fs, Colors.white).width; + final c = cache; + if (c != null) { + const cfs = fs * 0.8, margin = 5.0; + final cw = _text(c, cfs, Colors.white).width; + var x = line.right + margin; + final minX = spacing + margin + ew, maxX = w - spacing - margin - rw; + if (x < minX) { + x = minX; + } else if (x + cw > maxX) { + x = maxX - cw; + } + _label(canvas, c, cfs, textOpacity * 0.6, Offset(x, cy - cfs / 2), line, size); + } + if (e != null) _label(canvas, e, fs, textOpacity, Offset(spacing, cy - fs / 2), line, size); + if (r != null) _label(canvas, r, fs, textOpacity, Offset(w - spacing - rw, cy - fs / 2), line, size); + } + @override - bool shouldRepaint(UoscTimelinePainter old) => - old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched; + bool shouldRepaint(UoscTimelinePainter old) => true; } diff --git a/apps/desktop/lib/src/controls/uosc/volume_flash.dart b/apps/desktop/lib/src/controls/uosc/volume_flash.dart new file mode 100644 index 0000000..d9827df --- /dev/null +++ b/apps/desktop/lib/src/controls/uosc/volume_flash.dart @@ -0,0 +1,128 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../volume.dart' show maxVolume; + +// uosc's volume slider (`elements/Volume.lua`) as it flashes on a volume +// change: a vertical band at the right edge, black at `opacity.slider` +// with a white gauge from the bottom, notches at 100 %, the value at the +// bottom (black on the gauge, white off it). + +/// The flashed uosc volume slider; size it with [UoscVolumeSlider.width] +/// and the height uosc gives it (see [UoscVolumeSlider.heightFor]). +class UoscVolumeSlider extends StatelessWidget { + /// 0..[maxVolume]. + final double volume; + const UoscVolumeSlider({super.key, required this.volume}); + + /// `volume_size`. + static const width = 40.0; + + /// uosc: min(size * 8, 80 % of the room between the bars), less the + /// mute button (one [width]) below it. + static double heightFor(double room) => math.min(width * 8, room * 0.8) - width; + + @override + Widget build(BuildContext context) => CustomPaint( + key: const ValueKey('uosc-volume-flash'), + painter: UoscVolumePainter( + volume: volume, + fontFamily: DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily, + ), + ); +} + +class UoscVolumePainter extends CustomPainter { + final double volume; + final String? fontFamily; + const UoscVolumePainter({required this.volume, this.fontFamily}); + + /// `volume_border`, `border_radius`. + static const border = 1.0; + static const radius = 4.0; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width, h = size.height; + if (w <= 0 || h <= w) return; + final v = volume.clamp(0.0, maxVolume); + final nudgeY = h - (h * (100 / maxVolume)).roundToDouble(); + final nudge = (w * 0.18).roundToDouble(); + final spacing = (w * 0.2).roundToDouble(); + Path notches(double inset) { + final n = nudge - inset; + return Path() + ..moveTo(0, nudgeY - nudge) + ..lineTo(inset + n, nudgeY) + ..lineTo(0, nudgeY + nudge) + ..close() + ..moveTo(w, nudgeY - nudge) + ..lineTo(w - inset - n, nudgeY) + ..lineTo(w, nudgeY + nudge) + ..close(); + } + + final bg = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(radius + border)); + final volumeY = border + (h - border * 2) * (1 - math.min(v / maxVolume, 1)); + final gauge = RRect.fromRectAndRadius( + Rect.fromLTRB(border, math.min(volumeY, h - border), w - border, h - border), + const Radius.circular(radius), + ); + // The notches cut into both. + canvas.save(); + canvas.clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addPath(notches(0), Offset.zero), + ); + canvas.drawPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRRect(bg) + ..addRRect(gauge), + Paint()..color = Colors.black.withValues(alpha: 0.9), + ); + canvas.restore(); + canvas.save(); + canvas.clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addPath(notches(border), Offset.zero), + ); + canvas.drawRRect(gauge, Paint()..color = Colors.white); + canvas.restore(); + + // round(volume * 10) / 10, as Lua prints it ("85", "85.5"). + final tenths = (v * 10).round(); + final text = tenths % 10 == 0 ? '${tenths ~/ 10}' : (tenths / 10).toString(); + final fontSize = (w * 0.6 - text.length * (w / 20)).roundToDouble(); + TextPainter label(Color color) => TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontFamily: fontFamily, fontSize: fontSize, height: 1, color: color), + ), + textDirection: TextDirection.ltr, + )..layout(); + final dark = label(Colors.black), light = label(Colors.white); + final at = Offset((w - dark.width) / 2, h - spacing - dark.height); + canvas.save(); + canvas.clipRRect(gauge); + dark.paint(canvas, at); + canvas.restore(); + canvas.save(); + canvas.clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addRRect(gauge), + ); + light.paint(canvas, at); + canvas.restore(); + } + + @override + bool shouldRepaint(UoscVolumePainter old) => old.volume != volume || old.fontFamily != fontFamily; +} diff --git a/apps/desktop/lib/src/controls/volume.dart b/apps/desktop/lib/src/controls/volume.dart index 88475fb..d5b8743 100644 --- a/apps/desktop/lib/src/controls/volume.dart +++ b/apps/desktop/lib/src/controls/volume.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../playback/playback_controller.dart' show PlaybackController; +import '../widgets/app_menu.dart' show AppGlass; /// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies. const maxVolume = PlaybackController.maxVolume * 1.0; @@ -10,9 +14,174 @@ const maxVolume = PlaybackController.maxVolume * 1.0; /// The part of the volume bar above 100 %. const boostColor = Color(0xFFFFB74D); +/// One wheel notch or arrow key. +const volumeStep = 5.0; + /// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume]. double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume); +/// Turns wheel deltas into volume steps: a mouse notch (a big delta) is +/// one step, small smooth-scroll deltas add up to one. Mostly horizontal +/// scrolls (Shift+wheel, sideways pans) are no steps. +class WheelSteps { + // Flutter makes a notch 53 px on Linux, 60 on Windows (3 lines). + static const _notch = 50.0; + double _sum = 0; + + /// Whether [e] is a vertical scroll (the only kind that steps). + static bool vertical(PointerSignalEvent e) => + e is PointerScrollEvent && e.scrollDelta.dy != 0 && e.scrollDelta.dy.abs() > e.scrollDelta.dx.abs(); + + /// +1 louder (wheel up), -1 quieter, 0 not a step yet. + int take(PointerScrollEvent e) { + final dy = e.scrollDelta.dy; + if (dy.abs() >= _notch) { + _sum = 0; + return dy < 0 ? 1 : -1; + } + if (_sum != 0 && _sum.sign != dy.sign) _sum = 0; + _sum += dy; + if (_sum.abs() < _notch) return 0; + final step = _sum < 0 ? 1 : -1; + _sum = 0; + return step; + } + + /// Claims [e] if it is a vertical scroll that nothing under the mouse + /// claimed first (a list, a menu), then calls [onStep] per step. + void handle(PointerSignalEvent e, ValueChanged onStep) { + if (!vertical(e)) return; + GestureBinding.instance.pointerSignalResolver.register(e, (event) { + final step = take(event as PointerScrollEvent); + if (step != 0) onStep(step); + }); + } +} + +/// The wheel anywhere over [child] steps the volume ([onStep]: +1 / -1), +/// unless something under the mouse scrolls (it claims the event first) +/// or [enabled] is off. +class VolumeWheel extends StatefulWidget { + final ValueChanged onStep; + final bool enabled; + final Widget child; + const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child}); + + @override + State createState() => _VolumeWheelState(); +} + +class _VolumeWheelState extends State { + final _steps = WheelSteps(); + + @override + Widget build(BuildContext context) => Listener( + onPointerSignal: (e) { + if (widget.enabled) _steps.handle(e, widget.onStep); + }, + child: widget.child, + ); +} + +/// Shows [builder] for a second after each [trigger] tick (a wheel or +/// key volume step), fading in and out; ignores the mouse. +class VolumeFlash extends StatefulWidget { + final ValueListenable trigger; + final WidgetBuilder builder; + const VolumeFlash({super.key, required this.trigger, required this.builder}); + + /// uosc's `flash_duration`. + static const hold = Duration(seconds: 1); + + @override + State createState() => _VolumeFlashState(); +} + +class _VolumeFlashState extends State { + bool _on = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + widget.trigger.addListener(_flash); + } + + @override + void didUpdateWidget(VolumeFlash old) { + super.didUpdateWidget(old); + if (old.trigger != widget.trigger) { + old.trigger.removeListener(_flash); + widget.trigger.addListener(_flash); + } + } + + @override + void dispose() { + widget.trigger.removeListener(_flash); + _timer?.cancel(); + super.dispose(); + } + + void _flash() { + if (!mounted) return; + setState(() => _on = true); + _timer?.cancel(); + _timer = Timer(VolumeFlash.hold, () { + if (mounted) setState(() => _on = false); + }); + } + + @override + Widget build(BuildContext context) => IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _on ? 1 : 0, + child: _on || _timer != null ? widget.builder(context) : const SizedBox.shrink(), + ), + ); +} + +/// The standard design's volume readout for [VolumeFlash]: a glass pill +/// (the player's pill look) with the icon, a [VolumeBar] and the +/// percentage. +class VolumeReadout extends StatelessWidget { + final double volume; + const VolumeReadout({super.key, required this.volume}); + + @override + Widget build(BuildContext context) { + final v = volume.clamp(0.0, maxVolume); + return AppGlass.pill( + key: const ValueKey('volume-readout'), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 14, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up), + size: 20, + color: v > 100 ? boostColor : Colors.white, + ), + const SizedBox(width: 8), + VolumeBar(volume: v, onChanged: (_) {}, width: 96), + const SizedBox(width: 10), + SizedBox( + width: 44, + child: Text( + '${v.round()} %', + textAlign: TextAlign.right, + style: const TextStyle(color: Colors.white, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]), + ), + ), + ], + ), + ), + ); + } +} + /// 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. @@ -61,22 +230,25 @@ class _VolumePainter extends CustomPainter { ..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); + 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; + 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 %. +/// wheel steps [volumeStep]. class VolumeButton extends StatefulWidget { final Player player; final FocusNode? focusNode; @@ -87,6 +259,7 @@ class VolumeButton extends StatefulWidget { } class _VolumeButtonState extends State { + final _steps = WheelSteps(); bool _hover = false; double _beforeMute = 100; @@ -102,11 +275,9 @@ class _VolumeButtonState extends State { 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)); - } - }, + // Claimed here, so the player's own wheel handler skips it. + onPointerSignal: (e) => + _steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 655081f..a3fe6e5 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -12,7 +12,7 @@ import 'update_indicator.dart'; const sessionShortcuts = [ ('Space', 'Toggle ready'), ('← / →', 'Seek back / forward 10 s'), - ('↑ / ↓', 'Volume up / down (up to 150 %)'), + ('↑ / ↓, wheel', 'Volume up / down (up to 150 %)'), ('Shift+→', 'Skip 1:30 (for everyone)'), ('PgUp / PgDn', 'Previous / next chapter'), ('F', 'Toggle fullscreen'), diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 8fa088f..3e041bc 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -14,6 +14,7 @@ import '../controls/player_settings.dart'; import '../controls/uosc/buttons.dart'; import '../controls/uosc/controls.dart'; import '../controls/uosc/timeline.dart'; +import '../controls/uosc/volume_flash.dart'; import '../controls/volume.dart'; import '../queue/queue_ui.dart'; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; @@ -109,6 +110,8 @@ class _SessionPlayerState extends State { // The mouse on a floating element, or the Up next popover open, keeps // the controls up. final _hold = ControlsHold(); + // Ticks on each wheel / key volume step: flashes the volume readout. + final _volumeFlash = ValueNotifier(0); late final void Function() _stopPopoverWatch; SessionCoordinator get _coord => SessionState.instance.coordinator; @@ -143,6 +146,7 @@ class _SessionPlayerState extends State { _noFocus.dispose(); _controlsShown.dispose(); _hold.dispose(); + _volumeFlash.dispose(); super.dispose(); } @@ -162,6 +166,14 @@ class _SessionPlayerState extends State { /// and applied live. A null quality keeps the saved one. void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality); + /// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓; + /// flashes the design's volume readout. + void _stepVolume(int step) { + final player = _coord.playback.player; + player.setVolume(stepVolume(player.state.volume, step * volumeStep)); + _volumeFlash.value++; + } + void _toggleChat() { _coord.markInput(); widget.onToggleChat(); @@ -239,8 +251,7 @@ class _SessionPlayerState extends State { return true; } if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) { - final player = coord.playback.player; - player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5)); + _stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1); return true; } if (key == LogicalKeyboardKey.arrowRight) { @@ -296,21 +307,28 @@ class _SessionPlayerState extends State { } final VideoControlsBuilder? controls = _full ? _sessionControls : null; final video = widget.videoBuilder?.call(context, controls) ?? _video(controls); - return Stack( - fit: StackFit.expand, - children: [ - video, - if (_full) ...[ - _progressLine(), - _blockedCard(), - _actionArea(), - if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], - ] else - _miniOverlay(), - // Sitting out: nothing is loaded; the card covers the - // empty player (which stays mounted for joining back). - if (_coord.selfOut.value) _sitOut(), - ], + // The wheel anywhere on the full player steps the volume + // (lists, menus and popovers under the mouse scroll instead). + return VolumeWheel( + enabled: _full, + onStep: _stepVolume, + child: Stack( + fit: StackFit.expand, + children: [ + video, + if (_full) ...[ + _progressLine(), + _blockedCard(), + _actionArea(), + if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], + _volumeReadout(), + ] else + _miniOverlay(), + // Sitting out: nothing is loaded; the card covers the + // empty player (which stays mounted for joining back). + if (_coord.selfOut.value) _sitOut(), + ], + ), ); }), ), @@ -698,6 +716,60 @@ class _SessionPlayerState extends State { ? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height : _barHeight + ChapterSeekBar.height; + /// The volume flash after a wheel or key step, in the design in use: + /// uosc's vertical slider at the right edge (centred between the top + /// buttons and the bottom controls, as uosc places it), else a pill + /// under the top bar. + Widget _volumeReadout() { + final playback = _coord.playback; + Widget withVolume(Widget Function(double v) build) => StreamBuilder( + stream: playback.player.stream.volume, + initialData: playback.player.state.volume, + builder: (context, v) => build(v.data ?? 100), + ); + return SignalBuilder(builder: (context) { + if (AppSettings.instance.mpvControls.value) { + const top = 12 + UoscControls.buttonRowHeight; + final bottom = _bottomControlsHeight(true); + return Positioned( + right: UoscVolumeSlider.width / 2, + top: top, + bottom: bottom, + width: UoscVolumeSlider.width, + child: LayoutBuilder(builder: (context, c) { + final h = UoscVolumeSlider.heightFor(c.maxHeight); + if (h <= UoscVolumeSlider.width) return const SizedBox.shrink(); + return Align( + // Centred with the mute button's room below, like uosc. + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width), + child: SizedBox( + height: h, + child: VolumeFlash( + trigger: _volumeFlash, + builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)), + ), + ), + ), + ); + }), + ); + } + return Positioned( + left: 0, + right: 0, + top: _topBarHeight + 8, + child: Center( + child: VolumeFlash( + trigger: _volumeFlash, + builder: (context) => withVolume((v) => VolumeReadout(volume: v)), + ), + ), + ); + }); + } + /// The action area: one band above the bottom controls for everything /// that floats there. The status pill (the ready prompt among it) /// centred; at the right edge the end cards, else the action pills diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart index 8547f15..334b847 100644 --- a/apps/desktop/test/uosc_controls_test.dart +++ b/apps/desktop/test/uosc_controls_test.dart @@ -8,6 +8,7 @@ import 'package:watch_together/src/controls/uosc/controls.dart'; import 'package:watch_together/src/controls/uosc/menu.dart'; import 'package:watch_together/src/controls/uosc/style.dart'; import 'package:watch_together/src/controls/uosc/timeline.dart'; +import 'package:watch_together/src/controls/volume.dart' show VolumeWheel; import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/playback/media_marks.dart'; import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode; @@ -76,74 +77,76 @@ void main() { expect(formatUoscTime(const Duration(seconds: -4)), '00:00'); }); - group('cache hatching math', () { - test('cached range runs from the position to the cache end', () { - const size = Size(1000, 30); - final r = cacheRect(size, position: m(6), buffered: m(9), duration: m(24))!; - expect(r.left, 250); - expect(r.right, 375); - expect(r.top, 0); - expect(r.bottom, 30); - expect(cacheRect(size, position: m(6), buffered: m(6), duration: m(24)), isNull); - expect(cacheRect(size, position: m(6), buffered: m(5), duration: m(24)), isNull, reason: 'behind'); - expect(cacheRect(size, position: m(6), buffered: m(9), duration: Duration.zero), isNull); - // Clamped to the bar. - expect(cacheRect(size, position: m(20), buffered: m(40), duration: m(24))!.right, 1000); + group('timeline math (uosc)', () { + test('uosc clock: trimmed by the duration, minus kept', () { + expect(uoscClock(m(4, 22), const Duration(hours: 1, minutes: 52)), '00:04:22'); + expect(uoscClock(-const Duration(hours: 1, minutes: 47, seconds: 30), const Duration(hours: 1, minutes: 52)), + '-01:47:30'); + expect(uoscClock(m(6), m(24)), '06:00'); + expect(uoscClock(-m(18), m(24)), '-18:00'); + expect(uoscClock(const Duration(seconds: 7), const Duration(seconds: 40)), '07'); + expect(uoscClock(Duration.zero, m(24)), '00:00'); }); - test('45° strokes, evenly spaced, covering the whole range', () { - const r = Rect.fromLTRB(100, 0, 160, 30); - final lines = hatchLines(r, spacing: 6); - for (final (a, b) in lines) { - expect(a.dy, r.bottom); - expect(b.dy, r.top); - expect(b.dx - a.dx, r.height, reason: '45°'); - // Every stroke crosses the range. - expect(b.dx, greaterThan(r.left)); - expect(a.dx, lessThan(r.right)); - } - for (var i = 1; i < lines.length; i++) { - expect(lines[i].$1.dx - lines[i - 1].$1.dx, 6); - } - // No gap at either edge: the first stroke starts left of the range's - // bottom-left corner diagonal, the last one within a step of the end. - expect(lines.first.$1.dx, lessThanOrEqualTo(r.left - r.height + 6)); - expect(lines.last.$1.dx, greaterThan(r.right - 6)); - expect(hatchLines(Rect.zero), isEmpty); + test('uncached part: from the cache end (or the position) to the end', () { + expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24); + expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end'); + expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown'); + expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end'); + expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull); + }); + + test('cache label: seconds ahead, under 60 s, not when cached to the end', () { + expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s'); + expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s'); + expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s'); + expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more'); + expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached'); + expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull); + }); + + test('geometry: times map around the position line', () { + const g = UoscTimelineGeometry(width: 1000, size: 40, progress: 0.5); + expect(g.lineLeft, 499); + expect(g.lineRight, 501); + expect(g.x(0), 0.5); + expect(g.x(1), 1000 - 0.5); + expect(g.x(0.75), greaterThan(g.lineRight), reason: 'past the line'); + expect(g.fractionAt(500), 0.5, reason: 'on the line: the position'); + expect(g.fractionAt(0), 0); + expect(g.fractionAt(1000), 1); + expect(g.fractionAt(g.x(0.25)), moreOrLessEquals(0.25, epsilon: 0.001)); + expect(g.fractionAt(g.x(0.75)), moreOrLessEquals(0.75, epsilon: 0.001)); + // Collapsing: the line widens to 20 px over the last eighth. + expect(UoscTimelineGeometry.sizeAt(1), 40); + expect(UoscTimelineGeometry.sizeAt(0), 2); + expect(UoscTimelineGeometry.lineWidthAt(40), 2); + expect(UoscTimelineGeometry.lineWidthAt(2), 20); }); }); group('timeline', () { - testWidgets('elapsed part filled, time boxes inside the bar', (tester) async { + testWidgets('position line, elapsed left, remaining right, cache label', (tester) async { await tester.pumpWidget(host(UoscTimeline( - position: m(6), duration: m(24), buffered: m(8), chapters: chapters, onSeek: (_) {}))); + position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {}))); final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); expect(bar.width, 800, reason: 'the whole width (the test window)'); expect(bar.height, UoscTimeline.height); final p = painter(tester); expect(p.played, 0.25); - expect(p.hatched, isTrue); - expect(p.cache(bar.size), cacheRect(bar.size, position: m(6), buffered: m(8), duration: m(24))); - expect(boxText(tester, 'uosc-time'), '06:00'); - expect(boxText(tester, 'uosc-duration'), '24:00'); - final time = tester.getRect(find.byKey(const ValueKey('uosc-time'))); - expect(time.left, greaterThanOrEqualTo(bar.left)); - expect(time.top, greaterThanOrEqualTo(bar.top)); - expect(time.bottom, lessThanOrEqualTo(bar.bottom)); - expect(time.center.dx, lessThan(bar.left + 80), reason: 'at the left'); + expect(p.geometry.lineRight - p.geometry.lineLeft, UoscTimelineSpec.lineWidth, reason: 'a line, no fill'); + expect(p.visibility, 1); + expect(p.uncached, 6.5 / 24); + expect(p.elapsed, '06:00'); + expect(p.remaining, '-18:00'); + expect(p.cache, '30s'); + expect(p.textOpacity, 1); }); - testWidgets('a diamond on the top edge at each chapter start but the first', (tester) async { + testWidgets('a diamond on the top edge at each chapter start', (tester) async { await tester.pumpWidget(host(UoscTimeline( position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))); - expect(find.byKey(const ValueKey('uosc-chapter-0')), findsNothing, reason: 'starts at 0'); - final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); - for (final c in chapters.skip(1)) { - final mark = tester.getCenter(find.byKey(ValueKey('uosc-chapter-${c.index}'))); - expect(mark.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5), - reason: c.title); - expect(mark.dy, moreOrLessEquals(bar.top, epsilon: 0.5), reason: 'on the top edge'); - } + expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]); }); testWidgets('hover: chapter title over the hover time, above the bar', (tester) async { @@ -151,8 +154,9 @@ void main() { position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))); final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); final mouse = await mouseAt(tester, Offset.zero); + final g = painter(tester).geometry; // 01:51 of 24:00: inside "Opening". - await mouse.moveTo(Offset(bar.left + bar.width * (111 / (24 * 60)), bar.center.dy)); + await mouse.moveTo(Offset(bar.left + g.x(111 / (24 * 60)) + 0.2, bar.center.dy)); await tester.pump(); expect(boxText(tester, 'uosc-hover-chapter'), 'Opening'); expect(boxText(tester, 'uosc-hover-time'), '01:51'); @@ -169,21 +173,34 @@ void main() { expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing); }); - testWidgets('hidden controls: a thin line with chapter ticks, no boxes, no input', (tester) async { + testWidgets('a press on a hovered diamond jumps to its chapter', (tester) async { + final seeks = []; + await tester.pumpWidget(host(UoscTimeline( + position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))); + final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); + final at = Offset(bar.left + painter(tester).geometry.x(2 / 24) + 3, bar.top + 2); + final mouse = await mouseAt(tester, at); + await mouse.moveTo(at); + await tester.pump(); + expect(painter(tester).hoveredChapter, 2); + expect(boxText(tester, 'uosc-hover-time'), '02:00'); + await mouse.down(at); + await mouse.up(); + await tester.pump(); + expect(seeks, [m(2)]); + }); + + testWidgets('hidden controls: the progress line, no text, no input', (tester) async { final seeks = []; await tester.pumpWidget(host(UoscTimeline( position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add))); final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); expect(bar.height, UoscTimeline.collapsedHeight); - expect(painter(tester).played, 0.5); - expect(painter(tester).hatched, isFalse); - expect(find.byKey(const ValueKey('uosc-chapter-1')), findsNothing); - for (final c in chapters.skip(1)) { - final tick = tester.getRect(find.byKey(ValueKey('uosc-chapter-tick-${c.index}'))); - expect(tick.center.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5)); - expect(tick.width, lessThanOrEqualTo(2)); - } - expect(find.byKey(const ValueKey('uosc-time')), findsNothing); + final p = painter(tester); + expect(p.played, 0.5); + expect(p.visibility, 0); + expect(p.geometry.lineWidth, UoscTimelineSpec.progressLineWidth); + expect(p.textOpacity, 0); final mouse = await mouseAt(tester, Offset.zero); await mouse.moveTo(bar.center); await tester.pump(); @@ -192,19 +209,26 @@ void main() { expect(seeks, isEmpty); }); - testWidgets('drag seeks on every move and on release', (tester) async { + testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async { final seeks = []; await tester.pumpWidget(host(UoscTimeline( position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))); final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline'))); - final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse); - expect(seeks, isEmpty, reason: 'press alone does not seek'); - await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy)); + final g = painter(tester).geometry; + final g0 = await tester.startGesture(Offset(bar.left + g.x(0.25), bar.center.dy), kind: PointerDeviceKind.mouse); + expect(seeks, hasLength(1), reason: 'the press seeks'); + expect(seeks.single.inSeconds, closeTo(6 * 60, 2)); + await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy)); await tester.pump(); - expect(boxText(tester, 'uosc-time'), '12:00', reason: 'follows the drag'); - await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy)); - await g.up(); - expect(seeks, [m(12), m(18), m(18)]); + expect(painter(tester).elapsed, uoscClock(seeks.last, m(24)), reason: 'follows the drag'); + await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy)); + await g0.up(); + await tester.pump(); + expect(seeks, hasLength(3), reason: 'release at the last move: no repeat'); + expect(painter(tester).played, moreOrLessEquals(seeks.last.inMilliseconds / m(24).inMilliseconds, epsilon: 0.01), + reason: 'shows the target until the position catches up'); + await tester.pump(const Duration(seconds: 3)); + expect(painter(tester).played, 1 / 24, reason: 'never confirmed: back to the position'); }); }); @@ -245,7 +269,9 @@ void main() { await mouse.moveTo(const Offset(420, 220)); await tester.pump(); expect(buttonsOpacity(tester), 1); - expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height); + await tester.pump(UoscTimeline.animation); + expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height, + reason: 'grown back (animated)'); }); testWidgets('a long drag keeps the controls up', (tester) async { @@ -341,9 +367,52 @@ void main() { await tester.sendEventToBinding(PointerScrollEvent( kind: PointerDeviceKind.mouse, position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))), - scrollDelta: const Offset(0, -20))); + scrollDelta: const Offset(0, -60))); await tester.pump(); - expect(got, [0, 80, 85]); + expect(got, [0, 80, 85], reason: 'a notch is one step'); + for (var i = 0; i < 3; i++) { + await tester.sendEventToBinding(PointerScrollEvent( + kind: PointerDeviceKind.mouse, + position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))), + scrollDelta: const Offset(0, 20))); + } + await tester.pump(); + expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step'); + }); + }); + + group('volume wheel', () { + testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async { + final steps = []; + final list = ScrollController(); + addTearDown(list.dispose); + await tester.pumpWidget(MaterialApp( + home: VolumeWheel( + onStep: steps.add, + child: Stack(children: [ + const Positioned.fill(child: ColoredBox(color: Colors.black)), + Positioned( + right: 0, + top: 0, + width: 200, + height: 200, + child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]), + ), + ]), + ), + )); + Future wheel(Offset at, Offset delta) => + tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta)); + await wheel(const Offset(100, 400), const Offset(0, -60)); + await wheel(const Offset(100, 400), const Offset(0, 60)); + expect(steps, [1, -1], reason: 'up louder, down quieter'); + await wheel(const Offset(100, 400), const Offset(60, 0)); + await wheel(const Offset(100, 400), const Offset(60, -10)); + expect(steps, [1, -1], reason: 'sideways'); + await wheel(const Offset(700, 100), const Offset(0, 60)); + await tester.pump(); + expect(steps, [1, -1], reason: 'the list took it'); + expect(list.offset, greaterThan(0)); }); });