优化:卡片播放视频增加淡入效果
This commit is contained in:
parent
f5bb5ff346
commit
a8c9a59167
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async' show unawaited;
|
||||||
import 'dart:io' show File;
|
import 'dart:io' show File;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -39,6 +40,8 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
/// 递增以作废过期的异步 [ _loadAndPlay ],避免多路初始化互相抢同一个 State
|
/// 递增以作废过期的异步 [ _loadAndPlay ],避免多路初始化互相抢同一个 State
|
||||||
int _loadGen = 0;
|
int _loadGen = 0;
|
||||||
|
/// 视频层是否已淡入(先 0 再置 true,触发 [AnimatedOpacity])
|
||||||
|
bool _videoOpacityTarget = false;
|
||||||
/// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现)
|
/// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现)
|
||||||
bool _showBottomProgress = false;
|
bool _showBottomProgress = false;
|
||||||
/// 0–1 为确定进度;null 为不确定(无 Content-Length 等)
|
/// 0–1 为确定进度;null 为不确定(无 Content-Length 等)
|
||||||
@ -71,7 +74,50 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
void _stop() {
|
void _stop() {
|
||||||
_controller?.removeListener(_onVideoUpdate);
|
_controller?.removeListener(_onVideoUpdate);
|
||||||
_controller?.pause();
|
_controller?.pause();
|
||||||
if (mounted) setState(() {});
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_videoOpacityTarget = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 淡入略长 + easeInOut,避免「闪一下」的感觉
|
||||||
|
static const Duration _videoFadeDuration = Duration(milliseconds: 820);
|
||||||
|
/// 在 [play] 之后最多等到首帧再开始淡入,否则解码器突然出画会盖过 opacity 动画
|
||||||
|
static const Duration _videoFadeLeadInDeadline = Duration(milliseconds: 1200);
|
||||||
|
|
||||||
|
void _scheduleVideoFadeIn(int gen) {
|
||||||
|
unawaited(_runVideoFadeInSequence(gen));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runVideoFadeInSequence(int gen) async {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !mounted || gen != _loadGen || !widget.isActive) return;
|
||||||
|
|
||||||
|
// 与「视频层首帧 build」错开,保证 AnimatedOpacity 从 0 开始做完整动画
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||||
|
if (!mounted || gen != _loadGen || !widget.isActive) return;
|
||||||
|
|
||||||
|
final leadInEnd = DateTime.now().add(_videoFadeLeadInDeadline);
|
||||||
|
while (mounted && gen == _loadGen && widget.isActive && DateTime.now().isBefore(leadInEnd)) {
|
||||||
|
final v = c.value;
|
||||||
|
if (v.isInitialized && v.isPlaying) {
|
||||||
|
final likelyHasFrame = v.position > const Duration(milliseconds: 56) ||
|
||||||
|
(!v.isBuffering && v.size.width > 1 && v.size.height > 1);
|
||||||
|
if (likelyHasFrame) break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 24));
|
||||||
|
}
|
||||||
|
if (!mounted || gen != _loadGen || !widget.isActive) return;
|
||||||
|
|
||||||
|
// 再给 Texture 一瞬提交首帧,减轻与透明度不同步的突兀感
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 52));
|
||||||
|
if (!mounted || gen != _loadGen || !widget.isActive) return;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || gen != _loadGen || !widget.isActive) return;
|
||||||
|
setState(() => _videoOpacityTarget = true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _disposeController() {
|
void _disposeController() {
|
||||||
@ -113,7 +159,8 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
_stop();
|
_stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() => _videoOpacityTarget = false);
|
||||||
|
_scheduleVideoFadeIn(gen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,12 +222,14 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
_disposeController();
|
_disposeController();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() => _videoOpacityTarget = false);
|
||||||
|
_scheduleVideoFadeIn(gen);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_disposeController();
|
_disposeController();
|
||||||
setState(() {
|
setState(() {
|
||||||
_clearBottomProgress();
|
_clearBottomProgress();
|
||||||
|
_videoOpacityTarget = false;
|
||||||
});
|
});
|
||||||
widget.onStopRequested();
|
widget.onStopRequested();
|
||||||
}
|
}
|
||||||
@ -193,7 +242,12 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
|
final hasVideoUrl =
|
||||||
|
widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||||
|
final videoInitialized =
|
||||||
|
_controller != null && _controller!.value.isInitialized;
|
||||||
|
final showVideoLayer =
|
||||||
|
widget.isActive && hasVideoUrl && videoInitialized;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -216,38 +270,45 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (showVideo)
|
Positioned.fill(
|
||||||
Positioned.fill(
|
child: GestureDetector(
|
||||||
child: GestureDetector(
|
onTap: widget.onGenerateSimilar,
|
||||||
onTap: widget.onGenerateSimilar,
|
behavior: HitTestBehavior.opaque,
|
||||||
behavior: HitTestBehavior.opaque,
|
child: CachedNetworkImage(
|
||||||
child: FittedBox(
|
imageUrl: widget.imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
child: SizedBox(
|
placeholder: (_, __) => Container(
|
||||||
width: _controller!.value.size.width > 0
|
color: AppColors.surfaceAlt,
|
||||||
? _controller!.value.size.width
|
),
|
||||||
: 16,
|
errorWidget: (_, __, ___) => Container(
|
||||||
height: _controller!.value.size.height > 0
|
color: AppColors.surfaceAlt,
|
||||||
? _controller!.value.size.height
|
|
||||||
: 9,
|
|
||||||
child: VideoPlayer(_controller!),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
else
|
),
|
||||||
|
if (showVideoLayer)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: IgnorePointer(
|
||||||
onTap: widget.onGenerateSimilar,
|
ignoring: !_videoOpacityTarget,
|
||||||
behavior: HitTestBehavior.opaque,
|
child: AnimatedOpacity(
|
||||||
child: CachedNetworkImage(
|
opacity: _videoOpacityTarget ? 1.0 : 0.0,
|
||||||
imageUrl: widget.imageUrl,
|
duration: _videoFadeDuration,
|
||||||
fit: BoxFit.cover,
|
curve: Curves.easeInOutCubic,
|
||||||
placeholder: (_, __) => Container(
|
child: GestureDetector(
|
||||||
color: AppColors.surfaceAlt,
|
onTap: widget.onGenerateSimilar,
|
||||||
),
|
behavior: HitTestBehavior.opaque,
|
||||||
errorWidget: (_, __, ___) => Container(
|
child: FittedBox(
|
||||||
color: AppColors.surfaceAlt,
|
fit: BoxFit.cover,
|
||||||
|
child: SizedBox(
|
||||||
|
width: _controller!.value.size.width > 0
|
||||||
|
? _controller!.value.size.width
|
||||||
|
: 16,
|
||||||
|
height: _controller!.value.size.height > 0
|
||||||
|
? _controller!.value.size.height
|
||||||
|
: 9,
|
||||||
|
child: VideoPlayer(_controller!),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user