forked from Foxyz/sync_replay_project
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: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 width = constraints.maxWidth;
|
||||
final expanded = widget.expanded;
|
||||
final h = expanded ? UoscTimeline.height : UoscTimeline.collapsedHeight;
|
||||
final played = _dragging ? _dragFraction : timelineFraction(widget.position, widget.duration);
|
||||
final hoverX = expanded ? _hoverX : null;
|
||||
final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width);
|
||||
final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt);
|
||||
final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title;
|
||||
final withHours = widget.duration >= const Duration(hours: 1);
|
||||
final marks = [
|
||||
for (final c in widget.chapters)
|
||||
if (c.start > Duration.zero && widget.duration > Duration.zero) c,
|
||||
];
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('uosc-timeline'),
|
||||
width: width,
|
||||
height: h,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: UoscTimelinePainter(
|
||||
played: played,
|
||||
cache: (size) => cacheRect(size,
|
||||
position: widget.position, buffered: widget.buffered, duration: widget.duration),
|
||||
cacheKey: (widget.position, widget.buffered, widget.duration),
|
||||
hoverX: _dragging ? null : hoverX,
|
||||
hatched: expanded,
|
||||
final font = DefaultTextStyle.of(context).style.fontFamily ?? Theme.of(context).textTheme.bodyMedium?.fontFamily;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return TweenAnimationBuilder<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 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;
|
||||
// Text fades out just before it would overflow the shrinking band.
|
||||
const hideBelow = UoscTimelineSpec.fontSize * 0.8;
|
||||
final textOpacity = (size - hideBelow).clamp(0.0, hideBelow / 2) / (hideBelow / 2);
|
||||
final radius = geo.diamondRadius;
|
||||
final timestampGap = UoscTimelineSpec.tooltipGap + (hovered != null ? radius * 2 : radius).roundToDouble();
|
||||
final painter = UoscTimelinePainter(
|
||||
geometry: geo,
|
||||
visibility: visibility,
|
||||
uncached: uncachedFrom(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
chapters: [for (final c in marks) timelineFraction(c.start, duration)],
|
||||
hoveredChapter: hovered,
|
||||
hoverX: cursorX,
|
||||
textOpacity: textOpacity,
|
||||
elapsed: duration > Duration.zero ? uoscClock(shown, duration) : null,
|
||||
remaining: duration > Duration.zero ? uoscClock(shown - duration, duration) : null,
|
||||
cache: cacheLabel(position: widget.position, buffered: widget.buffered, duration: duration),
|
||||
fontFamily: font,
|
||||
);
|
||||
final bar = SizedBox(
|
||||
key: const ValueKey('uosc-timeline'),
|
||||
width: width,
|
||||
height: geo.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(child: CustomPaint(painter: painter)),
|
||||
),
|
||||
if (hoverAt != null && cursorX != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: geo.height + timestampGap,
|
||||
height: 160,
|
||||
child: IgnorePointer(
|
||||
child: CustomMultiChildLayout(
|
||||
delegate: _TooltipLayout(x: cursorX),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: #time,
|
||||
child: UoscBox(
|
||||
uoscClock(hoverAt, duration),
|
||||
key: const ValueKey('uosc-hover-time'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle,
|
||||
),
|
||||
),
|
||||
if (hoverTitle.isNotEmpty)
|
||||
LayoutId(
|
||||
id: #chapter,
|
||||
child: UoscBox(
|
||||
hoverTitle,
|
||||
key: const ValueKey('uosc-hover-chapter'),
|
||||
color: Colors.black,
|
||||
style: _tipStyle.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final c in marks)
|
||||
if (expanded)
|
||||
Positioned(
|
||||
key: ValueKey('uosc-chapter-${c.index}'),
|
||||
left: width * timelineFraction(c.start, widget.duration) - 4,
|
||||
top: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1.2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.black54, width: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
key: ValueKey('uosc-chapter-tick-${c.index}'),
|
||||
left: width * timelineFraction(c.start, widget.duration) - 1,
|
||||
top: -2,
|
||||
width: 2,
|
||||
height: h + 2,
|
||||
child: const IgnorePointer(child: ColoredBox(color: Colors.white70)),
|
||||
),
|
||||
if (expanded) ...[
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: UoscBox(formatUoscTime(_at(played), withHours: withHours),
|
||||
key: const ValueKey('uosc-time'), color: UoscStyle.bar),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: UoscBox(formatUoscTime(widget.duration, withHours: withHours),
|
||||
key: const ValueKey('uosc-duration'), color: UoscStyle.bar),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hoverAt != null)
|
||||
Positioned(
|
||||
// Kept clear of the window edges (the tip is centred).
|
||||
left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)),
|
||||
bottom: h + 8,
|
||||
child: IgnorePointer(
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (hoverTitle.isNotEmpty) UoscBox(hoverTitle, key: const ValueKey('uosc-hover-chapter')),
|
||||
UoscBox(formatUoscTime(hoverAt, withHours: withHours),
|
||||
key: const ValueKey('uosc-hover-time')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onHover: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)),
|
||||
onExit: (_) => setState(() => _hoverX = null),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
_dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0);
|
||||
_drag(true);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
setState(() => _hoverX = e.localPosition.dx.clamp(0, width));
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
_seekTo(e.localPosition.dx, width);
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
});
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!expanded) return IgnorePointer(child: bar);
|
||||
Offset clampTo(Offset p) => Offset(p.dx.clamp(0.0, width), p.dy);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onHover: (e) => setState(() => _hover = clampTo(e.localPosition)),
|
||||
onExit: (_) {
|
||||
if (!_dragging) setState(() => _hover = null);
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
if (duration <= Duration.zero || width <= 0) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
final chapter = _hoveredChapter(geo, marks, at);
|
||||
_lastSent = null;
|
||||
if (chapter != null) {
|
||||
final f = timelineFraction(marks[chapter].start, duration);
|
||||
_settle(f);
|
||||
_send(marks[chapter].start);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_pending = null;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
_drag(true);
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
if (!_dragging) return;
|
||||
final at = clampTo(e.localPosition);
|
||||
setState(() {
|
||||
_hover = at;
|
||||
_dragFraction = geo.fractionAt(at.dx);
|
||||
});
|
||||
_send(_at(_dragFraction));
|
||||
},
|
||||
onPointerUp: (e) {
|
||||
if (!_dragging) return;
|
||||
_send(_at(_dragFraction));
|
||||
_settle(_dragFraction);
|
||||
final p = e.localPosition;
|
||||
if (p.dx < 0 || p.dx > width || p.dy < 0 || p.dy > geo.height) _hover = null;
|
||||
_drag(false);
|
||||
},
|
||||
onPointerCancel: (_) => _drag(false),
|
||||
child: bar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _tipStyle = TextStyle(color: UoscStyle.text, fontSize: UoscTimelineSpec.fontSize, height: 1);
|
||||
}
|
||||
|
||||
/// Background, cached range (hatched, or a faint band when collapsed),
|
||||
/// the played part and the hover line.
|
||||
/// The hover tooltips: the time centred on [x], the chapter title over
|
||||
/// it; each kept [UoscTimelineSpec.tooltipGap] inside the band's ends.
|
||||
class _TooltipLayout extends MultiChildLayoutDelegate {
|
||||
final double x;
|
||||
_TooltipLayout({required this.x});
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
const gap = UoscTimelineSpec.tooltipGap;
|
||||
double left(double w) => (x - w / 2).clamp(gap, math.max(gap, size.width - gap - w));
|
||||
final loose = BoxConstraints.loose(size);
|
||||
final time = layoutChild(#time, loose);
|
||||
final timeTop = size.height - time.height;
|
||||
positionChild(#time, Offset(left(time.width), timeTop));
|
||||
if (hasChild(#chapter)) {
|
||||
final chapter = layoutChild(#chapter, loose);
|
||||
positionChild(#chapter, Offset(left(chapter.width), timeTop - gap - chapter.height));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_TooltipLayout old) => old.x != x;
|
||||
}
|
||||
|
||||
/// Paints the band as uosc does: background, the position line, the
|
||||
/// uncached hatching, chapter diamonds, the times, the hover line.
|
||||
class UoscTimelinePainter extends CustomPainter {
|
||||
final double played;
|
||||
final Rect? Function(Size size) cache;
|
||||
/// What [cache] depends on (repaint when it changes).
|
||||
final Object cacheKey;
|
||||
final UoscTimelineGeometry geometry;
|
||||
|
||||
/// 0 hidden .. 1 shown (picks the hatching texture and its strength).
|
||||
final double visibility;
|
||||
|
||||
/// Start of the uncached part (to the end), as a fraction.
|
||||
final double? uncached;
|
||||
|
||||
/// Chapter starts, as fractions.
|
||||
final List<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;
|
||||
}
|
||||
Reference in new issue
Block a user