Files
2026-07-14 11:11:36 +08:00

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);
}