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
+1106
-313
No files matched your search
@@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
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
|
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||||
/// [maxVolume], the wheel steps 5 %.
|
/// [maxVolume], the wheel steps [volumeStep].
|
||||||
class UoscVolumeButton extends StatefulWidget {
|
class UoscVolumeButton extends StatefulWidget {
|
||||||
/// 0..[maxVolume].
|
/// 0..[maxVolume].
|
||||||
final double volume;
|
final double volume;
|
||||||
@@ -180,6 +179,7 @@ class UoscVolumeButton extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||||
|
final _steps = WheelSteps();
|
||||||
double _beforeMute = 100;
|
double _beforeMute = 100;
|
||||||
bool _hover = false;
|
bool _hover = false;
|
||||||
|
|
||||||
@@ -191,11 +191,8 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
|||||||
onEnter: (_) => setState(() => _hover = true),
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
onExit: (_) => setState(() => _hover = false),
|
onExit: (_) => setState(() => _hover = false),
|
||||||
child: Listener(
|
child: Listener(
|
||||||
onPointerSignal: (e) {
|
// Claimed here, so the player's own wheel handler skips it.
|
||||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
onPointerSignal: (e) => _steps.handle(e, (step) => widget.onChanged(stepVolume(v, step * volumeStep))),
|
||||||
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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
|
// bottom with a row of flat buttons above it, the title top left and a
|
||||||
// corner widget top right.
|
// corner widget top right.
|
||||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||||
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
// they fade out, the timeline shrinks to uosc's progress line (gone when
|
||||||
// line when hidden" is off) and the cursor hides. Our own frame: no
|
// "Progress line when hidden" is off) and the cursor hides. Our own frame: no
|
||||||
// media_kit controls state to reach into.
|
// media_kit controls state to reach into.
|
||||||
|
|
||||||
class UoscControls extends StatefulWidget {
|
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.
|
// owner runs it: flat black panels, white icons, a muted blue fill.
|
||||||
|
|
||||||
abstract final class UoscStyle {
|
abstract final class UoscStyle {
|
||||||
/// Timeline background and the boxes on it.
|
/// The active menu row.
|
||||||
static const bar = Color(0xCC000000);
|
|
||||||
/// Played part of the timeline; also the active menu row.
|
|
||||||
static const fill = Color(0xFF4C6A8E);
|
static const fill = Color(0xFF4C6A8E);
|
||||||
/// Menus and tooltips.
|
/// Menus and tooltips.
|
||||||
static const panel = Color(0xF0101010);
|
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
|
/// A small dark box with one line of text (the timeline's hover time and
|
||||||
/// hover chapter title).
|
/// chapter title).
|
||||||
class UoscBox extends StatelessWidget {
|
class UoscBox extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -6,6 +7,66 @@ import '../../playback/media_marks.dart';
|
|||||||
import 'controls.dart';
|
import 'controls.dart';
|
||||||
import 'style.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).
|
/// Fraction of [total] at [d], 0..1 (0 without a duration).
|
||||||
double timelineFraction(Duration d, Duration total) {
|
double timelineFraction(Duration d, Duration total) {
|
||||||
final t = total.inMicroseconds;
|
final t = total.inMicroseconds;
|
||||||
@@ -13,36 +74,104 @@ double timelineFraction(Duration d, Duration total) {
|
|||||||
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
return (d.inMicroseconds / t).clamp(0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cached-ahead part of a timeline of [size]: from the playing
|
/// Where the uncached part starts (it runs to the end), as a fraction;
|
||||||
/// position to the end of mpv's cache. Null when nothing is cached ahead.
|
/// null when nothing known is uncached. We know only the end of mpv's
|
||||||
Rect? cacheRect(Size size, {required Duration position, required Duration buffered, required Duration duration}) {
|
/// cache ahead ([buffered]); without it (0) uosc draws no ranges either,
|
||||||
final from = timelineFraction(position, duration) * size.width;
|
/// and a cache that reaches the end (within uosc's 0.5 s) leaves none.
|
||||||
final to = timelineFraction(buffered, duration) * size.width;
|
double? uncachedFrom({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
if (to - from < 0.5) return null;
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
return Rect.fromLTRB(from, 0, to, size.height);
|
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,
|
/// The cache label next to the position ("12s"): the seconds buffered
|
||||||
/// [spacing] apart along x, across all of [r] (the painter clips them).
|
/// ahead, shown while under [UoscTimelineSpec.bufferedThreshold] and the
|
||||||
/// The first starts half a step in, so none just grazes the corner.
|
/// cache does not reach the end (uosc drops it at eof-cached).
|
||||||
List<(Offset, Offset)> hatchLines(Rect r, {double spacing = 6}) {
|
String? cacheLabel({required Duration position, required Duration buffered, required Duration duration}) {
|
||||||
if (r.isEmpty || spacing <= 0) return const [];
|
if (duration <= Duration.zero || buffered <= Duration.zero) return null;
|
||||||
final h = r.height;
|
if (duration - buffered <= const Duration(milliseconds: 500)) return null;
|
||||||
return [
|
final ahead = buffered > position ? buffered - position : Duration.zero;
|
||||||
for (var x = r.left - h + spacing / 2; x < r.right; x += spacing) (Offset(x, r.bottom), Offset(x + h, r.top)),
|
if (ahead >= UoscTimelineSpec.bufferedThreshold) return null;
|
||||||
];
|
return '${(ahead.inMilliseconds / 1000).round()}s';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The uosc timeline: full width, elapsed part filled, the cached range
|
/// The band's geometry at one size, as uosc lays it out: the position
|
||||||
/// ahead hatched, chapter starts as white diamonds on the top edge, the
|
/// line takes [lineWidth] pixels, and times map around it (`t2x`) so no
|
||||||
/// current time in a box at the left and the duration at the right.
|
/// time lies inside the line.
|
||||||
/// Hover shows the chapter title over the hover time. When the controls
|
class UoscTimelineGeometry {
|
||||||
/// hide ([expanded] false) it shrinks to a thin line with chapter ticks.
|
final double width;
|
||||||
/// 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 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 {
|
class UoscTimeline extends StatefulWidget {
|
||||||
final Duration position;
|
final Duration position;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
|
|
||||||
/// End of the cached range ([PlaybackController.buffered]).
|
/// End of the cached range ([PlaybackController.buffered]).
|
||||||
final Duration buffered;
|
final Duration buffered;
|
||||||
final List<MediaChapter> chapters;
|
final List<MediaChapter> chapters;
|
||||||
@@ -58,24 +187,32 @@ class UoscTimeline extends StatefulWidget {
|
|||||||
required this.onSeek,
|
required this.onSeek,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const height = 30.0;
|
static const height = UoscTimelineSpec.size + UoscTimelineSpec.border;
|
||||||
static const collapsedHeight = 2.0;
|
static const collapsedHeight = UoscTimelineSpec.progressSize + UoscTimelineSpec.border;
|
||||||
|
|
||||||
|
/// uosc's `animation_duration`.
|
||||||
|
static const animation = Duration(milliseconds: 100);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UoscTimeline> createState() => _UoscTimelineState();
|
State<UoscTimeline> createState() => _UoscTimelineState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UoscTimelineState extends State<UoscTimeline> {
|
class _UoscTimelineState extends State<UoscTimeline> {
|
||||||
double? _hoverX;
|
Offset? _hover;
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
double _dragFraction = 0;
|
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);
|
Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0);
|
||||||
|
|
||||||
void _seekTo(double x, double width) {
|
void _send(Duration d) {
|
||||||
if (widget.duration <= Duration.zero || width <= 0) return;
|
if (d == _lastSent) return;
|
||||||
_dragFraction = (x / width).clamp(0.0, 1.0);
|
_lastSent = d;
|
||||||
widget.onSeek(_at(_dragFraction));
|
widget.onSeek(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drag(bool on) {
|
void _drag(bool on) {
|
||||||
@@ -84,204 +221,425 @@ class _UoscTimelineState extends State<UoscTimeline> {
|
|||||||
UoscControls.maybeOf(context)?.hold(on);
|
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
|
@override
|
||||||
void didUpdateWidget(UoscTimeline old) {
|
void didUpdateWidget(UoscTimeline old) {
|
||||||
super.didUpdateWidget(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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pendingTimer?.cancel();
|
||||||
// A drag cut short by a rebuild must not keep the controls up.
|
// A drag cut short by a rebuild must not keep the controls up.
|
||||||
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
if (_dragging) UoscControls.maybeOf(context)?.hold(false);
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||||
final width = constraints.maxWidth;
|
return LayoutBuilder(
|
||||||
final expanded = widget.expanded;
|
builder: (context, constraints) {
|
||||||
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
|
final width = constraints.maxWidth;
|
||||||
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
|
return TweenAnimationBuilder<double>(
|
||||||
final hoverX = expanded ? _hoverX : null;
|
tween: Tween(end: widget.expanded ? 1 : 0),
|
||||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
duration: UoscTimeline.animation,
|
||||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
builder: (context, visibility, _) => _band(width, visibility, font),
|
||||||
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,
|
|
||||||
];
|
Widget _band(double width, double visibility, String? font) {
|
||||||
final bar = SizedBox(
|
final expanded = widget.expanded;
|
||||||
key: const ValueKey('uosc-timeline'),
|
final duration = widget.duration;
|
||||||
width: width,
|
final size = UoscTimelineGeometry.sizeAt(visibility);
|
||||||
height: h,
|
final progress = _dragging ? _dragFraction : (_pending ?? timelineFraction(widget.position, duration));
|
||||||
child: Stack(
|
final shown = _at(progress);
|
||||||
clipBehavior: Clip.none,
|
final geo = UoscTimelineGeometry(
|
||||||
children: [
|
width: width,
|
||||||
Positioned.fill(
|
size: size,
|
||||||
child: CustomPaint(
|
progress: progress,
|
||||||
painter: UoscTimelinePainter(
|
lineWidth: UoscTimelineGeometry.lineWidthAt(size),
|
||||||
played: played,
|
);
|
||||||
cache: (size) => cacheRect(size,
|
final marks = duration > Duration.zero ? widget.chapters : const <MediaChapter>[];
|
||||||
position: widget.position, buffered: widget.buffered, duration: widget.duration),
|
final hover = expanded ? _hover : null;
|
||||||
cacheKey: (widget.position, widget.buffered, widget.duration),
|
final hovered = _hoveredChapter(geo, marks, hover);
|
||||||
hoverX: _dragging ? null : hoverX,
|
final hoverFraction = hovered != null
|
||||||
hatched: expanded,
|
? 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}'),
|
if (!expanded) return IgnorePointer(child: bar);
|
||||||
left: width * timelineFraction(c.start, widget.duration) - 4,
|
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||||
top: -4,
|
return MouseRegion(
|
||||||
width: 8,
|
cursor: SystemMouseCursors.click,
|
||||||
height: 8,
|
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
child: IgnorePointer(
|
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||||
child: Transform.rotate(
|
onExit: (_) {
|
||||||
angle: math.pi / 4,
|
if (!_dragging) setState(() => _hover = null);
|
||||||
child: Container(
|
},
|
||||||
margin: const EdgeInsets.all(1.2),
|
child: Listener(
|
||||||
decoration: BoxDecoration(
|
behavior: HitTestBehavior.opaque,
|
||||||
color: Colors.white,
|
onPointerDown: (e) {
|
||||||
border: Border.all(color: Colors.black54, width: 0.6),
|
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);
|
||||||
else
|
_settle(f);
|
||||||
Positioned(
|
_send(marks[chapter].start);
|
||||||
key: ValueKey('uosc-chapter-tick-${c.index}'),
|
setState(() {});
|
||||||
left: width * timelineFraction(c.start, widget.duration) - 1,
|
return;
|
||||||
top: -2,
|
}
|
||||||
width: 2,
|
_pending = null;
|
||||||
height: h + 2,
|
_dragFraction = geo.fractionAt(at.dx);
|
||||||
child: const IgnorePointer(child: ColoredBox(color: Colors.white70)),
|
_drag(true);
|
||||||
),
|
_send(_at(_dragFraction));
|
||||||
if (expanded) ...[
|
},
|
||||||
Positioned(
|
onPointerMove: (e) {
|
||||||
left: 4,
|
if (!_dragging) return;
|
||||||
top: 0,
|
final at = clampTo(e.localPosition);
|
||||||
bottom: 0,
|
setState(() {
|
||||||
child: IgnorePointer(
|
_hover = at;
|
||||||
child: Center(
|
_dragFraction = geo.fractionAt(at.dx);
|
||||||
child: UoscBox(formatUoscTime(_at(played), withHours: withHours),
|
});
|
||||||
key: const ValueKey('uosc-time'), color: UoscStyle.bar),
|
_send(_at(_dragFraction));
|
||||||
),
|
},
|
||||||
),
|
onPointerUp: (e) {
|
||||||
),
|
if (!_dragging) return;
|
||||||
Positioned(
|
_send(_at(_dragFraction));
|
||||||
right: 4,
|
_settle(_dragFraction);
|
||||||
top: 0,
|
final p = e.localPosition;
|
||||||
bottom: 0,
|
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
|
||||||
child: IgnorePointer(
|
_drag(false);
|
||||||
child: Center(
|
},
|
||||||
child: UoscBox(formatUoscTime(widget.duration, withHours: withHours),
|
onPointerCancel: (_) => _drag(false),
|
||||||
key: const ValueKey('uosc-duration'), color: UoscStyle.bar),
|
child: 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background, cached range (hatched, or a faint band when collapsed),
|
/// The hover tooltips: the time centred on [x], the chapter title over
|
||||||
/// the played part and the hover line.
|
/// 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 {
|
class UoscTimelinePainter extends CustomPainter {
|
||||||
final double played;
|
final UoscTimelineGeometry geometry;
|
||||||
final Rect? Function(Size size) cache;
|
|
||||||
/// What [cache] depends on (repaint when it changes).
|
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
||||||
final Object cacheKey;
|
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 double? hoverX;
|
||||||
final bool hatched;
|
final double textOpacity;
|
||||||
|
final String? elapsed;
|
||||||
|
final String? remaining;
|
||||||
|
final String? cache;
|
||||||
|
final String? fontFamily;
|
||||||
const UoscTimelinePainter({
|
const UoscTimelinePainter({
|
||||||
required this.played,
|
required this.geometry,
|
||||||
required this.cache,
|
this.visibility = 1,
|
||||||
required this.cacheKey,
|
this.uncached,
|
||||||
|
this.chapters = const [],
|
||||||
|
this.hoveredChapter,
|
||||||
this.hoverX,
|
this.hoverX,
|
||||||
this.hatched = true,
|
this.textOpacity = 1,
|
||||||
|
this.elapsed,
|
||||||
|
this.remaining,
|
||||||
|
this.cache,
|
||||||
|
this.fontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
double get played => geometry.progress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final all = Offset.zero & size;
|
final g = geometry;
|
||||||
canvas.drawRect(all, Paint()..color = UoscStyle.bar);
|
final w = size.width, bottom = size.height, top = g.top;
|
||||||
final c = cache(size);
|
final line = Rect.fromLTRB(g.lineLeft, top, g.lineRight, bottom);
|
||||||
if (c != null) {
|
// Background (`opacity.timeline`) around the position line.
|
||||||
if (hatched) {
|
final bg = Path()
|
||||||
canvas.save();
|
..fillType = PathFillType.evenOdd
|
||||||
canvas.clipRect(c);
|
..addRect(Offset.zero & size)
|
||||||
final stroke = Paint()
|
..addRect(line);
|
||||||
..color = const Color(0x40FFFFFF)
|
canvas.drawPath(bg, Paint()..color = Colors.black.withValues(alpha: UoscTimelineSpec.opacity));
|
||||||
..strokeWidth = 1.5;
|
canvas.drawRect(line, Paint()..color = Colors.white);
|
||||||
for (final (a, b) in hatchLines(c)) {
|
_hatch(canvas, w, top, bottom);
|
||||||
canvas.drawLine(a, b, stroke);
|
_chapters(canvas);
|
||||||
}
|
if (textOpacity > 0) _times(canvas, w, top, bottom, line);
|
||||||
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 x = hoverX;
|
final x = hoverX;
|
||||||
if (x != null) {
|
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
|
@override
|
||||||
bool shouldRepaint(UoscTimelinePainter old) =>
|
bool shouldRepaint(UoscTimelinePainter old) => true;
|
||||||
old.played != played || old.cacheKey != cacheKey || old.hoverX != hoverX || old.hatched != hatched;
|
|
||||||
}
|
}
|
||||||
@@ -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/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
import '../playback/playback_controller.dart' show PlaybackController;
|
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.
|
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
|
||||||
const maxVolume = PlaybackController.maxVolume * 1.0;
|
const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||||
@@ -10,9 +14,174 @@ const maxVolume = PlaybackController.maxVolume * 1.0;
|
|||||||
/// The part of the volume bar above 100 %.
|
/// The part of the volume bar above 100 %.
|
||||||
const boostColor = Color(0xFFFFB74D);
|
const boostColor = Color(0xFFFFB74D);
|
||||||
|
|
||||||
|
/// One wheel notch or arrow key.
|
||||||
|
const volumeStep = 5.0;
|
||||||
|
|
||||||
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||||
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.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
|
/// 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
|
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
|
||||||
/// it.
|
/// it.
|
||||||
@@ -61,22 +230,25 @@ class _VolumePainter extends CustomPainter {
|
|||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||||
final full = x(100);
|
final full = x(100);
|
||||||
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint()
|
canvas.drawLine(
|
||||||
..color = trackColor
|
Offset(full, y - 5),
|
||||||
..strokeWidth = 1.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 > 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);
|
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);
|
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_VolumePainter old) =>
|
bool shouldRepaint(_VolumePainter old) => old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||||
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume for the standard bar (instead of media_kit's, which stops at
|
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||||
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||||
/// wheel steps 5 %.
|
/// wheel steps [volumeStep].
|
||||||
class VolumeButton extends StatefulWidget {
|
class VolumeButton extends StatefulWidget {
|
||||||
final Player player;
|
final Player player;
|
||||||
final FocusNode? focusNode;
|
final FocusNode? focusNode;
|
||||||
@@ -87,6 +259,7 @@ class VolumeButton extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VolumeButtonState extends State<VolumeButton> {
|
class _VolumeButtonState extends State<VolumeButton> {
|
||||||
|
final _steps = WheelSteps();
|
||||||
bool _hover = false;
|
bool _hover = false;
|
||||||
double _beforeMute = 100;
|
double _beforeMute = 100;
|
||||||
|
|
||||||
@@ -102,11 +275,9 @@ class _VolumeButtonState extends State<VolumeButton> {
|
|||||||
onEnter: (_) => setState(() => _hover = true),
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
onExit: (_) => setState(() => _hover = false),
|
onExit: (_) => setState(() => _hover = false),
|
||||||
child: Listener(
|
child: Listener(
|
||||||
onPointerSignal: (e) {
|
// Claimed here, so the player's own wheel handler skips it.
|
||||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
onPointerSignal: (e) =>
|
||||||
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
_steps.handle(e, (step) => player.setVolume(stepVolume(player.state.volume, step * volumeStep))),
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'update_indicator.dart';
|
|||||||
const sessionShortcuts = [
|
const sessionShortcuts = [
|
||||||
('Space', 'Toggle ready'),
|
('Space', 'Toggle ready'),
|
||||||
('← / →', 'Seek back / forward 10 s'),
|
('← / →', 'Seek back / forward 10 s'),
|
||||||
('↑ / ↓', 'Volume up / down (up to 150 %)'),
|
('↑ / ↓, wheel', 'Volume up / down (up to 150 %)'),
|
||||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||||
('PgUp / PgDn', 'Previous / next chapter'),
|
('PgUp / PgDn', 'Previous / next chapter'),
|
||||||
('F', 'Toggle fullscreen'),
|
('F', 'Toggle fullscreen'),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../controls/player_settings.dart';
|
|||||||
import '../controls/uosc/buttons.dart';
|
import '../controls/uosc/buttons.dart';
|
||||||
import '../controls/uosc/controls.dart';
|
import '../controls/uosc/controls.dart';
|
||||||
import '../controls/uosc/timeline.dart';
|
import '../controls/uosc/timeline.dart';
|
||||||
|
import '../controls/uosc/volume_flash.dart';
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.dart';
|
||||||
import '../queue/queue_ui.dart';
|
import '../queue/queue_ui.dart';
|
||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
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 mouse on a floating element, or the Up next popover open, keeps
|
||||||
// the controls up.
|
// the controls up.
|
||||||
final _hold = ControlsHold();
|
final _hold = ControlsHold();
|
||||||
|
// Ticks on each wheel / key volume step: flashes the volume readout.
|
||||||
|
final _volumeFlash = ValueNotifier(0);
|
||||||
late final void Function() _stopPopoverWatch;
|
late final void Function() _stopPopoverWatch;
|
||||||
|
|
||||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||||
@@ -143,6 +146,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_controlsShown.dispose();
|
_controlsShown.dispose();
|
||||||
_hold.dispose();
|
_hold.dispose();
|
||||||
|
_volumeFlash.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +166,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
/// and applied live. A null quality keeps the saved one.
|
/// and applied live. A null quality keeps the saved one.
|
||||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
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() {
|
void _toggleChat() {
|
||||||
_coord.markInput();
|
_coord.markInput();
|
||||||
widget.onToggleChat();
|
widget.onToggleChat();
|
||||||
@@ -239,8 +251,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||||
final player = coord.playback.player;
|
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||||
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowRight) {
|
if (key == LogicalKeyboardKey.arrowRight) {
|
||||||
@@ -296,21 +307,28 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
||||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||||
return Stack(
|
// The wheel anywhere on the full player steps the volume
|
||||||
fit: StackFit.expand,
|
// (lists, menus and popovers under the mouse scroll instead).
|
||||||
children: [
|
return VolumeWheel(
|
||||||
video,
|
enabled: _full,
|
||||||
if (_full) ...[
|
onStep: _stepVolume,
|
||||||
_progressLine(),
|
child: Stack(
|
||||||
_blockedCard(),
|
fit: StackFit.expand,
|
||||||
_actionArea(),
|
children: [
|
||||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
video,
|
||||||
] else
|
if (_full) ...[
|
||||||
_miniOverlay(),
|
_progressLine(),
|
||||||
// Sitting out: nothing is loaded; the card covers the
|
_blockedCard(),
|
||||||
// empty player (which stays mounted for joining back).
|
_actionArea(),
|
||||||
if (_coord.selfOut.value) _sitOut(),
|
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
|
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||||
: _barHeight + ChapterSeekBar.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
|
/// The action area: one band above the bottom controls for everything
|
||||||
/// that floats there. The status pill (the ready prompt among it)
|
/// that floats there. The status pill (the ready prompt among it)
|
||||||
/// centred; at the right edge the end cards, else the action pills
|
/// 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/menu.dart';
|
||||||
import 'package:watch_together/src/controls/uosc/style.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/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/jellyfin/track_prefs.dart';
|
||||||
import 'package:watch_together/src/playback/media_marks.dart';
|
import 'package:watch_together/src/playback/media_marks.dart';
|
||||||
import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode;
|
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');
|
expect(formatUoscTime(const Duration(seconds: -4)), '00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
group('cache hatching math', () {
|
group('timeline math (uosc)', () {
|
||||||
test('cached range runs from the position to the cache end', () {
|
test('uosc clock: trimmed by the duration, minus kept', () {
|
||||||
const size = Size(1000, 30);
|
expect(uoscClock(m(4, 22), const Duration(hours: 1, minutes: 52)), '00:04:22');
|
||||||
final r = cacheRect(size, position: m(6), buffered: m(9), duration: m(24))!;
|
expect(uoscClock(-const Duration(hours: 1, minutes: 47, seconds: 30), const Duration(hours: 1, minutes: 52)),
|
||||||
expect(r.left, 250);
|
'-01:47:30');
|
||||||
expect(r.right, 375);
|
expect(uoscClock(m(6), m(24)), '06:00');
|
||||||
expect(r.top, 0);
|
expect(uoscClock(-m(18), m(24)), '-18:00');
|
||||||
expect(r.bottom, 30);
|
expect(uoscClock(const Duration(seconds: 7), const Duration(seconds: 40)), '07');
|
||||||
expect(cacheRect(size, position: m(6), buffered: m(6), duration: m(24)), isNull);
|
expect(uoscClock(Duration.zero, m(24)), '00:00');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('45° strokes, evenly spaced, covering the whole range', () {
|
test('uncached part: from the cache end (or the position) to the end', () {
|
||||||
const r = Rect.fromLTRB(100, 0, 160, 30);
|
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
|
||||||
final lines = hatchLines(r, spacing: 6);
|
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
|
||||||
for (final (a, b) in lines) {
|
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
|
||||||
expect(a.dy, r.bottom);
|
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
|
||||||
expect(b.dy, r.top);
|
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||||
expect(b.dx - a.dx, r.height, reason: '45°');
|
});
|
||||||
// Every stroke crosses the range.
|
|
||||||
expect(b.dx, greaterThan(r.left));
|
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
|
||||||
expect(a.dx, lessThan(r.right));
|
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');
|
||||||
for (var i = 1; i < lines.length; i++) {
|
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
|
||||||
expect(lines[i].$1.dx - lines[i - 1].$1.dx, 6);
|
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');
|
||||||
// No gap at either edge: the first stroke starts left of the range's
|
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
|
||||||
// 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));
|
test('geometry: times map around the position line', () {
|
||||||
expect(hatchLines(Rect.zero), isEmpty);
|
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', () {
|
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(
|
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')));
|
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||||
expect(bar.width, 800, reason: 'the whole width (the test window)');
|
expect(bar.width, 800, reason: 'the whole width (the test window)');
|
||||||
expect(bar.height, UoscTimeline.height);
|
expect(bar.height, UoscTimeline.height);
|
||||||
final p = painter(tester);
|
final p = painter(tester);
|
||||||
expect(p.played, 0.25);
|
expect(p.played, 0.25);
|
||||||
expect(p.hatched, isTrue);
|
expect(p.geometry.lineRight - p.geometry.lineLeft, UoscTimelineSpec.lineWidth, reason: 'a line, no fill');
|
||||||
expect(p.cache(bar.size), cacheRect(bar.size, position: m(6), buffered: m(8), duration: m(24)));
|
expect(p.visibility, 1);
|
||||||
expect(boxText(tester, 'uosc-time'), '06:00');
|
expect(p.uncached, 6.5 / 24);
|
||||||
expect(boxText(tester, 'uosc-duration'), '24:00');
|
expect(p.elapsed, '06:00');
|
||||||
final time = tester.getRect(find.byKey(const ValueKey('uosc-time')));
|
expect(p.remaining, '-18:00');
|
||||||
expect(time.left, greaterThanOrEqualTo(bar.left));
|
expect(p.cache, '30s');
|
||||||
expect(time.top, greaterThanOrEqualTo(bar.top));
|
expect(p.textOpacity, 1);
|
||||||
expect(time.bottom, lessThanOrEqualTo(bar.bottom));
|
|
||||||
expect(time.center.dx, lessThan(bar.left + 80), reason: 'at the left');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
await tester.pumpWidget(host(UoscTimeline(
|
||||||
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||||
expect(find.byKey(const ValueKey('uosc-chapter-0')), findsNothing, reason: 'starts at 0');
|
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hover: chapter title over the hover time, above the bar', (tester) async {
|
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: (_) {})));
|
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||||
final mouse = await mouseAt(tester, Offset.zero);
|
final mouse = await mouseAt(tester, Offset.zero);
|
||||||
|
final g = painter(tester).geometry;
|
||||||
// 01:51 of 24:00: inside "Opening".
|
// 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();
|
await tester.pump();
|
||||||
expect(boxText(tester, 'uosc-hover-chapter'), 'Opening');
|
expect(boxText(tester, 'uosc-hover-chapter'), 'Opening');
|
||||||
expect(boxText(tester, 'uosc-hover-time'), '01:51');
|
expect(boxText(tester, 'uosc-hover-time'), '01:51');
|
||||||
@@ -169,21 +173,34 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing);
|
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>[];
|
final seeks = <Duration>[];
|
||||||
await tester.pumpWidget(host(UoscTimeline(
|
await tester.pumpWidget(host(UoscTimeline(
|
||||||
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
|
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')));
|
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
||||||
expect(bar.height, UoscTimeline.collapsedHeight);
|
expect(bar.height, UoscTimeline.collapsedHeight);
|
||||||
expect(painter(tester).played, 0.5);
|
final p = painter(tester);
|
||||||
expect(painter(tester).hatched, isFalse);
|
expect(p.played, 0.5);
|
||||||
expect(find.byKey(const ValueKey('uosc-chapter-1')), findsNothing);
|
expect(p.visibility, 0);
|
||||||
for (final c in chapters.skip(1)) {
|
expect(p.geometry.lineWidth, UoscTimelineSpec.progressLineWidth);
|
||||||
final tick = tester.getRect(find.byKey(ValueKey('uosc-chapter-tick-${c.index}')));
|
expect(p.textOpacity, 0);
|
||||||
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 mouse = await mouseAt(tester, Offset.zero);
|
final mouse = await mouseAt(tester, Offset.zero);
|
||||||
await mouse.moveTo(bar.center);
|
await mouse.moveTo(bar.center);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -192,19 +209,26 @@ void main() {
|
|||||||
expect(seeks, isEmpty);
|
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>[];
|
final seeks = <Duration>[];
|
||||||
await tester.pumpWidget(host(UoscTimeline(
|
await tester.pumpWidget(host(UoscTimeline(
|
||||||
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||||
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
|
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);
|
final g = painter(tester).geometry;
|
||||||
expect(seeks, isEmpty, reason: 'press alone does not seek');
|
final g0 = await tester.startGesture(Offset(bar.left + g.x(0.25), bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||||
await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
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();
|
await tester.pump();
|
||||||
expect(boxText(tester, 'uosc-time'), '12:00', reason: 'follows the drag');
|
expect(painter(tester).elapsed, uoscClock(seeks.last, m(24)), reason: 'follows the drag');
|
||||||
await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||||
await g.up();
|
await g0.up();
|
||||||
expect(seeks, [m(12), m(18), m(18)]);
|
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 mouse.moveTo(const Offset(420, 220));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(buttonsOpacity(tester), 1);
|
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 {
|
testWidgets('a long drag keeps the controls up', (tester) async {
|
||||||
@@ -341,9 +367,52 @@ void main() {
|
|||||||
await tester.sendEventToBinding(PointerScrollEvent(
|
await tester.sendEventToBinding(PointerScrollEvent(
|
||||||
kind: PointerDeviceKind.mouse,
|
kind: PointerDeviceKind.mouse,
|
||||||
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
|
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
|
||||||
scrollDelta: const Offset(0, -20)));
|
scrollDelta: const Offset(0, -60)));
|
||||||
await tester.pump();
|
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