up
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user