410 lines
12 KiB
Dart
410 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../core/contracts/danmaku.dart';
|
|
import '../../../core/infra/player_engine/player_engine.dart';
|
|
import '../../../core/media/danmaku_heatmap.dart';
|
|
import '../../../providers/danmaku_settings_provider.dart';
|
|
import '../../../shared/utils/format_utils.dart';
|
|
import '../services/danmaku/danmaku_session_notifier.dart';
|
|
|
|
|
|
const double _kSeekBarHeight = 28.0;
|
|
|
|
|
|
class DebouncedSeekBar extends ConsumerStatefulWidget {
|
|
final PlayerEngine player;
|
|
|
|
|
|
final void Function(Duration?)? onDragChanged;
|
|
|
|
const DebouncedSeekBar({super.key, required this.player, this.onDragChanged});
|
|
|
|
@override
|
|
ConsumerState<DebouncedSeekBar> createState() => _DebouncedSeekBarState();
|
|
}
|
|
|
|
class _DebouncedSeekBarState extends ConsumerState<DebouncedSeekBar> {
|
|
StreamSubscription<Duration>? _posSub;
|
|
StreamSubscription<Duration>? _durSub;
|
|
StreamSubscription<Duration>? _bufSub;
|
|
|
|
Duration _position = Duration.zero;
|
|
Duration _duration = Duration.zero;
|
|
Duration _buffer = Duration.zero;
|
|
|
|
bool _dragging = false;
|
|
double _dragFraction = 0.0;
|
|
bool _hovering = false;
|
|
double? _hoverFraction;
|
|
|
|
List<DanmakuComment>? _cachedComments;
|
|
double? _cachedDurationSec;
|
|
DanmakuHeatmapData? _cachedHeatmap;
|
|
|
|
PlayerEngine get _player => widget.player;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_position = _player.state.position;
|
|
_duration = _player.state.duration;
|
|
_buffer = _player.state.buffer;
|
|
_subscribeStreams();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant DebouncedSeekBar oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.player != widget.player) {
|
|
unawaited(_posSub?.cancel());
|
|
unawaited(_durSub?.cancel());
|
|
unawaited(_bufSub?.cancel());
|
|
_position = _player.state.position;
|
|
_duration = _player.state.duration;
|
|
_buffer = _player.state.buffer;
|
|
_subscribeStreams();
|
|
}
|
|
}
|
|
|
|
void _subscribeStreams() {
|
|
_posSub = _player.stream.position.listen((p) {
|
|
if (!_dragging && mounted) setState(() => _position = p);
|
|
});
|
|
_durSub = _player.stream.duration.listen((d) {
|
|
if (mounted) setState(() => _duration = d);
|
|
});
|
|
_bufSub = _player.stream.buffer.listen((b) {
|
|
if (mounted) setState(() => _buffer = b);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_posSub?.cancel();
|
|
_durSub?.cancel();
|
|
_bufSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
double _fraction(Duration d) {
|
|
final total = _duration.inMilliseconds;
|
|
if (total <= 0) return 0.0;
|
|
return (d.inMilliseconds / total).clamp(0.0, 1.0);
|
|
}
|
|
|
|
void _onDown(PointerDownEvent e, double width) {
|
|
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
|
setState(() {
|
|
_dragging = true;
|
|
_dragFraction = fraction;
|
|
});
|
|
widget.onDragChanged?.call(_duration * fraction);
|
|
}
|
|
|
|
void _onMove(PointerMoveEvent e, double width) {
|
|
if (!_dragging) return;
|
|
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
|
setState(() {
|
|
_dragFraction = fraction;
|
|
});
|
|
widget.onDragChanged?.call(_duration * fraction);
|
|
}
|
|
|
|
void _onUp(PointerUpEvent e, double width) {
|
|
if (!_dragging) return;
|
|
final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0);
|
|
final target = _duration * fraction;
|
|
unawaited(_player.seek(target));
|
|
setState(() {
|
|
_dragging = false;
|
|
_position = target;
|
|
_hovering = false;
|
|
_hoverFraction = null;
|
|
});
|
|
widget.onDragChanged?.call(null);
|
|
}
|
|
|
|
void _onCancel(PointerCancelEvent e) {
|
|
if (!_dragging) return;
|
|
setState(() {
|
|
_dragging = false;
|
|
_hovering = false;
|
|
_hoverFraction = null;
|
|
});
|
|
widget.onDragChanged?.call(null);
|
|
}
|
|
|
|
|
|
List<DanmakuComment>? _watchDanmakuComments() {
|
|
final state = ref.watch(danmakuSessionProvider).value;
|
|
if (state is DanmakuSessionMatched &&
|
|
state.commentStatus == DanmakuCommentStatus.ready) {
|
|
return state.comments;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
DanmakuHeatmapData? _resolveHeatmap(
|
|
List<DanmakuComment>? comments,
|
|
double durationSec,
|
|
bool enabled,
|
|
) {
|
|
if (!enabled || comments == null) {
|
|
_cachedComments = null;
|
|
_cachedDurationSec = null;
|
|
_cachedHeatmap = null;
|
|
return null;
|
|
}
|
|
if (identical(comments, _cachedComments) &&
|
|
_cachedDurationSec == durationSec) {
|
|
return _cachedHeatmap;
|
|
}
|
|
final h = computeHeatmap(comments: comments, durationSec: durationSec);
|
|
_cachedComments = comments;
|
|
_cachedDurationSec = durationSec;
|
|
_cachedHeatmap = h;
|
|
return h;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final posFrac = _dragging ? _dragFraction : _fraction(_position);
|
|
final bufFrac = _fraction(_buffer);
|
|
final active = _hovering || _dragging;
|
|
|
|
final showBubble = _dragging || (_hovering && _hoverFraction != null);
|
|
final bubbleFrac = _dragging ? _dragFraction : (_hoverFraction ?? 0.0);
|
|
final bubbleTime = _duration * bubbleFrac;
|
|
|
|
final heatmapEnabled = ref.watch(
|
|
danmakuSettingsProvider.select(
|
|
(v) => v.value?.panelSettings.heatmapEnabled ?? true,
|
|
),
|
|
);
|
|
final comments = heatmapEnabled ? _watchDanmakuComments() : null;
|
|
final durationSec = _duration.inMilliseconds / 1000.0;
|
|
final heatmap = _resolveHeatmap(comments, durationSec, heatmapEnabled);
|
|
final heatmapVisible = heatmap?.visible == true;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final w = constraints.maxWidth;
|
|
const bubbleWidth = 60.0;
|
|
const bubbleHalfWidth = bubbleWidth / 2;
|
|
final bubbleLeft = showBubble
|
|
? (w * bubbleFrac - bubbleHalfWidth).clamp(0.0, w - bubbleWidth)
|
|
: 0.0;
|
|
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
onEnter: (_) => setState(() => _hovering = true),
|
|
onHover: (event) {
|
|
final frac = (event.localPosition.dx / w).clamp(0.0, 1.0);
|
|
setState(() => _hoverFraction = frac);
|
|
},
|
|
onExit: (_) {
|
|
if (!_dragging) {
|
|
setState(() {
|
|
_hovering = false;
|
|
_hoverFraction = null;
|
|
});
|
|
}
|
|
},
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerDown: (e) => _onDown(e, w),
|
|
onPointerMove: (e) => _onMove(e, w),
|
|
onPointerUp: (e) => _onUp(e, w),
|
|
onPointerCancel: _onCancel,
|
|
child: SizedBox(
|
|
height: _kSeekBarHeight,
|
|
child: CustomPaint(
|
|
size: Size(w, _kSeekBarHeight),
|
|
painter: _SeekBarPainter(
|
|
position: posFrac,
|
|
buffer: bufFrac,
|
|
active: active,
|
|
heatmap: heatmapVisible ? heatmap : null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (showBubble)
|
|
Positioned(
|
|
bottom: _kSeekBarHeight + 6,
|
|
left: bubbleLeft,
|
|
child: IgnorePointer(
|
|
child: Container(
|
|
width: bubbleWidth,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xCC000000),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
formatDurationClock(bubbleTime),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeekBarPainter extends CustomPainter {
|
|
final double position;
|
|
final double buffer;
|
|
final bool active;
|
|
final DanmakuHeatmapData? heatmap;
|
|
|
|
const _SeekBarPainter({
|
|
required this.position,
|
|
required this.buffer,
|
|
required this.active,
|
|
this.heatmap,
|
|
});
|
|
|
|
|
|
static const double _heatmapBaselineY = 11.0;
|
|
static const double _heatmapMaxHeight = 10.0;
|
|
static const double _heatmapPixelsPerBucket = 4.0;
|
|
|
|
|
|
static const double _heatmapGamma = 0.6;
|
|
|
|
|
|
static const Color _heatmapLowColor = Color(0xCC82B1FF);
|
|
static const Color _heatmapHighColor = Color(0xFF2962FF);
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
_paintHeatmap(canvas, size);
|
|
_paintSeekBar(canvas, size);
|
|
}
|
|
|
|
void _paintHeatmap(Canvas canvas, Size size) {
|
|
final h = heatmap;
|
|
if (h == null || h.buckets.isEmpty || size.width <= 0) return;
|
|
|
|
final targetCount = (size.width / _heatmapPixelsPerBucket).floor().clamp(
|
|
1,
|
|
h.buckets.length,
|
|
);
|
|
final samples = downsampleHeatmap(h.buckets, targetCount);
|
|
if (samples.isEmpty) return;
|
|
|
|
final points = <Offset>[];
|
|
var peak = 0.0;
|
|
for (var i = 0; i < samples.length; i++) {
|
|
final shaped = math
|
|
.pow(samples[i].clamp(0.0, 1.0), _heatmapGamma)
|
|
.toDouble();
|
|
if (shaped > peak) peak = shaped;
|
|
final cx = samples.length == 1
|
|
? size.width / 2
|
|
: i / (samples.length - 1) * size.width;
|
|
points.add(Offset(cx, _heatmapBaselineY - _heatmapMaxHeight * shaped));
|
|
}
|
|
|
|
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
|
|
for (var i = 1; i < points.length; i++) {
|
|
linePath.lineTo(points[i].dx, points[i].dy);
|
|
}
|
|
|
|
final color = Color.lerp(_heatmapLowColor, _heatmapHighColor, peak)!;
|
|
|
|
final fillPath = Path.from(linePath)
|
|
..lineTo(points.last.dx, _heatmapBaselineY)
|
|
..lineTo(points.first.dx, _heatmapBaselineY)
|
|
..close();
|
|
canvas.drawPath(
|
|
fillPath,
|
|
Paint()
|
|
..style = PaintingStyle.fill
|
|
..color = color.withValues(alpha: color.a * 0.18),
|
|
);
|
|
|
|
canvas.drawPath(
|
|
linePath,
|
|
Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.4
|
|
..strokeJoin = StrokeJoin.round
|
|
..strokeCap = StrokeCap.round
|
|
..color = color,
|
|
);
|
|
}
|
|
|
|
void _paintSeekBar(Canvas canvas, Size size) {
|
|
final trackH = active ? 5.0 : 3.0;
|
|
final cy = size.height / 2;
|
|
final top = cy - trackH / 2;
|
|
final bottom = cy + trackH / 2;
|
|
final r = Radius.circular(trackH / 2);
|
|
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(Rect.fromLTRB(0, top, size.width, bottom), r),
|
|
Paint()..color = const Color(0x3DFFFFFF),
|
|
);
|
|
|
|
if (buffer > 0) {
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTRB(0, top, size.width * buffer, bottom),
|
|
r,
|
|
),
|
|
Paint()..color = const Color(0x3DFFFFFF),
|
|
);
|
|
}
|
|
|
|
if (position > 0) {
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTRB(0, top, size.width * position, bottom),
|
|
r,
|
|
),
|
|
Paint()..color = const Color(0xFFFF0000),
|
|
);
|
|
}
|
|
|
|
if (active) {
|
|
canvas.drawCircle(
|
|
Offset(size.width * position, cy),
|
|
6.4,
|
|
Paint()..color = const Color(0xFFFF0000),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_SeekBarPainter old) =>
|
|
position != old.position ||
|
|
buffer != old.buffer ||
|
|
active != old.active ||
|
|
!identical(heatmap, old.heatmap);
|
|
}
|