Files
video/app/lib/screens/player/simple_player_screen.dart
2026-10-17 19:04:05 +08:00

995 lines
32 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/config/test_stream.dart';
import '../../core/media/download_manager.dart';
import '../../core/media/local_media_server.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/watch_dedup_store.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/auth_provider.dart';
import '../../services/video_service.dart';
import '../feed/widgets/comment_sheet.dart';
import '../feed/widgets/feed_overlays.dart';
import '../feed/widgets/interaction_bar.dart';
import '../feed/widgets/video_info_section.dart';
/// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用):
/// 与 Feed 页体验一致:右侧互动栏、左下信息区、双击点赞、长按倍速/分享、
/// 抖音风格细进度条、系列视频支持上下划切换集数
class SimplePlayerScreen extends ConsumerStatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
final VideoItem item;
@override
ConsumerState<SimplePlayerScreen> createState() =>
_SimplePlayerScreenState();
}
class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
late Player _player = Player();
late VideoController _controller = VideoController(_player);
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _playing = true;
bool _showPauseIcon = false;
Timer? _pauseIconTimer;
bool _seeking = false;
double _seekValue = 0;
// 系列
Map<String, dynamic>? _seriesData;
List<VideoItem> _episodeItems = [];
late PageController _pageController;
int _currentPage = 0;
// 当前播放的 item(切集后更新,供互动栏/信息区用)
late VideoItem _currentItem = widget.item;
// 互动状态(切集后从后端重新拉取)
bool _isLiked = false;
bool _isFavorited = false;
int _likeCount = 0;
int _favoriteCount = 0;
int _commentCount = 0;
StreamSubscription<Duration>? _posSub;
StreamSubscription<Duration>? _durSub;
static const _rates = [1.0, 1.5, 2.0, 3.0];
double _rate = 1.0;
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
List<dynamic> get _episodes =>
(_seriesData?['episodes'] as List?) ?? const [];
String get _seriesTitle =>
_seriesData?['series']?['title'] as String? ?? '';
@override
void initState() {
super.initState();
_open(widget.item);
_reportWatch();
_loadSeries();
_loadInteraction();
_subscribePlayer();
}
// ---------- 播放器 ----------
void _subscribePlayer() {
_posSub = _player.stream.position.listen((p) {
if (!mounted || _seeking) return;
setState(() => _position = p);
});
_durSub = _player.stream.duration.listen((d) {
if (!mounted) return;
setState(() => _duration = d);
});
}
void _open(VideoItem item) {
// 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存
_player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))),
play: true);
}
void _toggle() {
_player.playOrPause();
setState(() {
_showPauseIcon = !_playing;
_playing = !_playing;
});
_pauseIconTimer?.cancel();
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
if (mounted) setState(() => _showPauseIcon = false);
});
}
// ---------- 倍速 ----------
void _setRate(double r) {
_player.setRate(r);
setState(() => _rate = r);
}
String get _rateLabel =>
_rate == _rate.roundToDouble() ? '${_rate.toInt()}X' : '${_rate}X';
// ---------- 互动 ----------
Future<void> _loadInteraction() async {
// 从后端拉当前视频的点赞/收藏状态
try {
final data = await VideoService.fetchDetail(_currentItem.videoId);
if (!mounted) return;
setState(() {
_isLiked = (data['is_liked'] as bool?) ?? false;
_isFavorited = (data['is_favorited'] as bool?) ?? false;
_likeCount = (data['like_count'] as num?)?.toInt() ?? 0;
_favoriteCount = (data['favorite_count'] as num?)?.toInt() ?? 0;
_commentCount = (data['comment_count'] as num?)?.toInt() ?? 0;
});
} catch (_) {
// 游客或未登录时忽略,用默认值
}
}
void _onLike() {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
setState(() {
_isLiked = !_isLiked;
_likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1;
});
VideoService.toggleLike(_currentItem.videoId).catchError((_) {
setState(() {
_isLiked = !_isLiked;
_likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1;
});
return <String, dynamic>{};
});
}
void _onFavorite() {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
setState(() {
_isFavorited = !_isFavorited;
_favoriteCount = _isFavorited ? _favoriteCount + 1 : _favoriteCount - 1;
});
VideoService.toggleFavorite(_currentItem.videoId).catchError((_) {
setState(() {
_isFavorited = !_isFavorited;
_favoriteCount =
_isFavorited ? _favoriteCount + 1 : _favoriteCount - 1;
});
return <String, dynamic>{};
});
}
Future<void> _openComments() async {
_player.pause();
await showCommentSheet(
context,
videoId: _currentItem.videoId,
onCountChanged: (delta) =>
setState(() => _commentCount = (_commentCount + delta).clamp(0, 1 << 31)),
);
if (mounted) _player.play();
}
void _share() {
Share.share(
'${_currentItem.title}\n${_currentItem.videoUrl}',
subject: _currentItem.title,
);
}
// ---------- 系列 ----------
Future<void> _loadSeries() async {
final data = await VideoService.fetchVideoSeries(_currentItem.videoId);
if (!mounted) return;
setState(() => _seriesData = data);
_initEpisodePage();
}
void _initEpisodePage() {
if (!_hasSeries) return;
final episodes = _episodes;
if (episodes.isEmpty) return;
_episodeItems = episodes.map((ep) {
final m = ep as Map<String, dynamic>;
return VideoItem(
videoId: m['video_id'],
title: m['title'] ?? '',
coverUrl: m['cover_url'] ?? '',
videoUrl: m['video_url'] ?? '',
duration: (m['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
}).toList();
_currentPage = _episodeItems.indexWhere(
(e) => e.videoId == _currentItem.videoId,
);
if (_currentPage < 0) _currentPage = 0;
_pageController = PageController(initialPage: _currentPage);
}
void _onPageChanged(int index) {
if (index < 0 || index >= _episodeItems.length) return;
_switchToEpisodeById(_episodeItems[index].videoId);
}
void _switchToEpisodeById(int videoId) {
if (videoId == _currentItem.videoId) return;
// 从 episodes 原始数据构造新 VideoItem
final epData = _episodes.firstWhere(
(e) => (e as Map)['video_id'] == videoId,
orElse: () => null,
) as Map<String, dynamic>?;
if (epData == null) return;
final newItem = VideoItem(
videoId: videoId,
title: epData['title'] ?? '',
coverUrl: epData['cover_url'] ?? '',
videoUrl: epData['video_url'] ?? '',
duration: (epData['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
// 彻底销毁旧 Player,创建新 Player
_posSub?.cancel();
_durSub?.cancel();
_player.dispose();
setState(() {
_player = Player();
_controller = VideoController(_player);
_currentItem = newItem;
_position = Duration.zero;
_duration = Duration.zero;
_playing = true;
_rate = 1.0;
_open(newItem);
});
_subscribePlayer();
_reportWatch();
_loadSeries();
_loadInteraction();
}
// ---------- Watch ----------
Future<void> _reportWatch() async {
final dedup = WatchDedupStore.I;
if (dedup.hasWatched(_currentItem.videoId)) return;
await dedup.markWatched(_currentItem.videoId);
try {
final result = await VideoService.reportWatch(_currentItem.videoId);
if (!mounted) return;
if (result.isPointsCost) {
showTopToast(context, '本次消耗 ${result.costPoints} 积分');
}
} on ApiException catch (e) {
if (!mounted) return;
_player.pause();
if (e.isGuestLimit) {
showGuestLimitDialog(context);
} else if (e.isPointsInsufficient) {
showPointsInsufficientDialog(
context,
(e.data?['points'] as num?)?.toInt() ?? 0,
);
} else {
showTopToast(context, e.message);
}
} catch (_) {}
}
// ---------- 长按 ----------
void _onLongPress() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题 + 当前倍速
Row(
children: [
const Icon(Icons.speed, color: AppColors.primary, size: 20),
const SizedBox(width: 6),
const Text('倍速播放',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
Text('当前 $_rateLabel',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
const SizedBox(height: 14),
// 四档倍速:1X / 1.5X / 2X / 3X
Row(
children: [
for (final r in _rates) ...[
Expanded(
child: GestureDetector(
onTap: () {
_setRate(r);
Navigator.of(sheetContext).pop();
},
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: (_rate - r).abs() < 0.01
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
border: Border.all(
color: (_rate - r).abs() < 0.01
? AppColors.primary
: Colors.transparent,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
r == r.roundToDouble()
? '${r.toInt()}X'
: '${r}X',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: (_rate - r).abs() < 0.01
? AppColors.primary
: Colors.black87,
),
),
),
),
),
if (r != _rates.last) const SizedBox(width: 10),
],
],
),
const SizedBox(height: 16),
const Divider(height: 1),
// 分享
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.share_outlined),
title: const Text('分享'),
onTap: () {
Navigator.of(sheetContext).pop();
_share();
},
),
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded =
dm.items.any((e) => e.videoId == _currentItem.videoId);
final downloading =
dm.progress.containsKey(_currentItem.videoId);
final isLocal = _currentItem.videoUrl.contains('127.0.0.1');
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded || isLocal
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded || isLocal
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading && !isLocal,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(_currentItem)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
],
),
),
),
);
}
// ---------- 进度条 ----------
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
Duration get _effectiveDuration => _duration.inMilliseconds > 0
? _duration
: Duration(seconds: _currentItem.duration);
double get _sliderValue {
if (_seeking) return _seekValue;
if (_effectiveDuration.inMilliseconds <= 0) return 0;
return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
.clamp(0.0, 1.0);
}
static String _fmtDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
void _onSeekStart(double v) {
setState(() {
_seeking = true;
_seekValue = v;
});
}
void _onSeek(double v) {
setState(() => _seekValue = v);
}
void _onSeekEnd(double v) {
setState(() => _seeking = false);
if (_effectiveDuration.inMilliseconds > 0) {
final target = Duration(
milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
);
_player.seek(target);
}
}
// ---------- 生命周期 ----------
@override
void dispose() {
_pauseIconTimer?.cancel();
_posSub?.cancel();
_durSub?.cancel();
_player.dispose();
if (_hasSeries && _episodeItems.isNotEmpty) {
_pageController.dispose();
}
super.dispose();
}
// ---------- 构建 ----------
@override
Widget build(BuildContext context) {
// 互动栏 item(用当前播放的 item + 本地互动状态)
final interactionItem = _currentItem.copyWith(
isLiked: _isLiked,
isFavorited: _isFavorited,
likeCount: _likeCount,
favoriteCount: _favoriteCount,
commentCount: _commentCount,
);
final content = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggle,
onDoubleTap: _onLike,
onLongPress: _onLongPress,
child: Stack(
fit: StackFit.expand,
children: [
// 视频画面
Center(
child: Video(
controller: _controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
),
// 中央播放/暂停图标(短暂显示)
if (_showPauseIcon)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_circle_outline,
size: 72,
color: Colors.white70,
),
),
),
// 底部渐变(提升文字可读性)
const Positioned(
left: 0, right: 0, bottom: 0, height: 200,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
),
// ---------- 顶部:返回 + 倍速 ----------
SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
// 倍速按钮
GestureDetector(
onTap: _onLongPress,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_rateLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
// ---------- 右侧互动栏 ----------
Positioned(
right: 8, bottom: 96,
child: InteractionBar(
item: interactionItem,
onLike: (_) => _onLike(),
onFavorite: (_) => _onFavorite(),
onComment: (_) => _openComments(),
),
),
// ---------- 左下信息区 ----------
Positioned(
left: 12, right: 88, bottom: 24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
if (_hasSeries)
GestureDetector(
onTap: _showSeriesSheet,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.playlist_play,
color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
_seriesTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
const Icon(Icons.chevron_right,
color: Colors.white, size: 14),
],
),
),
),
// 视频信息(昵称/标题/描述/标签)
VideoInfoSection(item: _currentItem),
],
),
),
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
Positioned(
left: 0, right: 0, bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 时间刻度:左当前(拖动时为目标位置),右总时长
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_fmtDuration(_seeking
? Duration(
milliseconds: (_seekValue *
_effectiveDuration.inMilliseconds)
.round())
: _position),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
Text(
_fmtDuration(_effectiveDuration),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
],
),
),
_ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _effectiveDuration.inMilliseconds,
),
],
),
),
],
),
);
// 系列视频:用 PageView 支持上下划切换集数
if (_hasSeries && _episodeItems.isNotEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
itemCount: _episodeItems.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, index) {
if (index == _currentPage) return content;
return Container(
color: Colors.black,
child: Center(
child: Text(
_episodeItems[index].title,
style: const TextStyle(color: Colors.white38),
),
),
);
},
),
);
}
// 非系列视频:直接渲染
return Scaffold(
backgroundColor: Colors.black,
body: content,
);
}
// ---------- 系列列表弹窗 ----------
void _showSeriesSheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, controller) => Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_seriesTitle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
controller: controller,
itemCount: _episodes.length,
itemBuilder: (_, i) {
final ep = _episodes[i] as Map<String, dynamic>;
final isCurrent = ep['video_id'] == _currentItem.videoId;
return ListTile(
leading: Container(
width: 40, height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isCurrent
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${ep['episode_no'] ?? i + 1}',
style: TextStyle(
fontWeight: FontWeight.w700,
color: isCurrent
? AppColors.primary
: Colors.black87,
),
),
),
title: Text(
ep['title'] as String? ?? '',
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: isCurrent
? FontWeight.w600
: FontWeight.normal,
color: isCurrent
? AppColors.primary
: Colors.black87,
),
),
trailing: isCurrent
? const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 20)
: null,
onTap: () {
Navigator.of(context).pop();
_switchToEpisodeById(ep['video_id'] as int);
},
);
},
),
),
],
),
),
),
);
}
}
/// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白),
/// 拖动时自动变厚显示 thumb,松开后恢复细线。
class _ThinProgressBar extends StatefulWidget {
const _ThinProgressBar({
required this.value,
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
this.totalMs = 0,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动时气泡显示目标位置用时/分/秒
final int totalMs;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false;
double _dragValue = 0;
void _start(Offset local, double width) {
setState(() => _dragging = true);
widget.onChangeStart(widget.value);
_update(local, width);
}
void _update(Offset local, double width) {
final v = (local.dx / width).clamp(0.0, 1.0);
_dragValue = v;
widget.onChanged(v);
}
void _end(double width) {
setState(() => _dragging = false);
final v = _dragValue.clamp(0.0, 1.0);
widget.onChangeEnd(v);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (_, constraints) {
final w = constraints.maxWidth;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (d) => _start(d.localPosition, w),
onHorizontalDragUpdate: (d) => _update(d.localPosition, w),
onHorizontalDragEnd: (_) => _end(w),
onTapDown: (d) {
setState(() => _dragging = true);
_start(d.localPosition, w);
},
onTapUp: (_) => _end(w),
child: SizedBox(
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Align(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: _dragging ? 4 : 2,
width: double.infinity,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
),
),
);
},
);
}
}
class _ProgressPainter extends CustomPainter {
_ProgressPainter({required this.value, required this.dragging});
final double value;
final bool dragging;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final y = h / 2;
// 背景(未播放)
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.2)
..strokeWidth = h
..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(0, y), Offset(w, y), bgPaint);
// 已播放
final fgPaint = Paint()
..color = Colors.white
..strokeWidth = h
..strokeCap = StrokeCap.round;
final playedX = (w * value).clamp(0.0, w);
canvas.drawLine(Offset(0, y), Offset(playedX, y), fgPaint);
// 拖动时显示 thumb
if (dragging) {
final thumbPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(playedX, y), 6, thumbPaint);
}
}
@override
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
oldDelegate.value != value || oldDelegate.dragging != dragging;
}