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
+258 -56
View File
@@ -7,6 +7,7 @@ import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
import '../../../services/video_service.dart';
import 'feed_overlays.dart';
import 'interaction_bar.dart';
import 'video_info_section.dart';
@@ -40,8 +41,28 @@ class FeedVideoPage extends ConsumerStatefulWidget {
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
int _likeBurstTrigger = 0;
Map<String, dynamic>? _seriesData;
static const _rates = [1.0, 1.25, 1.5, 2.0];
static const _rates = [1.0, 1.5, 2.0, 3.0];
@override
void initState() {
super.initState();
_loadSeries();
}
Future<void> _loadSeries() async {
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
if (!mounted) return;
setState(() => _seriesData = data);
}
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
void _onDoubleTap() {
if (!ref.read(authProvider).isLoggedIn) {
@@ -52,69 +73,98 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
}
/// 倍速标签:1.0→1X,1.5→1.5X
String _rateLabel(double r) =>
r == r.roundToDouble() ? '${r.toInt()}X' : '${r}X';
void _onLongPress() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) {
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.speed, color: AppColors.primary),
title: const Text('倍速播放'),
subtitle: Text('当前 ${currentRate.toStringAsFixed(2)}x'),
onTap: () {
Navigator.of(context).pop();
_showRateSheet();
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('分享'),
onTap: () {
Navigator.of(context).pop();
_share();
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
final player = widget.pool.slotAt(widget.slotIndex).player;
final currentRate = player.state.rate;
void _showRateSheet() {
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => SafeArea(
builder: (sheetContext) => Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final r in _rates)
ListTile(
title: Center(child: Text('${r}x')),
trailing: (currentRate - r).abs() < 0.01
? const Icon(Icons.check, color: AppColors.primary)
: null,
onTap: () {
widget.pool.slotAt(widget.slotIndex).player.setRate(r);
Navigator.of(context).pop();
},
),
const SizedBox(height: 8),
// 标题 + 当前倍速
Row(
children: [
const Icon(Icons.speed, color: AppColors.primary, size: 20),
const SizedBox(width: 6),
const Text('倍速播放',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
Text('当前 ${_rateLabel(currentRate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
const SizedBox(height: 14),
// 四档倍速:1X / 1.5X / 2X / 3X
Row(
children: [
for (final r in _rates) ...[
Expanded(
child: GestureDetector(
onTap: () {
player.setRate(r);
Navigator.of(sheetContext).pop();
},
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: (currentRate - r).abs() < 0.01
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
border: Border.all(
color: (currentRate - r).abs() < 0.01
? AppColors.primary
: Colors.transparent,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_rateLabel(r),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: (currentRate - r).abs() < 0.01
? AppColors.primary
: Colors.black87,
),
),
),
),
),
if (r != _rates.last) const SizedBox(width: 10),
],
],
),
const SizedBox(height: 16),
const Divider(height: 1),
// 分享
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.share_outlined),
title: const Text('分享'),
onTap: () {
Navigator.of(sheetContext).pop();
_share();
},
),
],
),
),
@@ -126,6 +176,121 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
await Share.share(text, subject: widget.item.title);
}
void _showSeriesSheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, controller) => Column(
children: [
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: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_seriesTitle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
controller: controller,
itemCount: _episodes.length,
itemBuilder: (_, i) {
final ep = _episodes[i] as Map<String, dynamic>;
final isCurrent = ep['video_id'] == widget.item.videoId;
return ListTile(
leading: Container(
width: 40, height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isCurrent
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${ep['episode_no'] ?? i + 1}',
style: TextStyle(
fontWeight: FontWeight.w700,
color: isCurrent ? AppColors.primary : Colors.black87,
),
),
),
title: Text(
ep['title'] as String? ?? '',
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight:
isCurrent ? FontWeight.w600 : FontWeight.normal,
color: isCurrent ? AppColors.primary : Colors.black87,
),
),
trailing: isCurrent
? const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 20)
: null,
onTap: () => _switchToEpisode(ep),
);
},
),
),
],
),
),
);
}
void _switchToEpisode(Map<String, dynamic> ep) {
Navigator.of(context).pop();
final newItem = VideoItem(
videoId: ep['video_id'] as int,
title: ep['title'] as String? ?? '',
coverUrl: ep['cover_url'] as String? ?? '',
videoUrl: ep['video_url'] as String? ?? '',
duration: (ep['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
// 打开 SimplePlayerScreen 播放该集
context.push('/player', extra: newItem);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
@@ -159,7 +324,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
pool: widget.pool,
slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl,
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
isCurrent: widget.isCurrent,
),
// 双击爱心动画
@@ -176,10 +341,47 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
),
),
// 左下信息区
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
Positioned(
left: 12, right: 88, bottom: 24,
child: VideoInfoSection(item: widget.item),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
if (_hasSeries)
GestureDetector(
onTap: _showSeriesSheet,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.playlist_play, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
_seriesTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
const Icon(Icons.chevron_right, color: Colors.white, size: 14),
],
),
),
),
// 视频信息(昵称/标题/描述)
VideoInfoSection(item: widget.item),
],
),
),
],
),
@@ -13,13 +13,15 @@ class VideoPlayerView extends StatefulWidget {
required this.pool,
required this.slotIndex,
required this.coverUrl,
required this.playing,
required this.isCurrent,
});
final FeedPlayerPool pool;
final int slotIndex;
final String coverUrl;
final bool playing;
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
final bool isCurrent;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
@@ -30,6 +32,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _seeking = false;
double _seekValue = 0; // 拖动中的视觉值(0~1),拖动期间直接控制 painter
@override
void initState() {
@@ -43,16 +46,26 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
}
double get _value {
if (_seeking) return _seekValue;
final total = _duration.inMilliseconds;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
}
void _onSeekStart(double v) => setState(() => _seeking = true);
void _onSeek(double v) => setState(() {});
void _onSeekStart(double v) {
setState(() {
_seeking = true;
_seekValue = v;
});
}
void _onSeek(double v) {
setState(() => _seekValue = v);
}
void _onSeekEnd(double v) {
_seeking = false;
setState(() => _seeking = false);
final totalMs = _duration.inMilliseconds;
if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
@@ -88,14 +101,22 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
},
),
// 暂停态大播放图标
if (!widget.playing)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88, color: Colors.white70,
),
// 暂停态大播放图标:只在当前页、且该槽位确实处于暂停态时显示
if (widget.isCurrent)
IgnorePointer(
child: StreamBuilder<bool>(
stream: _slot.player.stream.playing,
initialData: _slot.player.state.playing,
builder: (_, snapshot) {
final playing = snapshot.data ?? false;
if (playing) return const SizedBox.shrink();
return const Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88, color: Colors.white70,
),
);
},
),
),