1
This commit is contained in:
@@ -3,10 +3,8 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/share_text.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import 'widgets/comment_sheet.dart';
|
||||
@@ -99,9 +97,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
if (mounted) notifier.pool.playCurrent();
|
||||
}
|
||||
|
||||
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
|
||||
void _share(VideoItem item) => Share.share(buildShareText(item));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(feedProvider(_strategy));
|
||||
@@ -139,7 +134,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
onLike: notifier.toggleLike,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
onComment: _openComments,
|
||||
onShare: _share,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/video_item.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import 'feed_overlays.dart';
|
||||
@@ -11,7 +13,7 @@ import 'video_info_section.dart';
|
||||
import 'video_player_view.dart';
|
||||
|
||||
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
|
||||
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
|
||||
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞,长按弹倍速/分享
|
||||
class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
const FeedVideoPage({
|
||||
super.key,
|
||||
@@ -22,26 +24,16 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
required this.onLike,
|
||||
this.onFavorite,
|
||||
this.onComment,
|
||||
this.onShare,
|
||||
});
|
||||
|
||||
final VideoItem item;
|
||||
final int slotIndex;
|
||||
final FeedPlayerPool pool;
|
||||
final bool isCurrent;
|
||||
|
||||
/// 点赞回调(由上层接 feedProvider.toggleLike,内部判登录态)
|
||||
final void Function(int videoId) onLike;
|
||||
|
||||
/// 收藏回调(由上层接 feedProvider.toggleFavorite)
|
||||
final void Function(int videoId)? onFavorite;
|
||||
|
||||
/// 评论入口(打开评论弹层)
|
||||
final void Function(VideoItem item)? onComment;
|
||||
|
||||
/// 分享入口(系统分享面板)
|
||||
final void Function(VideoItem item)? onShare;
|
||||
|
||||
@override
|
||||
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
|
||||
}
|
||||
@@ -49,24 +41,98 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
||||
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
int _likeBurstTrigger = 0;
|
||||
|
||||
static const _rates = [1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
void _onDoubleTap() {
|
||||
// 游客双击:与点赞按钮一致守卫登录(《01-开发文档》2.1.1 游客不可点赞)
|
||||
if (!ref.read(authProvider).isLoggedIn) {
|
||||
context.go('/login');
|
||||
return;
|
||||
}
|
||||
setState(() => _likeBurstTrigger++);
|
||||
// 双击:未点赞等同点赞;已点赞只播动画不改状态
|
||||
if (!widget.item.isLiked) {
|
||||
widget.onLike(widget.item.videoId);
|
||||
}
|
||||
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
|
||||
}
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showRateSheet() {
|
||||
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _share() async {
|
||||
final text = '${widget.item.title}\n${widget.item.videoUrl}';
|
||||
await Share.share(text, subject: widget.item.title);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.pool.toggleCurrent,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
onLongPress: _onLongPress,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
@@ -74,10 +140,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
children: [
|
||||
// 底部渐变(提升文字可读性)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 160,
|
||||
left: 0, right: 0, bottom: 0, height: 160,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -91,9 +154,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// 播放器
|
||||
// 播放器(含抖音风格细进度条)
|
||||
VideoPlayerView(
|
||||
controller: widget.pool.controllerAt(widget.slotIndex),
|
||||
pool: widget.pool,
|
||||
slotIndex: widget.slotIndex,
|
||||
coverUrl: widget.item.coverUrl,
|
||||
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
|
||||
),
|
||||
@@ -101,24 +165,20 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
// 双击爱心动画
|
||||
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
|
||||
|
||||
// 右侧互动栏
|
||||
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 96,
|
||||
right: 8, bottom: 96,
|
||||
child: InteractionBar(
|
||||
item: widget.item,
|
||||
onLike: widget.onLike,
|
||||
onFavorite: widget.onFavorite,
|
||||
onComment: widget.onComment,
|
||||
onShare: widget.onShare,
|
||||
),
|
||||
),
|
||||
|
||||
// 左下信息区
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 88,
|
||||
bottom: 24,
|
||||
left: 12, right: 88, bottom: 24,
|
||||
child: VideoInfoSection(item: widget.item),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -58,13 +58,6 @@ class InteractionBar extends ConsumerWidget {
|
||||
count: item.favoriteCount,
|
||||
onTap: () => _guard(context, ref, () => onFavorite?.call(item.videoId)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ActionItem(
|
||||
icon: Icons.share_outlined,
|
||||
count: null,
|
||||
label: '分享',
|
||||
onTap: () => onShare?.call(item),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,221 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
|
||||
///
|
||||
/// 封面仅在视频尚未渲染出第一帧时显示(player state 仍在初始 idle),
|
||||
/// 一旦 HLS 起播第一帧,封面立即淡出,避免「有声音没画面被封面挡住」。
|
||||
class VideoPlayerView extends StatelessWidget {
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
|
||||
/// Feed 用的抖音风格视频播放器:
|
||||
/// - 覆盖 Video(controller: pool.controllerAt(slot))
|
||||
/// - 底部一条 2px 细线进度条(已播白 / 未播半透明白)
|
||||
/// - 按住拖动变厚 + 出现 thumb,松手 seek 后恢复
|
||||
class VideoPlayerView extends StatefulWidget {
|
||||
const VideoPlayerView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.pool,
|
||||
required this.slotIndex,
|
||||
required this.coverUrl,
|
||||
required this.playing,
|
||||
});
|
||||
|
||||
final VideoController controller;
|
||||
final FeedPlayerPool pool;
|
||||
final int slotIndex;
|
||||
final String coverUrl;
|
||||
final bool playing;
|
||||
|
||||
@override
|
||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||
}
|
||||
|
||||
class _VideoPlayerViewState extends State<VideoPlayerView> {
|
||||
late final PlayerSlot _slot = widget.pool.slotAt(widget.slotIndex);
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
bool _seeking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_slot.player.stream.position.listen((p) {
|
||||
if (mounted && !_seeking) setState(() => _position = p);
|
||||
});
|
||||
_slot.player.stream.duration.listen((d) {
|
||||
if (mounted) setState(() => _duration = d);
|
||||
});
|
||||
}
|
||||
|
||||
double get _value {
|
||||
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 _onSeekEnd(double v) {
|
||||
_seeking = false;
|
||||
final totalMs = _duration.inMilliseconds;
|
||||
if (totalMs > 0) {
|
||||
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = controller.player;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 播放器(无控制条,纯画面)
|
||||
// 底层:纯画面(media_kit 官方 Video,无控制条)
|
||||
Video(
|
||||
controller: controller,
|
||||
controller: _slot.controller,
|
||||
controls: NoVideoControls,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
||||
// 封面兜底:仅在视频尚未开始渲染时显示
|
||||
// 封面兜底:position==0 时显示(仅未渲染第一帧)
|
||||
StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
stream: _slot.player.stream.position,
|
||||
initialData: _slot.player.state.position,
|
||||
builder: (_, snapshot) {
|
||||
final pos = snapshot.data ?? Duration.zero;
|
||||
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
|
||||
final showCover =
|
||||
coverUrl.isNotEmpty && pos == Duration.zero;
|
||||
widget.coverUrl.isNotEmpty && pos == Duration.zero;
|
||||
return showCover
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
? Image.network(
|
||||
widget.coverUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// 暂停态大播放图标
|
||||
if (!playing)
|
||||
if (!widget.playing)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 88,
|
||||
color: Colors.white70,
|
||||
size: 88, color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 抖音风格细进度条(贴底,常驻)
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
value: _value,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _update(Offset local, double width) {
|
||||
final v = (local.dx / width).clamp(0.0, 1.0);
|
||||
_dragValue = v;
|
||||
widget.onChanged(v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (d) {
|
||||
setState(() => _dragging = true);
|
||||
widget.onChangeStart(widget.value);
|
||||
_update(d.localPosition, w);
|
||||
},
|
||||
onHorizontalDragUpdate: (d) => _update(d.localPosition, w),
|
||||
onHorizontalDragEnd: (_) {
|
||||
setState(() => _dragging = false);
|
||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||
},
|
||||
onTapDown: (d) {
|
||||
setState(() => _dragging = true);
|
||||
widget.onChangeStart(widget.value);
|
||||
_update(d.localPosition, w);
|
||||
},
|
||||
onTapUp: (_) {
|
||||
setState(() => _dragging = false);
|
||||
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
|
||||
},
|
||||
child: Container(
|
||||
height: 32, // 触摸热区 32px(视觉 2px,好点)
|
||||
alignment: Alignment.bottomCenter,
|
||||
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 h = dragging ? 3.0 : 2.0;
|
||||
final y = size.height / 2;
|
||||
|
||||
final track = Paint()
|
||||
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), track);
|
||||
|
||||
if (value > 0) {
|
||||
final played = Paint()
|
||||
..color = Colors.white
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = h;
|
||||
canvas.drawLine(Offset(0, y), Offset(value * size.width, y), played);
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
canvas.drawCircle(
|
||||
Offset(value * size.width, y), 6,
|
||||
Paint()..color = Colors.white..style = PaintingStyle.fill,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ProgressPainter old) =>
|
||||
value != old.value || dragging != old.dragging;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class _HomeShellState extends ConsumerState<HomeShell> {
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
height: 60,
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
|
||||
selectedIndex: _index,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
@@ -10,8 +12,10 @@ 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});
|
||||
|
||||
@@ -24,8 +28,21 @@ class SimplePlayerScreen extends StatefulWidget {
|
||||
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;
|
||||
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
|
||||
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() {
|
||||
@@ -33,6 +50,18 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
_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) {
|
||||
@@ -47,41 +76,62 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pauseIconTimer?.cancel();
|
||||
_posSub?.cancel();
|
||||
_durSub?.cancel();
|
||||
_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();
|
||||
// 短暂显示暂停/播放图标
|
||||
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);
|
||||
|
||||
@@ -103,7 +153,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 拖动条
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
@@ -114,7 +163,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 系列信息头
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
@@ -157,7 +205,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 集数网格
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
controller: controller,
|
||||
@@ -212,36 +259,68 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
}
|
||||
|
||||
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||
Navigator.of(context).pop(); // 关抽屉
|
||||
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, // 系列一般作者相同
|
||||
author: widget.item.author,
|
||||
);
|
||||
setState(() {
|
||||
_player.dispose();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_position = Duration.zero;
|
||||
_duration = Duration.zero;
|
||||
_open(newItem);
|
||||
// 重置 seriesData 重新加载
|
||||
_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,
|
||||
@@ -249,7 +328,9 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
if (!_playing)
|
||||
|
||||
// 中央播放/暂停图标(短暂显示)
|
||||
if (_showPauseIcon)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
@@ -259,49 +340,227 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部:返回 + 系列按钮
|
||||
|
||||
// ---------- 顶部:返回 + 倍速 + 系列 ----------
|
||||
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,
|
||||
),
|
||||
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: 12, right: 12, bottom: 24,
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'@${widget.item.author.nickname}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
// 标题信息(贴底,不占用视频观看空间)
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 进度条:抖音风格一条细线,覆盖视频底部
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user