Initial commit
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user