uosc timeline ported from uosc (hatching on the uncached part, line marker, remaining time, buffered label, diamonds); mouse wheel sets the volume anywhere on the video, with a volume readout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8585fb78e
commit
9572e8457d
9 files changed
+1055
-262
No files matched your search
@@ -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<UoscBadgeButton> {
|
||||
}
|
||||
|
||||
/// 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<UoscVolumeButton> {
|
||||
final _steps = WheelSteps();
|
||||
double _beforeMute = 100;
|
||||
bool _hover = false;
|
||||
|
||||
@@ -191,11 +191,8 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||
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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MediaChapter> 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<UoscTimeline> createState() => _UoscTimelineState();
|
||||
}
|
||||
|
||||
class _UoscTimelineState extends State<UoscTimeline> {
|
||||
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<UoscTimeline> {
|
||||
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<MediaChapter> 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 font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return TweenAnimationBuilder<double>(
|
||||
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 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 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 <MediaChapter>[];
|
||||
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;
|
||||
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,
|
||||
];
|
||||
// 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: h,
|
||||
height: geo.height,
|
||||
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,
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final c in marks)
|
||||
if (expanded)
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
key: ValueKey('uosc-chapter-${c.index}'),
|
||||
left: width * timelineFraction(c.start, widget.duration) - 4,
|
||||
top: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: geo.height + timestampGap,
|
||||
height: 160,
|
||||
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,
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TooltipLayout(x: cursorX),
|
||||
children: [
|
||||
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
|
||||
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
|
||||
key: const ValueKey('uosc-hover-time')),
|
||||
],
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
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) {
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
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) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
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<double> 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<int> 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<int> onStep;
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
const VolumeWheel({super.key, required this.onStep, this.enabled = true, required this.child});
|
||||
|
||||
@override
|
||||
State<VolumeWheel> createState() => _VolumeWheelState();
|
||||
}
|
||||
|
||||
class _VolumeWheelState extends State<VolumeWheel> {
|
||||
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<int> 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<VolumeFlash> createState() => _VolumeFlashState();
|
||||
}
|
||||
|
||||
class _VolumeFlashState extends State<VolumeFlash> {
|
||||
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()
|
||||
canvas.drawLine(
|
||||
Offset(full, y - 5),
|
||||
Offset(full, y + 5),
|
||||
Paint()
|
||||
..color = trackColor
|
||||
..strokeWidth = 1.5);
|
||||
..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<VolumeButton> {
|
||||
final _steps = WheelSteps();
|
||||
bool _hover = false;
|
||||
double _beforeMute = 100;
|
||||
|
||||
@@ -102,11 +275,9 @@ class _VolumeButtonState extends State<VolumeButton> {
|
||||
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: [
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<SessionPlayer> {
|
||||
// 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<SessionPlayer> {
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
_hold.dispose();
|
||||
_volumeFlash.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -162,6 +166,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// 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<SessionPlayer> {
|
||||
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,7 +307,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||
return Stack(
|
||||
// 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,
|
||||
@@ -305,12 +321,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_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<SessionPlayer> {
|
||||
? 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<double>(
|
||||
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
|
||||
|
||||
@@ -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 = <Duration>[];
|
||||
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 = <Duration>[];
|
||||
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 = <Duration>[];
|
||||
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 = <int>[];
|
||||
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<void> 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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user