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