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 createState() => _DebouncedSeekBarState(); } class _DebouncedSeekBarState extends ConsumerState { StreamSubscription? _posSub; StreamSubscription? _durSub; StreamSubscription? _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? _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? _watchDanmakuComments() { final state = ref.watch(danmakuSessionProvider).value; if (state is DanmakuSessionMatched && state.commentStatus == DanmakuCommentStatus.ready) { return state.comments; } return null; } DanmakuHeatmapData? _resolveHeatmap( List? 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 = []; 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); }