This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+184 -21
View File
@@ -11,8 +11,7 @@ import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
/// 与 Feed 一致的观看计费(开始播放即上报,按 video_id+日期本地去重),
/// 游客超限 2002 / 积分不足 2001 弹窗逻辑复用 feed_overlays。
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
class SimplePlayerScreen extends StatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
@@ -23,15 +22,27 @@ class SimplePlayerScreen extends StatefulWidget {
}
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
late final Player _player = Player();
late final VideoController _controller = VideoController(_player);
late Player _player = Player();
late VideoController _controller = VideoController(_player);
bool _playing = true;
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
@override
void initState() {
super.initState();
_player.open(Media(resolvePlayUrl(widget.item)));
_open(widget.item);
_reportWatch();
_loadSeries();
}
void _open(VideoItem item) {
_player.open(Media(resolvePlayUrl(item)), play: true);
}
Future<void> _loadSeries() async {
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
if (!mounted) return;
setState(() => _seriesData = data);
}
@override
@@ -40,7 +51,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
super.dispose();
}
/// 观看上报(计费):本地去重后上报,扣费 Toast / 超限弹窗
Future<void> _reportWatch() async {
final dedup = WatchDedupStore.I;
if (dedup.hasWatched(widget.item.videoId)) return;
@@ -72,6 +82,157 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
_playing ? _player.play() : _player.pause();
}
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
void _showSeriesSheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.4,
maxChildSize: 0.9,
builder: (_, controller) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// 拖动条
Center(
child: 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: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: (_seriesData!['series']['cover_url'] as String?)
?.isNotEmpty ==
true
? Image.network(
_seriesData!['series']['cover_url'],
width: 64, height: 64, fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(width: 64, height: 64),
)
: Container(
width: 64, height: 64, color: Colors.grey[200]),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_seriesData!['series']['title'] ?? '',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'共 ${_seriesData!['series']['total_episodes']} 集',
style: TextStyle(
color: Colors.grey[600], fontSize: 12),
),
],
),
),
],
),
),
const Divider(height: 1),
// 集数网格
Expanded(
child: GridView.builder(
controller: controller,
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount: _episodes.length,
itemBuilder: (_, i) {
final ep = _episodes[i] as Map<String, dynamic>;
final isCurrent =
ep['video_id'] == widget.item.videoId;
return GestureDetector(
onTap: () => _switchToEpisode(ep),
child: Container(
decoration: BoxDecoration(
color: isCurrent
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
border: Border.all(
color: isCurrent
? AppColors.primary
: Colors.transparent),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
ep['episode_no']?.toString() ?? '${i + 1}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isCurrent
? AppColors.primary
: Colors.black87,
),
),
),
),
);
},
),
),
],
),
),
),
);
}
void _switchToEpisode(Map<String, dynamic> ep) {
Navigator.of(context).pop(); // 关抽屉
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,
author: widget.item.author, // 系列一般作者相同
);
setState(() {
_player.dispose();
_player = Player();
_controller = VideoController(_player);
_open(newItem);
// 重置 seriesData 重新加载
_seriesData = null;
});
_reportWatch();
_loadSeries();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -81,7 +242,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
child: Stack(
fit: StackFit.expand,
children: [
// 播放器
Center(
child: Video(
controller: _controller,
@@ -89,7 +249,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
fit: BoxFit.contain,
),
),
// 暂停态大播放图标
if (!_playing)
const IgnorePointer(
child: Center(
@@ -100,18 +259,24 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
),
),
),
// 返回按钮
const SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: BackButton(color: Colors.white),
// 顶部:返回 + 系列按钮
SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BackButton(color: Colors.white),
if (_hasSeries)
IconButton(
icon: const Icon(Icons.menu_book, color: Colors.white),
tooltip: '查看系列',
onPressed: _showSeriesSheet,
),
],
),
),
// 底部标题/作者
Positioned(
left: 12,
right: 12,
bottom: 24,
left: 12, right: 12, bottom: 24,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -120,17 +285,15 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
fontSize: 15, fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
fontSize: 14, color: Colors.white70),
),
],
),