refa: segment progressbar

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-01-18 22:21:48 +08:00
parent 395893fc7d
commit 68464e4e34
6 changed files with 464 additions and 296 deletions

View File

@@ -365,12 +365,15 @@ class RenderProgressBar extends RenderBox {
_thumbGlowColor = thumbGlowColor,
_thumbGlowRadius = thumbGlowRadius,
_paintThumbGlow = thumbGlowRadius > thumbRadius,
_thumbCanPaintOutsideBar = thumbCanPaintOutsideBar {
_drag = _EagerHorizontalDragGestureRecognizer()
..onStart = _onDragStart
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _finishDrag;
_thumbCanPaintOutsideBar = thumbCanPaintOutsideBar,
_hitTestSelf = onDragStart != null {
if (onDragStart != null) {
_drag = _EagerHorizontalDragGestureRecognizer()
..onStart = _onDragStart
..onUpdate = _onDragUpdate
..onEnd = _onDragEnd
..onCancel = _finishDrag;
}
if (!_userIsDraggingThumb) {
_progress = progress;
_thumbValue = _proportionOfTotal(_progress);
@@ -380,6 +383,7 @@ class RenderProgressBar extends RenderBox {
@override
void dispose() {
_drag?.dispose();
_drag = null;
super.dispose();
}
@@ -659,8 +663,9 @@ class RenderProgressBar extends RenderBox {
@override
double computeMaxIntrinsicHeight(double width) => _heightWhenNoLabels();
final bool _hitTestSelf;
@override
bool hitTestSelf(Offset position) => true;
bool hitTestSelf(Offset position) => _hitTestSelf;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {

View File

@@ -1,23 +1,43 @@
import 'package:flutter/material.dart';
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
class Segment {
import 'package:flutter/foundation.dart' show listEquals, kDebugMode;
import 'package:flutter/gestures.dart' show TapGestureRecognizer;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show BoxHitTestEntry;
sealed class BaseSegment {
final double start;
final double end;
final Color color;
final String? title;
final String? url;
final int? from;
final int? to;
Segment(
this.start,
this.end,
this.color, [
this.title,
this.url,
this.from,
this.to,
]);
BaseSegment({
required this.start,
required this.end,
});
}
class Segment extends BaseSegment {
final Color color;
Segment({
required super.start,
required super.end,
required this.color,
});
@override
bool operator ==(Object other) {
@@ -25,9 +45,38 @@ class Segment {
return true;
}
if (other is Segment) {
return start == other.start && end == other.end && color == other.color;
}
return false;
}
@override
int get hashCode => Object.hash(start, end, color);
}
class ViewPointSegment extends BaseSegment {
final String? title;
final String? url;
final int? from;
final int? to;
ViewPointSegment({
required super.start,
required super.end,
this.title,
this.url,
this.from,
this.to,
});
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is ViewPointSegment) {
return start == other.start &&
end == other.end &&
color == other.color &&
title == other.title &&
url == other.url &&
from == other.from &&
@@ -37,116 +86,284 @@ class Segment {
}
@override
int get hashCode => Object.hash(start, end, color, title, url, from, to);
int get hashCode => Object.hash(start, end, title, url, from, to);
}
class SegmentProgressBar extends CustomPainter {
final List<Segment> segmentColors;
double? _defHeight;
SegmentProgressBar({
required this.segmentColors,
class SegmentProgressBar extends BaseSegmentProgressBar<Segment> {
const SegmentProgressBar({
super.key,
super.height = 3.5,
required super.segments,
});
@override
void paint(Canvas canvas, Size size) {
RenderObject createRenderObject(BuildContext context) {
return RenderProgressBar(
height: height,
segments: segments,
);
}
}
class RenderProgressBar extends BaseRenderProgressBar<Segment> {
RenderProgressBar({
required super.height,
required super.segments,
});
@override
void paint(PaintingContext context, Offset offset) {
final size = this.size;
final canvas = context.canvas;
final paint = Paint()..style = PaintingStyle.fill;
for (int i = 0; i < segmentColors.length; i++) {
final item = segmentColors[i];
paint.color = item.color;
for (final segment in segments) {
paint.color = segment.color;
final segmentStart = segment.start * size.width;
final segmentEnd = segment.end * size.width;
if (segmentEnd > segmentStart ||
(segmentEnd == segmentStart && segmentStart > 0)) {
canvas.drawRect(
Rect.fromLTWH(
segmentStart,
0,
segmentEnd == segmentStart ? 2 : segmentEnd - segmentStart,
size.height,
),
paint,
);
}
}
}
}
class ViewPointSegmentProgressBar
extends BaseSegmentProgressBar<ViewPointSegment> {
const ViewPointSegmentProgressBar({
super.key,
super.height = 3.5,
required super.segments,
this.onSeek,
});
final ValueSetter<Duration>? onSeek;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderViewPointProgressBar(
height: height,
segments: segments,
onSeek: onSeek,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderViewPointProgressBar renderObject,
) {
renderObject
..height = height
..segments = segments
..onSeek = onSeek;
}
}
class RenderViewPointProgressBar
extends BaseRenderProgressBar<ViewPointSegment> {
RenderViewPointProgressBar({
required super.height,
required super.segments,
ValueSetter<Duration>? onSeek,
}) : _onSeek = onSeek,
_hitTestSelf = onSeek != null {
if (onSeek != null) {
_tapGestureRecognizer = TapGestureRecognizer()..onTapUp = _onTapUp;
}
}
static const double barHeight = 15.0;
@override
Size computeDryLayout(BoxConstraints constraints) {
return constraints.constrain(Size(constraints.maxWidth, barHeight));
}
@override
void paint(PaintingContext context, Offset offset) {
final size = this.size;
final canvas = context.canvas;
final paint = Paint()..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, barHeight),
paint..color = Colors.grey[600]!.withValues(alpha: 0.45),
);
paint.color = Colors.black.withValues(alpha: 0.5);
for (int index = 0; index < segments.length; index++) {
final isFirst = index == 0;
final item = segments[index];
final segmentStart = item.start * size.width;
final segmentEnd = item.end * size.width;
if (segmentEnd > segmentStart ||
(segmentEnd == segmentStart && segmentStart > 0)) {
if (item.title != null) {
double fontSize = 10;
double fontSize = 10;
_defHeight ??=
(TextPainter(
text: TextSpan(
text: item.title,
style: TextStyle(
fontSize: fontSize,
),
),
textDirection: TextDirection.ltr,
)..layout()).height +
2;
TextPainter getTextPainter() => TextPainter(
text: TextSpan(
text: item.title,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
),
TextPainter getTextPainter() => TextPainter(
text: TextSpan(
text: item.title,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1,
),
strutStyle: StrutStyle(leading: 0, height: 1, fontSize: fontSize),
textDirection: TextDirection.ltr,
)..layout();
),
strutStyle: StrutStyle(leading: 0, height: 1, fontSize: fontSize),
textDirection: TextDirection.ltr,
)..layout();
TextPainter textPainter = getTextPainter();
TextPainter textPainter = getTextPainter();
late double prevStart;
if (i != 0) {
prevStart = segmentColors[i - 1].start * size.width;
}
double width = i == 0 ? segmentStart : segmentStart - prevStart;
while (textPainter.width > width - 2 && fontSize >= 2) {
fontSize -= 0.5;
textPainter = getTextPainter();
}
if (i == 0) {
canvas.drawRect(
Rect.fromLTRB(
0,
-_defHeight!,
size.width,
0,
),
Paint()..color = Colors.grey[600]!.withValues(alpha: 0.45),
);
}
canvas.drawRect(
Rect.fromLTWH(
segmentStart,
-_defHeight!,
segmentEnd == segmentStart ? 2 : segmentEnd - segmentStart,
size.height + _defHeight!,
),
paint,
);
double textX = i == 0
? (segmentStart - textPainter.width) / 2
: (segmentStart - prevStart - textPainter.width) / 2 +
prevStart +
1;
double textY = (-_defHeight! - textPainter.height) / 2;
textPainter.paint(canvas, Offset(textX, textY));
} else {
canvas.drawRect(
Rect.fromLTWH(
segmentStart,
0,
segmentEnd == segmentStart ? 2 : segmentEnd - segmentStart,
size.height,
),
paint,
);
late double prevStart;
if (!isFirst) {
prevStart = segments[index - 1].start * size.width;
}
final width = isFirst ? segmentStart : segmentStart - prevStart;
while (textPainter.width > width - 2 && fontSize >= 2) {
fontSize -= 0.5;
textPainter.dispose();
textPainter = getTextPainter();
}
canvas.drawRect(
Rect.fromLTWH(
segmentStart,
0,
segmentEnd == segmentStart ? 2 : segmentEnd - segmentStart,
barHeight + height,
),
paint,
);
final textX = isFirst
? (segmentStart - textPainter.width) / 2
: (segmentStart - prevStart - textPainter.width) / 2 +
prevStart +
1;
final textY = (barHeight - textPainter.height) / 2;
textPainter.paint(canvas, Offset(textX, textY));
}
}
}
ValueSetter<Duration>? _onSeek;
set onSeek(ValueSetter<Duration>? value) {
if (_onSeek == value) {
return;
}
_onSeek = value;
}
TapGestureRecognizer? _tapGestureRecognizer;
@override
bool shouldRepaint(SegmentProgressBar oldDelegate) {
return segmentColors != oldDelegate.segmentColors;
void dispose() {
_onSeek = null;
_tapGestureRecognizer
?..onTapUp = null
..dispose();
_tapGestureRecognizer = null;
super.dispose();
}
final bool _hitTestSelf;
@override
bool hitTestSelf(Offset position) => _hitTestSelf;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (event is PointerDownEvent) {
_tapGestureRecognizer?.addPointer(event);
}
}
void _onTapUp(TapUpDetails details) {
try {
final seg = details.localPosition.dx / size.width;
final item = _segments
.where((item) => item.start >= seg)
.reduce((a, b) => a.start < b.start ? a : b);
if (item.from case final from?) {
_onSeek?.call(Duration(seconds: from));
}
// if (kDebugMode) debugPrint('${item.title},,${item.from}');
} catch (e) {
if (kDebugMode) rethrow;
}
}
}
abstract class BaseSegmentProgressBar<T extends BaseSegment>
extends LeafRenderObjectWidget {
const BaseSegmentProgressBar({
super.key,
this.height = 3.5,
required this.segments,
});
final double height;
final List<T> segments;
@override
void updateRenderObject(
BuildContext context,
BaseRenderProgressBar renderObject,
) {
renderObject
..height = height
..segments = segments;
}
}
class BaseRenderProgressBar<T extends BaseSegment> extends RenderBox {
BaseRenderProgressBar({
required double height,
required List<T> segments,
ValueSetter<int>? onSeek,
}) : _height = height,
_segments = segments;
double _height;
double get height => _height;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
List<T> _segments;
List<T> get segments => _segments;
set segments(List<T> value) {
if (listEquals(_segments, value)) return;
_segments = value;
markNeedsPaint();
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return constraints.constrain(Size(constraints.maxWidth, height));
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -838,7 +838,11 @@ class VideoDetailController extends GetxController
segmentList.map((e) {
double start = (e.segment.first / duration).clamp(0.0, 1.0);
double end = (e.segment.second / duration).clamp(0.0, 1.0);
return Segment(start, end, _getColor(e.segmentType));
return Segment(
start: start,
end: end,
color: _getColor(e.segmentType),
);
}),
);
@@ -1453,7 +1457,7 @@ class VideoDetailController extends GetxController
late final Map<int, String> vttSubtitles = {};
late final RxInt vttSubtitlesIndex = (-1).obs;
late final RxBool showVP = true.obs;
late final RxList<Segment> viewPointList = <Segment>[].obs;
late final RxList<ViewPointSegment> viewPointList = <ViewPointSegment>[].obs;
// 设定字幕轨道
Future<void> setSubtitle(int index) async {
@@ -1575,14 +1579,13 @@ class VideoDetailController extends GetxController
0.0,
1.0,
);
return Segment(
start,
start,
Colors.black.withValues(alpha: 0.5),
item.content,
item.imgUrl,
item.from,
item.to,
return ViewPointSegment(
start: start,
end: start,
title: item.content,
url: item.imgUrl,
from: item.from,
to: item.to,
);
}).toList();
} catch (_) {}

View File

@@ -100,7 +100,7 @@ class _ViewPointsPageState extends State<ViewPointsPage>
),
itemCount: videoDetailController.viewPointList.length,
itemBuilder: (context, index) {
Segment segment = videoDetailController.viewPointList[index];
final segment = videoDetailController.viewPointList[index];
if (currentIndex == -1 && segment.from != null && segment.to != null) {
final positionSeconds =
videoDetailController.plPlayerController.positionSeconds.value;
@@ -122,7 +122,7 @@ class _ViewPointsPageState extends State<ViewPointsPage>
return child;
}
Widget _buildItem(ThemeData theme, Segment segment, bool isCurr) {
Widget _buildItem(ThemeData theme, ViewPointSegment segment, bool isCurr) {
final theme = Theme.of(context);
return Material(
type: MaterialType.transparency,

View File

@@ -1740,106 +1740,69 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
right: 0,
child: Obx(
() {
if (plPlayerController.showControls.value) {
return const SizedBox.shrink();
}
switch (plPlayerController.progressType) {
case BtmProgressBehavior.onlyShowFullScreen:
if (!isFullScreen) {
return const SizedBox.shrink();
}
case BtmProgressBehavior.onlyHideFullScreen:
if (isFullScreen) {
return const SizedBox.shrink();
}
default:
}
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
IgnorePointer(
child: RepaintBoundary.wrap(
Obx(() {
final int value =
plPlayerController.sliderPositionSeconds.value;
final int max = plPlayerController
.durationSeconds
.value
.inSeconds;
final int buffer =
plPlayerController.bufferedSeconds.value;
return ProgressBar(
progress: Duration(seconds: value),
buffered: Duration(seconds: buffer),
total: Duration(seconds: max),
progressBarColor: primary,
baseBarColor: const Color(0x33FFFFFF),
bufferedBarColor: bufferedBarColor,
thumbColor: primary,
thumbGlowColor: thumbGlowColor,
barHeight: 3.5,
thumbRadius: 2.5,
);
}),
0,
),
),
if (plPlayerController.enableBlock &&
videoDetailController.segmentProgressList.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0.75,
child: IgnorePointer(
child: RepaintBoundary.wrap(
CustomPaint(
size: const Size(double.infinity, 3.5),
painter: SegmentProgressBar(
segmentColors:
videoDetailController.segmentProgressList,
),
),
1,
final offstage = switch (plPlayerController.progressType) {
BtmProgressBehavior.onlyShowFullScreen => !isFullScreen,
BtmProgressBehavior.onlyHideFullScreen => isFullScreen,
_ => plPlayerController.showControls.value,
};
return Offstage(
offstage: offstage,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Obx(() {
final int value =
plPlayerController.sliderPositionSeconds.value;
final int max =
plPlayerController.durationSeconds.value.inSeconds;
final int buffer =
plPlayerController.bufferedSeconds.value;
return ProgressBar(
progress: Duration(seconds: value),
buffered: Duration(seconds: buffer),
total: Duration(seconds: max),
progressBarColor: primary,
baseBarColor: const Color(0x33FFFFFF),
bufferedBarColor: bufferedBarColor,
thumbColor: primary,
thumbGlowColor: thumbGlowColor,
barHeight: 3.5,
thumbRadius: 2.5,
);
}),
if (plPlayerController.enableBlock &&
videoDetailController.segmentProgressList.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0.75,
child: SegmentProgressBar(
segments: videoDetailController.segmentProgressList,
),
),
),
if (plPlayerController.showViewPoints &&
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value) ...[
Positioned(
left: 0,
right: 0,
bottom: 0.75,
child: IgnorePointer(
child: RepaintBoundary.wrap(
CustomPaint(
size: const Size(double.infinity, 3.5),
painter: SegmentProgressBar(
segmentColors:
videoDetailController.viewPointList,
),
),
2,
if (plPlayerController.showViewPoints &&
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value)
Padding(
padding: const .only(bottom: 4.25),
child: ViewPointSegmentProgressBar(
segments: videoDetailController.viewPointList,
onSeek: PlatformUtils.isMobile
? (position) => plPlayerController.seekTo(
position,
isSeek: false,
)
: null,
),
),
),
if (PlatformUtils.isMobile)
buildViewPointWidget(
videoDetailController,
plPlayerController,
4.25,
maxWidth,
),
if (plPlayerController.showDmChart &&
videoDetailController.showDmTrendChart.value)
if (videoDetailController.dmTrend.value?.dataOrNull
case final list?)
buildDmChart(primary, list, videoDetailController),
],
if (plPlayerController.showDmChart &&
videoDetailController.showDmTrendChart.value)
if (videoDetailController.dmTrend.value?.dataOrNull
case final list?)
buildDmChart(primary, list, videoDetailController),
],
),
);
},
),
@@ -2325,7 +2288,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
// fullscreen
if (dx > maxWidth) {
_removeDmAction();
return const Positioned(left: 0, top: 0, child: SizedBox.shrink());
return const SizedBox.shrink();
}
final seekOffset = _getValidOffset(item.content.text);
@@ -2346,7 +2309,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
if (right > (maxWidth - item.xPosition)) {
_removeDmAction();
return const Positioned(left: 0, top: 0, child: SizedBox.shrink());
return const SizedBox.shrink();
}
final extra = item.content.extra;

View File

@@ -22,9 +22,31 @@ class BottomControl extends StatelessWidget {
final double maxWidth;
final bool isFullScreen;
final PlPlayerController controller;
final Widget Function() buildBottomControl;
final ValueGetter<Widget> buildBottomControl;
final VideoDetailController videoDetailController;
void onDragStart(ThumbDragDetails duration) {
feedBack();
controller.onChangedSliderStart(duration.timeStamp);
}
void onDragUpdate(ThumbDragDetails duration, int max) {
if (!controller.isFileSource && controller.showSeekPreview) {
controller.updatePreviewIndex(duration.timeStamp.inSeconds);
}
controller.onUpdatedSliderProgress(duration.timeStamp);
}
void onSeek(Duration duration, int max) {
if (controller.showSeekPreview) {
controller.showPreview.value = false;
}
controller
..onChangedSliderEnd()
..onChangedSlider(duration.inSeconds.toDouble())
..seekTo(Duration(seconds: duration.inSeconds), isSeek: false);
}
@override
Widget build(BuildContext context) {
final colorScheme = ColorScheme.of(context);
@@ -33,27 +55,6 @@ class BottomControl extends StatelessWidget {
: colorScheme.primary;
final thumbGlowColor = primary.withAlpha(80);
final bufferedBarColor = primary.withValues(alpha: 0.4);
void onDragStart(ThumbDragDetails duration) {
feedBack();
controller.onChangedSliderStart(duration.timeStamp);
}
void onDragUpdate(ThumbDragDetails duration, int max) {
if (!controller.isFileSource && controller.showSeekPreview) {
controller.updatePreviewIndex(duration.timeStamp.inSeconds);
}
controller.onUpdatedSliderProgress(duration.timeStamp);
}
void onSeek(Duration duration, int max) {
if (controller.showSeekPreview) {
controller.showPreview.value = false;
}
controller
..onChangedSliderEnd()
..onChangedSlider(duration.inSeconds.toDouble())
..seekTo(Duration(seconds: duration.inSeconds), isSeek: false);
}
Widget progressBar() {
final child = Obx(() {
@@ -93,63 +94,42 @@ class BottomControl extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 7),
child: Obx(
() => Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
progressBar(),
if (controller.enableBlock &&
videoDetailController.segmentProgressList.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 5.25,
child: IgnorePointer(
child: RepaintBoundary(
child: CustomPaint(
key: const Key('segmentList'),
size: const Size(double.infinity, 3.5),
painter: SegmentProgressBar(
segmentColors:
videoDetailController.segmentProgressList,
),
),
() => Offstage(
offstage: !controller.showControls.value,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
progressBar(),
if (controller.enableBlock &&
videoDetailController.segmentProgressList.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 5.25,
child: SegmentProgressBar(
segments: videoDetailController.segmentProgressList,
),
),
),
if (controller.showViewPoints &&
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value) ...[
Positioned(
left: 0,
right: 0,
bottom: 5.25,
child: IgnorePointer(
child: RepaintBoundary(
child: CustomPaint(
key: const Key('viewPointList'),
size: const Size(double.infinity, 3.5),
painter: SegmentProgressBar(
segmentColors:
videoDetailController.viewPointList,
),
),
if (controller.showViewPoints &&
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value)
Padding(
padding: const .only(bottom: 8.75),
child: ViewPointSegmentProgressBar(
segments: videoDetailController.viewPointList,
onSeek: PlatformUtils.isDesktop
? (position) =>
controller.seekTo(position, isSeek: false)
: null,
),
),
),
if (!PlatformUtils.isMobile)
buildViewPointWidget(
videoDetailController,
controller,
8.75,
maxWidth - 40,
),
if (videoDetailController.showDmTrendChart.value)
if (videoDetailController.dmTrend.value?.dataOrNull
case final list?)
buildDmChart(primary, list, videoDetailController, 4.5),
],
if (videoDetailController.showDmTrendChart.value)
if (videoDetailController.dmTrend.value?.dataOrNull
case final list?)
buildDmChart(primary, list, videoDetailController, 4.5),
],
),
),
),
),