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

567 lines
18 KiB
Dart

import 'dart:async';
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';
/// 单视频播放页(搜索/用户主页/作品/收藏/历史共用):
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
/// - 顶部返回 + 倍速 + 系列
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
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);
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _playing = true;
double _rate = 1.0;
bool _seeking = false;
Map<String, dynamic>? _seriesData;
Timer? _pauseIconTimer;
bool _showPauseIcon = false;
StreamSubscription<Duration>? _posSub;
StreamSubscription<Duration>? _durSub;
@override
void initState() {
super.initState();
_open(widget.item);
_reportWatch();
_loadSeries();
_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) {
_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() {
_pauseIconTimer?.cancel();
_posSub?.cancel();
_durSub?.cancel();
_player.dispose();
super.dispose();
}
// ---------- 播放控制 ----------
void _toggle() {
setState(() => _playing = !_playing);
_playing ? _player.play() : _player.pause();
// 短暂显示暂停/播放图标
setState(() => _showPauseIcon = true);
_pauseIconTimer?.cancel();
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
if (mounted) setState(() => _showPauseIcon = false);
});
}
// ---------- 进度条 seek ----------
double get _sliderValue {
final total = _duration.inMilliseconds;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
}
void _onSeekStart(double v) {
_seeking = true;
}
void _onSeek(double v) {
setState(() {});
}
void _onSeekEnd(double v) {
_seeking = false;
final totalMs = _duration.inMilliseconds;
if (totalMs > 0) {
_player.seek(Duration(milliseconds: (v * totalMs).round()));
}
}
// ---------- 倍速 ----------
static const _rates = [1.0, 1.25, 1.5, 2.0];
void _setRate(double r) {
_player.setRate(r);
setState(() => _rate = r);
}
// ---------- 系列 ----------
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);
_position = Duration.zero;
_duration = Duration.zero;
_open(newItem);
_seriesData = null;
});
_subscribePlayer();
_reportWatch();
_loadSeries();
}
// ---------- Watch ----------
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 (_) {}
}
@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,
),
),
// 中央播放/暂停图标(短暂显示)
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(),
),
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: _ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
),
),
],
),
),
);
}
}
/// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白),
/// 拖动时自动变厚显示 thumb,松开后恢复细线。
class _ThinProgressBar extends StatefulWidget {
const _ThinProgressBar({
required this.value,
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
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: Container(
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: _dragging
? Duration.zero
: const Duration(milliseconds: 150),
curve: Curves.easeOut,
height: _dragging ? 4.0 : 2.0, // 拖动时变厚
color: Colors.transparent,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
),
);
},
);
}
}
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 = dragging ? 3.0 : 2.0;
final y = (size.height - h) / 2;
// 背景:未播放部分(20% 透明白)
final trackPaint = Paint()
..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,
);
// 已播放部分(纯白)
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,
);
}
// 拖动时显示 thumb(小圆点)
if (dragging) {
final thumbPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(value * w, size.height / 2),
6,
thumbPaint,
);
}
}
@override
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
value != oldDelegate.value || dragging != oldDelegate.dragging;
}