Files
video/app/lib/screens/player/simple_player_screen.dart
T
2026-10-16 13:51:00 +08:00

308 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../core/config/test_stream.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../core/storage/watch_dedup_store.dart';
import '../../models/video_item.dart';
import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
class SimplePlayerScreen extends StatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
final VideoItem item;
@override
State<SimplePlayerScreen> createState() => _SimplePlayerScreenState();
}
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
late Player _player = Player();
late VideoController _controller = VideoController(_player);
bool _playing = true;
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
@override
void initState() {
super.initState();
_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
void dispose() {
_player.dispose();
super.dispose();
}
Future<void> _reportWatch() async {
final dedup = WatchDedupStore.I;
if (dedup.hasWatched(widget.item.videoId)) return;
await dedup.markWatched(widget.item.videoId);
try {
final result = await VideoService.reportWatch(widget.item.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 _toggle() {
setState(() => _playing = !_playing);
_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(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: Video(
controller: _controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
),
if (!_playing)
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),
if (_hasSeries)
IconButton(
icon: const Icon(Icons.menu_book, color: Colors.white),
tooltip: '查看系列',
onPressed: _showSeriesSheet,
),
],
),
),
// 底部标题/作者
Positioned(
left: 12, right: 12, bottom: 24,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 14, color: Colors.white70),
),
],
),
),
),
],
),
),
);
}
}