This commit is contained in:
lzcode
2026-10-16 20:03:58 +08:00
parent 9962136fc8
commit ebad02ce31
19 changed files with 622 additions and 240 deletions
+209 -130
View File
@@ -12,10 +12,11 @@ import '../../models/video_item.dart';
import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 单视频播放页(搜索/用户主页/作品/收藏/历史共用):
/// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用):
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
/// - 顶部返回 + 倍速 + 系列
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
/// - 系列视频支持上下划切换上一集/下一集
class SimplePlayerScreen extends StatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
@@ -44,6 +45,11 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
StreamSubscription<Duration>? _posSub;
StreamSubscription<Duration>? _durSub;
// 系列切换(上下划)
late PageController _pageController;
int _currentPage = 0;
List<VideoItem> _episodeItems = [];
@override
void initState() {
super.initState();
@@ -72,6 +78,32 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
final data = await VideoService.fetchVideoSeries(widget.item.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 == widget.item.videoId,
);
if (_currentPage < 0) _currentPage = 0;
_pageController = PageController(initialPage: _currentPage);
}
@override
@@ -80,6 +112,7 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
_posSub?.cancel();
_durSub?.cancel();
_player.dispose();
if (_hasSeries) _pageController.dispose();
super.dispose();
}
@@ -88,7 +121,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
void _toggle() {
setState(() => _playing = !_playing);
_playing ? _player.play() : _player.pause();
// 短暂显示暂停/播放图标
setState(() => _showPauseIcon = true);
_pauseIconTimer?.cancel();
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
@@ -260,28 +292,55 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
void _switchToEpisode(Map<String, dynamic> ep) {
Navigator.of(context).pop();
_switchToEpisodeById(ep['video_id'] as int);
}
void _switchToEpisodeById(int videoId) {
if (videoId == widget.item.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: ep['video_id'],
title: ep['title'] ?? '',
coverUrl: ep['cover_url'] ?? '',
videoUrl: ep['video_url'] ?? '',
duration: (ep['duration'] as num?)?.toInt() ?? 0,
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.dispose();
_player = Player();
_controller = VideoController(_player);
_position = Duration.zero;
_duration = Duration.zero;
_playing = true;
_open(newItem);
_seriesData = null;
});
_subscribePlayer();
_reportWatch();
_loadSeries();
}
// ---------- 上下划切换(系列) ----------
void _onPageChanged(int index) {
if (index == _currentPage) return;
_currentPage = index;
_switchToEpisodeById(_episodeItems[index].videoId);
}
// ---------- Watch ----------
Future<void> _reportWatch() async {
@@ -312,120 +371,150 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
// 视频画面
Center(
child: Video(
controller: _controller,
controls: NoVideoControls,
fit: BoxFit.contain,
final content = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggle,
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,
),
),
),
// 中央播放/暂停图标(短暂显示)
if (_showPauseIcon)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_circle_outline,
size: 72,
color: Colors.white70,
),
),
),
// ---------- 顶部:返回 + 倍速 + 系列 ----------
SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 倍速
PopupMenuButton<double>(
initialValue: _rate,
icon: const Icon(Icons.speed, color: Colors.white),
tooltip: '倍速',
onSelected: _setRate,
itemBuilder: (_) => _rates
.map((r) => PopupMenuItem(
value: r,
child: Text('${r}x'),
))
.toList(),
// ---------- 顶部:返回 + 倍速 + 系列 ----------
SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 倍速
PopupMenuButton<double>(
initialValue: _rate,
icon: const Icon(Icons.speed, color: Colors.white),
tooltip: '倍速',
onSelected: _setRate,
itemBuilder: (_) => _rates
.map((r) => PopupMenuItem(
value: r,
child: Text('${r}x'),
))
.toList(),
),
if (_hasSeries)
IconButton(
icon: const Icon(Icons.menu_book, color: Colors.white),
tooltip: '查看系列',
onPressed: _showSeriesSheet,
),
if (_hasSeries)
IconButton(
icon: const Icon(Icons.menu_book, color: Colors.white),
tooltip: '查看系列',
onPressed: _showSeriesSheet,
],
),
],
),
),
// ---------- 底部:标题 + 抖音风格细进度条 ----------
Positioned(
left: 0, right: 0, bottom: 0,
child: IgnorePointer(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white),
),
],
const SizedBox(height: 2),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 12, color: Colors.white70),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
// ---------- 底部:标题 + 抖音风格细进度条 ----------
Positioned(
left: 0, right: 0, bottom: 0,
child: IgnorePointer(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题信息(贴底,不占用视频观看空间)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white),
),
const SizedBox(height: 2),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 12, color: Colors.white70),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
// 进度条:抖音风格一条细线,覆盖视频底部
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
),
),
],
),
);
// 系列视频:用 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),
),
),
),
// 进度条:抖音风格一条细线,覆盖视频底部
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
),
),
],
);
},
),
),
);
}
// 非系列视频:直接渲染
return Scaffold(
backgroundColor: Colors.black,
body: content,
);
}
}
@@ -528,39 +617,29 @@ class _ProgressPainter extends CustomPainter {
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
..strokeCap = StrokeCap.round
..strokeWidth = h;
canvas.drawLine(
Offset(0, size.height / 2),
Offset(w, size.height / 2),
trackPaint,
);
canvas.drawLine(Offset(0, y), Offset(w, y), trackPaint);
// 已播放部分(纯白)
// 已播放部分(白色)
if (value > 0) {
final playedPaint = Paint()
..color = Colors.white
..strokeCap = StrokeCap.round
..strokeWidth = h;
canvas.drawLine(
Offset(0, size.height / 2),
Offset(value * w, size.height / 2),
playedPaint,
);
canvas.drawLine(Offset(0, y), Offset(value * w, y), playedPaint);
}
// 拖动时显示 thumb(小圆点)
// 拖动时的 thumb
if (dragging) {
final thumbPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(value * w, size.height / 2),
6,
thumbPaint,
Offset(value * w, y), 6,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
}
}
@override
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
value != oldDelegate.value || dragging != oldDelegate.dragging;
bool shouldRepaint(covariant _ProgressPainter old) =>
value != old.value || dragging != old.dragging;
}