fix(player): 重构单层过渡并阻止锁定集串播

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:41:18 +08:00
1 parent 70d8e0f982
commit 0cb0ab0613
8 files changed
+657 -335

No files matched your search

@@ -10,6 +10,26 @@ extension PlaybackSessionControls on PlaybackSession {
_warmNeighbors(this, preferredNext: forward); _warmNeighbors(this, preferredNext: forward);
} }
/// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。
Future<void> selectLocked(
int index, {
int? preloadIndex,
int? previousIndex,
}) {
if (_closed || index < 0 || index >= episodes.length) {
return Future<void>.value();
}
_generation++;
_selected = false;
_currentIndex = index;
_rememberNeighbors(preloadIndex, previousIndex);
_loading = false;
_error = null;
_userWantsPlay = false;
_notify();
return _releasePlayers();
}
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
PlayerPort? handoffAt(int index) { PlayerPort? handoffAt(int index) {
final slot = _handoff; final slot = _handoff;
+29 -2
View File
@@ -100,6 +100,7 @@ class PlayerLogic extends ChangeNotifier {
bool pip = false; bool pip = false;
bool pipPreparing = false; bool pipPreparing = false;
final Map<String, int> pickedEpisode = {}; final Map<String, int> pickedEpisode = {};
final Set<String> _unlockedEpisodeIds = {};
/// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。
String? unlockingEpisodeId; String? unlockingEpisodeId;
@@ -238,7 +239,8 @@ class PlayerLogic extends ChangeNotifier {
episodes: sessionEpisodes, episodes: sessionEpisodes,
factory: factory, factory: factory,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty, canPlay: (episode) =>
episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode),
prepare: preparePlayer, prepare: preparePlayer,
activate: activatePlayer, activate: activatePlayer,
coordinator: coordinator, coordinator: coordinator,
@@ -428,6 +430,7 @@ class PlayerLogic extends ChangeNotifier {
final localPath = await downloads?.findLocalPath(episode.id); final localPath = await downloads?.findLocalPath(episode.id);
if (request != pageRequest) return false; if (request != pageRequest) return false;
if (localPath != null) { if (localPath != null) {
_unlockedEpisodeIds.add(episode.id);
blockedTicket = null; blockedTicket = null;
blockedEpisodeId = null; blockedEpisodeId = null;
qualities = const []; qualities = const [];
@@ -452,6 +455,7 @@ class PlayerLogic extends ChangeNotifier {
final playUrl = preferred ?? ticket.playUrl; final playUrl = preferred ?? ticket.playUrl;
if (preferred == null) selectedQuality = 'AUTO'; if (preferred == null) selectedQuality = 'AUTO';
if (ticket.accessible && playUrl.isNotEmpty) { if (ticket.accessible && playUrl.isNotEmpty) {
_unlockedEpisodeIds.add(episode.id);
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl( sessionEpisodes[index] = episode.withVideoUrl(
playUrl, playUrl,
@@ -467,7 +471,7 @@ class PlayerLogic extends ChangeNotifier {
} catch (exception) { } catch (exception) {
if (request != pageRequest) return false; if (request != pageRequest) return false;
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
if (episode.videoUrl.isNotEmpty) { if (episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode)) {
ticketError = null; ticketError = null;
return true; return true;
} }
@@ -566,6 +570,7 @@ class PlayerLogic extends ChangeNotifier {
unlockError = service.unlockError; unlockError = service.unlockError;
return false; return false;
} }
_unlockedEpisodeIds.add(episode.id);
final ready = await ensureTicket(index, forceRefresh: true); final ready = await ensureTicket(index, forceRefresh: true);
if (ready) { if (ready) {
await _selectVisible(index); await _selectVisible(index);
@@ -962,6 +967,22 @@ class PlayerLogic extends ChangeNotifier {
!isMounted()) { !isMounted()) {
return; return;
} }
final episode = sessionEpisodes[index];
if (isEpisodeLocked(episode)) {
blockedTicket = null;
blockedEpisodeId = episode.id;
ticketError = null;
await session.selectLocked(
index,
preloadIndex: nextVisibleIndex(index),
previousIndex: previousVisibleIndex(index),
);
if (request != pageRequest ||
feedRevisionAtStart != feedRevision ||
!isMounted()) {
return;
}
}
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index); final ready = await ensureTicket(index);
if (request != pageRequest || if (request != pageRequest ||
@@ -973,6 +994,12 @@ class PlayerLogic extends ChangeNotifier {
await _selectVisible(index); await _selectVisible(index);
} }
/// 收费集只有在本会话确认可播或解锁成功后才解除门禁。
bool isEpisodeLocked(Episode episode) {
return (episode.locked || !episode.isFree) &&
!_unlockedEpisodeIds.contains(episode.id);
}
/// 按可见队列选择一集,并预热上一集与下一集。 /// 按可见队列选择一集,并预热上一集与下一集。
Future<void> _selectVisible(int index) { Future<void> _selectVisible(int index) {
return session.select( return session.select(
+36 -31
View File
@@ -146,9 +146,8 @@ class _PlayerPageState extends State<PlayerPage>
_thumbnailService = PlaybackThumbnailService( _thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource, sourceResolver: _resolveThumbnailSource,
); );
_spriteAxisService = SpriteThumbnailAxisService( _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
spriteCacheCapacity: 8, ..addListener(_handleSpriteFrameChanged);
)..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
logic.start( logic.start(
factory: context.read<PlayerFactory>(), factory: context.read<PlayerFactory>(),
@@ -389,10 +388,9 @@ class _PlayerPageState extends State<PlayerPage>
continue; continue;
} }
final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl;
if (thumbnailsUrl != null) { if (thumbnailsUrl != null &&
unawaited( !logic.isEpisodeLocked(logic.sessionEpisodes[index])) {
_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
);
} }
} }
} }
@@ -603,33 +601,36 @@ class _PlayerPageState extends State<PlayerPage>
) )
: displayed; : displayed;
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
final locked =
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
// Selection changes before the prepared slot is promoted. Use its controller // Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder. // throughout the handoff instead of briefly falling back to a placeholder.
final port = final port = locked
(selected ? logic.session.active : null) ?? ? null
logic.session.preparedPlayerAt(index); : (selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null; final videoPort = port is VideoPlayerPort ? port : null;
final handoff = selected ? logic.session.handoffAt(index) : null; final handoff = selected && !locked ? logic.session.handoffAt(index) : null;
final handoffPort = handoff is VideoPlayerPort ? handoff : null; final handoffPort = handoff is VideoPlayerPort ? handoff : null;
// 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。
final videoReady = videoPort?.controller.value.isInitialized ?? false; final videoReady = videoPort?.controller.value.isInitialized ?? false;
if (selected && videoReady) _logSwipeReveal(); if (selected && videoReady) _logSwipeReveal();
final ready = videoReady || port != null; final ready = !locked && (videoReady || port != null);
final theater = logic.pip || logic.pipPreparing; final theater = logic.pip || logic.pipPreparing;
final videoFit = theater ? BoxFit.contain : BoxFit.cover; final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable = final preparedFrameAvailable =
selected && logic.session.reusedWarmAt(index); selected && !locked && logic.session.reusedWarmAt(index);
final thumbnailsUrl = episode.thumbnailsUrl; final thumbnailsUrl = episode.thumbnailsUrl;
final openingFrameReady = final openingFrameReady =
thumbnailsUrl != null && thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
if (thumbnailsUrl != null) { if (thumbnailsUrl != null) {
unawaited( unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero),
);
} }
final locked = logic.session.isLocked || episode.locked;
if (selected && if (selected &&
!locked &&
videoPort != null && videoPort != null &&
videoPort.controller.value.isInitialized) { videoPort.controller.value.isInitialized) {
unawaited( unawaited(
@@ -649,20 +650,27 @@ class _PlayerPageState extends State<PlayerPage>
color: AppColors.mediaBlack, color: AppColors.mediaBlack,
), ),
PlayerVideoSurface( PlayerVideoSurface(
controller: videoPort?.controller, controller: locked ? null : videoPort?.controller,
handoff: handoffPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater, theater: theater,
holdOpeningUntilFirstFrame: logic.session.userWantsPlay, warmFrameAvailable: preparedFrameAvailable,
preparedFrameAvailable: preparedFrameAvailable, thumbnailReady: openingFrameReady,
openingFrameReady: openingFrameReady, waitForPlaybackFrame: logic.session.userWantsPlay,
openingFrame: playerOpeningFrame( placeholder: locked
thumbnailsUrl, ? null
spriteSource: _spriteAxisService, : handoffPort != null
fit: videoFit, ? ControllerFramePlaceholder(
), controller: handoffPort.controller,
theater: theater,
)
: playerOpeningFrame(
thumbnailsUrl,
spriteSource: _spriteAxisService,
fit: videoFit,
),
), ),
if (selected && if (selected &&
!locked &&
!theater && !theater &&
!ready && !ready &&
!logic.ticketLoading && !logic.ticketLoading &&
@@ -884,10 +892,7 @@ class _PlayerPageState extends State<PlayerPage>
controlsVisible: _chromeVisible, controlsVisible: _chromeVisible,
), ),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && if (selected && !theater && locked)
!theater &&
!ready &&
logic.blockedEpisodeId == episode.id)
PlayerBlockedNotice( PlayerBlockedNotice(
l10n: l10n, l10n: l10n,
episode: episode, episode: episode,
+247 -165
View File
@@ -6,37 +6,36 @@ import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
const Duration _crossFadeDuration = Duration(milliseconds: 180);
const double _errorHorizontal = 32; const double _errorHorizontal = 32;
const int _errorMaxLines = 3; const int _errorMaxLines = 3;
/// 黑色底层与视频层共用同一控制器。清晰度切换时用仍在播放的旧画面盖住新纹理的黑帧。 /// 播放区只维护一个占位和一个视频层,首帧确认后执行一次交叉淡化。
class PlayerVideoSurface extends StatefulWidget { class PlayerVideoSurface extends StatefulWidget {
const PlayerVideoSurface({ const PlayerVideoSurface({
super.key, super.key,
required this.controller, required this.controller,
required this.videoUrl, required this.videoUrl,
required this.theater, required this.theater,
this.handoff, this.placeholder,
this.openingFrame, this.warmFrameAvailable = false,
this.openingFrameReady = false, this.thumbnailReady = false,
this.holdOpeningUntilFirstFrame = false, this.waitForPlaybackFrame = false,
this.preparedFrameAvailable = false,
}); });
final VideoPlayerController? controller; final VideoPlayerController? controller;
final VideoPlayerController? handoff;
/// 解码出第一帧之前垫上的视频首帧,没有则为空。 /// 视频首帧未确认时唯一的占位 widget。
final Widget? openingFrame; final Widget? placeholder;
/// 当前集雪碧图 t=0 是否已在共享缓存中可用。 /// 相邻集预热槽已可用时直接出视频,不挂载占位。
final bool openingFrameReady; final bool warmFrameAvailable;
/// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。
final bool holdOpeningUntilFirstFrame; final bool thumbnailReady;
/// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 /// 自动播放中的新集等待 position 首次前进后再移交画面。
final bool preparedFrameAvailable; final bool waitForPlaybackFrame;
final String videoUrl; final String videoUrl;
final bool theater; final bool theater;
@@ -44,53 +43,87 @@ class PlayerVideoSurface extends StatefulWidget {
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState(); State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
} }
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> { class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
String? _lastDebugState; with SingleTickerProviderStateMixin {
String? _lastLayerState; late final AnimationController _crossFade;
VideoPlayerController? _firstFrameController; VideoPlayerController? _attachedController;
Widget? _placeholderSnapshot;
Size? _videoSize;
bool _playbackStarted = false; bool _playbackStarted = false;
bool _firstFrameSeen = false; bool _frameConfirmed = false;
bool _confirmationScheduled = false;
bool _transitioning = false;
bool _videoVisible = false;
String? _lastLayerState;
@override
void initState() {
super.initState();
_crossFade = AnimationController(vsync: this, duration: _crossFadeDuration)
..addStatusListener(_handleFadeStatus);
_placeholderSnapshot = widget.placeholder;
_attachController(widget.controller);
final value = widget.controller?.value;
_updatePlaybackFlags(value);
_videoSize = value != null && value.isInitialized ? value.size : null;
if (_firstFrameConfirmed(value)) {
_confirmFirstFrame(initial: true);
}
}
@override @override
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) { if (oldWidget.controller != widget.controller) {
_lastDebugState = null; _detachController(oldWidget.controller);
_lastLayerState = null; _resetForControllerChange();
_firstFrameController = null; _attachController(widget.controller);
_playbackStarted = false; final value = widget.controller?.value;
_firstFrameSeen = false; _updatePlaybackFlags(value);
_videoSize = value != null && value.isInitialized ? value.size : null;
if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true);
return;
} }
if (!_transitioning &&
!_videoVisible &&
oldWidget.placeholder != widget.placeholder) {
_placeholderSnapshot = widget.placeholder;
}
if (oldWidget.thumbnailReady != widget.thumbnailReady ||
oldWidget.warmFrameAvailable != widget.warmFrameAvailable) {
final value = widget.controller?.value;
_updatePlaybackFlags(value);
if (!_frameConfirmed && _firstFrameConfirmed(value)) {
_confirmFirstFrame();
} else {
_logLayer('placeholder', value);
}
}
}
@override
void dispose() {
_detachController(widget.controller);
_crossFade
..removeStatusListener(_handleFadeStatus)
..dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = widget.controller; final controller = widget.controller;
if (controller == null) return _frame(null); final value = controller?.value;
return ValueListenableBuilder<VideoPlayerValue>( final videoSize = _videoSize;
valueListenable: controller, final showVideo = controller != null && videoSize != null;
builder: (context, value, _) => _frame(value), final showPlaceholder = !_videoVisible && _placeholderSnapshot != null;
); final video = showVideo
} ? _VideoLayer(
controller: controller,
Widget _frame(VideoPlayerValue? value) { size: videoSize,
final controller = widget.controller; theater: widget.theater,
final handoff = _liveHandoff(controller); )
final firstFrameSeen = _trackFrameState(controller, value); : null;
final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen;
final textureReady = _shouldReveal(value, handoff != null);
final reveal =
textureReady &&
(!widget.holdOpeningUntilFirstFrame ||
widget.preparedFrameAvailable ||
firstFrameReady ||
_playbackStarted &&
value?.isPlaying == false &&
value?.isBuffering != true);
final showOpening =
widget.openingFrame != null && handoff == null && !reveal;
_logStateChange(value);
_logLayerState(value, handoff, reveal, firstFrameReady, showOpening);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -98,29 +131,18 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
key: Key('player-fallback-background'), key: Key('player-fallback-background'),
color: AppColors.mediaBlack, color: AppColors.mediaBlack,
), ),
if (showOpening) if (showPlaceholder)
KeyedSubtree( KeyedSubtree(
key: const Key('player-opening-frame'), key: const Key('player-single-placeholder'),
child: widget.openingFrame!, child: _placeholderSnapshot!,
), ),
AnimatedOpacity( if (_videoVisible && video != null)
key: const Key('player-video-opacity'), KeyedSubtree(key: const Key('player-video-layer'), child: video)
opacity: reveal ? 1 : 0, else if (_transitioning && video != null)
duration: Duration.zero, FadeTransition(
child: reveal && controller != null key: const Key('player-video-crossfade'),
? _VideoLayer( opacity: _crossFade,
controller: controller, child: video,
value: value!,
theater: widget.theater,
)
: const SizedBox.expand(),
),
if (handoff != null)
_VideoLayer(
key: const Key('player-video-handoff'),
controller: handoff,
value: handoff.value,
theater: widget.theater,
), ),
if (kDebugMode && value?.hasError == true) if (kDebugMode && value?.hasError == true)
Center( Center(
@@ -140,134 +162,194 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
); );
} }
bool _shouldReveal(VideoPlayerValue? value, bool covered) { void _attachController(VideoPlayerController? controller) {
if (covered) return value?.isInitialized ?? false; _attachedController = controller;
if (value == null || value.hasError || !value.isInitialized) return false; controller?.addListener(_handleControllerChanged);
return value.size.width > 0 && value.size.height > 0;
} }
bool _trackFrameState( void _detachController(VideoPlayerController? controller) {
VideoPlayerController? controller, controller?.removeListener(_handleControllerChanged);
VideoPlayerValue? value, if (_attachedController == controller) _attachedController = null;
) { }
if (!identical(_firstFrameController, controller)) {
_firstFrameController = controller; void _handleControllerChanged() {
_playbackStarted = false; final value = widget.controller?.value;
_firstFrameSeen = false; final nextSize = value != null && value.isInitialized ? value.size : null;
final sizeChanged = nextSize != _videoSize;
_updatePlaybackFlags(value);
_videoSize = nextSize;
if (!_frameConfirmed && _firstFrameConfirmed(value)) {
_scheduleFirstFrameConfirmation();
} else if (sizeChanged && mounted) {
setState(() {});
} }
if (value == null) return false;
_playbackStarted = _playbackStarted || value.isPlaying;
_firstFrameSeen =
_firstFrameSeen || value.position > Duration.zero || value.isCompleted;
return _firstFrameSeen;
} }
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 void _updatePlaybackFlags(VideoPlayerValue? value) {
VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { _playbackStarted = _playbackStarted || value?.isPlaying == true;
final handoff = widget.handoff;
if (handoff == null || identical(handoff, controller)) return null;
return handoff;
} }
void _logStateChange(VideoPlayerValue? value) { void _scheduleFirstFrameConfirmation() {
if (_confirmationScheduled || _frameConfirmed) return;
_confirmationScheduled = true;
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'phase=first-frame-scheduled',
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'phase=first-frame-callback mounted=$mounted',
);
}
_confirmationScheduled = false;
if (!mounted || _frameConfirmed) return;
if (_firstFrameConfirmed(widget.controller?.value)) {
_confirmFirstFrame();
}
});
WidgetsBinding.instance.scheduleFrame();
}
bool _firstFrameConfirmed(VideoPlayerValue? value) {
if (value == null ||
!value.isInitialized ||
value.hasError ||
value.size.width <= 0 ||
value.size.height <= 0) {
return false;
}
if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true;
if (value.position > Duration.zero || value.isCompleted) return true;
return _playbackStarted && !value.isPlaying && !value.isBuffering;
}
void _confirmFirstFrame({bool initial = false}) {
if (_frameConfirmed || _videoSize == null) return;
_frameConfirmed = true;
final canSkipPlaceholder =
widget.warmFrameAvailable || _placeholderSnapshot == null;
if (canSkipPlaceholder) {
if (initial) {
_videoVisible = true;
_placeholderSnapshot = null;
} else {
setState(() {
_videoVisible = true;
_placeholderSnapshot = null;
});
}
_logLayer('video', widget.controller?.value);
return;
}
if (initial) {
_transitioning = true;
_logLayer('fade-start', widget.controller?.value);
_crossFade.forward(from: 0);
} else {
setState(() {
_transitioning = true;
_placeholderSnapshot = widget.placeholder;
});
_logLayer('fade-start', widget.controller?.value);
_crossFade.forward(from: 0);
}
}
void _handleFadeStatus(AnimationStatus status) {
if (status != AnimationStatus.completed || !mounted) return;
setState(() {
_transitioning = false;
_videoVisible = true;
_placeholderSnapshot = null;
});
_logLayer('fade-end', widget.controller?.value);
}
void _resetForControllerChange() {
_crossFade.reset();
_placeholderSnapshot = widget.placeholder;
_videoSize = null;
_playbackStarted = false;
_frameConfirmed = false;
_confirmationScheduled = false;
_transitioning = false;
_videoVisible = false;
_lastLayerState = null;
}
void _logLayer(String phase, VideoPlayerValue? value) {
if (!kDebugMode) return; if (!kDebugMode) return;
final status = _debugStatus(value); final fallback = phase == 'video' || phase == 'fade-end'
final signature =
'$status|${value?.isInitialized ?? false}|'
'${value?.errorDescription ?? 'none'}';
if (signature == _lastDebugState) return;
_lastDebugState = signature;
final source = widget.controller?.dataSource ?? widget.videoUrl;
debugPrint(
'[video] init=${value?.isInitialized ?? false} '
'err=${value?.errorDescription ?? 'none'} host=${_host(source)}',
);
}
void _logLayerState(
VideoPlayerValue? value,
VideoPlayerController? handoff,
bool reveal,
bool firstFrameReady,
bool showOpening,
) {
if (!kDebugMode) return;
final reason = handoff != null
? 'handoff'
: !_shouldReveal(value, handoff != null)
? 'uninitialized'
: widget.preparedFrameAvailable
? 'warm-frame'
: !firstFrameReady && widget.holdOpeningUntilFirstFrame
? 'awaiting-first-frame'
: 'reveal';
final fallback = handoff != null
? 'handoff'
: showOpening
? widget.openingFrameReady
? 'thumbnails-first-frame'
: 'black'
: reveal
? 'video' ? 'video'
: _placeholderSnapshot == null
? 'black'
: widget.thumbnailReady
? 'thumbnails-first-frame'
: 'black'; : 'black';
final signature = final signature =
'warm=${widget.preparedFrameAvailable ? 1 : 0} ' 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
'frame=${firstFrameReady ? 1 : 0} ' 'frame=${_frameConfirmed ? 1 : 0} '
'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} ' 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} '
'fallback=$fallback ' 'fallback=$fallback '
'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} '
'init=${value?.isInitialized == true ? 1 : 0} ' 'init=${value?.isInitialized == true ? 1 : 0} '
'position=${value?.position.inMilliseconds ?? 0} '
'playing=${value?.isPlaying == true ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} '
'buffering=${value?.isBuffering == true ? 1 : 0} ' 'buffering=${value?.isBuffering == true ? 1 : 0}';
'reason=$reason';
if (signature == _lastLayerState) return; if (signature == _lastLayerState) return;
_lastLayerState = signature; _lastLayerState = signature;
debugPrint('[player-layer] $signature'); debugPrint(
} '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature',
);
String _debugStatus(VideoPlayerValue? value) {
if (value == null) return 'loading';
if (value.hasError) {
return 'error: ${value.errorDescription ?? 'unknown'}';
}
if (value.isBuffering) return 'buffering';
if (value.isInitialized) return 'initialized';
return 'loading';
}
String _host(String source) {
final uri = Uri.tryParse(source);
if (uri == null || uri.host.isEmpty) return 'unknown';
final port = uri.hasPort
? uri.port
: uri.scheme == 'https'
? 443
: 80;
return '${uri.host}:$port';
} }
} }
class _VideoLayer extends StatelessWidget { /// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。
const _VideoLayer({ class ControllerFramePlaceholder extends StatelessWidget {
const ControllerFramePlaceholder({
super.key, super.key,
required this.controller, required this.controller,
required this.value,
required this.theater, required this.theater,
}); });
final VideoPlayerController controller; final VideoPlayerController controller;
final VideoPlayerValue value;
final bool theater; final bool theater;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = controller.value.size;
return FittedBox( return FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover, fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox( child: SizedBox(
width: value.size.width, width: size.width,
height: value.size.height, height: size.height,
child: VideoPlayer(controller, key: ValueKey(controller)), child: VideoPlayer(controller, key: ValueKey(controller)),
), ),
); );
} }
} }
class _VideoLayer extends StatelessWidget {
const _VideoLayer({
required this.controller,
required this.size,
required this.theater,
});
final VideoPlayerController controller;
final Size size;
final bool theater;
@override
Widget build(BuildContext context) => FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox(
width: size.width,
height: size.height,
child: VideoPlayer(controller, key: ValueKey(controller)),
),
);
}
+86
View File
@@ -32,6 +32,92 @@ void main() {
await session.close(); await session.close();
}); });
test('should_release_previous_player_immediately_when_selecting_locked', () async {
final lockedEpisodes = [
Episode(
id: 'free',
number: 1,
title: 'Free',
isFree: true,
videoUrl: 'https://example.test/free.mp4',
),
Episode(
id: 'locked',
number: 2,
title: 'Locked',
isFree: false,
locked: true,
videoUrl: 'https://example.test/should-not-play.mp4',
),
Episode(
id: 'next',
number: 3,
title: 'Next',
isFree: true,
videoUrl: 'https://example.test/next.mp4',
),
];
final factory = FakeFactory();
final session = PlaybackSession(
episodes: lockedEpisodes,
factory: factory.create,
canPlay: (episode) => !episode.locked,
);
await session.select(0, preloadIndex: 1);
final previous = factory.created.single;
await session.selectLocked(1, preloadIndex: 2, previousIndex: 0);
await session.playbackSettled;
expect(session.active, isNull);
expect(previous.pauseCalls, greaterThan(0));
expect(previous.disposed, isTrue);
expect(factory.live, 0);
expect(factory.created, hasLength(1));
await session.select(2, previousIndex: 1);
expect(session.active, same(factory.created.last));
expect(factory.created.last.playCalls, greaterThan(0));
await session.close();
});
test('should_play_locked_episode_only_after_it_is_unlocked', () async {
final lockedEpisodes = [
Episode(
id: 'free',
number: 1,
title: 'Free',
isFree: true,
videoUrl: 'https://example.test/free.mp4',
),
Episode(
id: 'locked',
number: 2,
title: 'Locked',
isFree: false,
locked: true,
videoUrl: 'https://example.test/locked.mp4',
),
];
final unlocked = <String>{};
final factory = FakeFactory();
final session = PlaybackSession(
episodes: lockedEpisodes,
factory: factory.create,
canPlay: (episode) => episode.isFree || unlocked.contains(episode.id),
);
await session.select(0);
await session.selectLocked(1);
expect(session.active, isNull);
unlocked.add('locked');
await session.select(1);
expect(session.active, same(factory.created.last));
expect(factory.created.last.playCalls, greaterThan(0));
await session.close();
});
test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async { test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async {
final factory = FakeFactory(holdFirstInitialize: true); final factory = FakeFactory(holdFirstInitialize: true);
final session = PlaybackSession( final session = PlaybackSession(
+11
View File
@@ -1031,6 +1031,14 @@ void main() {
await _pumpApp( await _pumpApp(
tester, tester,
catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), catalog: FakeCatalogRepository(dramas: const [_lockedDrama]),
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'locked-demo-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
),
); );
await _openSeriesPlayer( await _openSeriesPlayer(
tester, tester,
@@ -1040,6 +1048,9 @@ void main() {
expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); expect(find.byKey(const Key('progress-loading-highlight')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing); expect(find.byType(Slider), findsNothing);
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
expect( expect(
tester tester
.widget<SeekProgressBar>(find.byKey(const Key('player-progress'))) .widget<SeekProgressBar>(find.byKey(const Key('player-progress')))
+138
View File
@@ -11,12 +11,15 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/core/data/models/wallet.dart';
import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
@@ -46,6 +49,37 @@ Drama _drama(
], ],
); );
Drama _gateDrama() => const Drama(
id: 'gate',
title: 'Gate',
synopsis: 'Gate synopsis',
posterUrl: '/files/gate.png',
episodes: [
Episode(
id: 'gate-1',
number: 1,
title: 'Free',
isFree: true,
videoUrl: 'https://example.test/gate-1.mp4',
),
Episode(
id: 'gate-2',
number: 2,
title: 'Locked',
isFree: false,
locked: true,
videoUrl: 'https://example.test/gate-2.mp4',
),
Episode(
id: 'gate-3',
number: 3,
title: 'Next',
isFree: true,
videoUrl: 'https://example.test/gate-3.mp4',
),
],
);
AndroidPictureInPicture _pip() => AndroidPictureInPicture _pip() =>
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
@@ -53,11 +87,13 @@ PlayerLogic _logic(
FakeWatchHistory history, { FakeWatchHistory history, {
PlaybackRepository? playback, PlaybackRepository? playback,
PlaybackPositionCache? positionCache, PlaybackPositionCache? positionCache,
WalletController? wallet,
Future<void> Function()? waitForFrame, Future<void> Function()? waitForFrame,
}) => PlayerLogic( }) => PlayerLogic(
history: history, history: history,
playback: playback ?? FakePlaybackRepository(), playback: playback ?? FakePlaybackRepository(),
pictureInPicture: _pip(), pictureInPicture: _pip(),
wallet: wallet,
positionCache: positionCache, positionCache: positionCache,
waitForFrame: waitForFrame ?? () async {}, waitForFrame: waitForFrame ?? () async {},
); );
@@ -581,6 +617,88 @@ void main() {
await _drain(logic); await _drain(logic);
}); });
test('should_pause_and_release_when_swiping_to_locked_episode', () async {
final factory = _RecordingFactory();
final playback = FakePlaybackRepository();
final logic = _logic(FakeWatchHistory(), playback: playback);
logic.attach(
PlayerPage(drama: _gateDrama(), initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final previous = factory.created.single;
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
);
await logic.selectEpisode(1);
await _drain(logic);
expect(previous.pauses, greaterThan(0));
expect(previous.disposed, isTrue);
expect(logic.session.active, isNull);
expect(factory.live, 0);
expect(factory.created, hasLength(1));
expect(logic.blockedEpisodeId, 'gate-2');
playback.ticket = null;
await logic.selectEpisode(2);
await _drain(logic);
expect(logic.session.active, isNotNull);
expect(factory.created.last.plays, greaterThan(0));
logic.dispose();
await _drain(logic);
});
test('should_start_locked_episode_after_unlock_succeeds', () async {
final factory = _RecordingFactory();
final playback = FakePlaybackRepository();
final wallet = WalletController(_UnlockCommerceRepository());
final logic = _logic(
FakeWatchHistory(),
playback: playback,
wallet: wallet,
);
logic.attach(
PlayerPage(drama: _gateDrama(), initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
);
await logic.selectEpisode(1);
await _drain(logic);
expect(logic.session.active, isNull);
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: true,
accessType: PlayTicket.coinRequired,
playUrl: '/files/gate-2.mp4',
);
final unlocked = await logic.unlockEpisode(1);
await _drain(logic);
expect(unlocked, isTrue);
expect(logic.isEpisodeLocked(logic.sessionEpisodes[1]), isFalse);
final active = logic.session.active;
expect(active, isA<_RecordingPlayer>());
expect((active! as _RecordingPlayer).plays, greaterThan(0));
logic.dispose();
await _drain(logic);
});
test('should_preload_only_what_follows_when_starting_from_a_later_index', () async { test('should_preload_only_what_follows_when_starting_from_a_later_index', () async {
final factory = _RecordingFactory(); final factory = _RecordingFactory();
final drama = _drama('signal'); final drama = _drama('signal');
@@ -1403,3 +1521,23 @@ class _VideoRecordingPlayer extends VideoPlayerPort {
await controller.dispose(); await controller.dispose();
} }
} }
class _UnlockCommerceRepository implements CommerceRepository {
@override
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
wallet: CoinWallet(
coinBalance: 100,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
@override
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
unlocked: true,
accessType: PlayTicket.coinRequired,
coinCost: 30,
coinBalance: 70,
);
}
+90 -137
View File
@@ -7,179 +7,130 @@ import 'package:video_player/video_player.dart';
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
void main() { void main() {
testWidgets('should_keep_fallback_visible_before_video_is_ready', ( testWidgets('should_show_black_without_placeholder_before_video', (
tester, tester,
) async { ) async {
await _pumpSurface(tester); await _pumpSurface(tester);
expect(_fallbackColor(tester), Colors.black); expect(_fallbackColor(tester), Colors.black);
expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(_opacity(tester), 0); expect(find.byKey(const Key('player-video-layer')), findsNothing);
}); });
testWidgets('should_show_opening_frame_before_decode_is_ready', ( testWidgets('should_show_exactly_one_placeholder_before_first_frame', (
tester, tester,
) async { ) async {
await _pumpSurface(tester, placeholder: _placeholder('opening'));
expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('opening')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
});
testWidgets('should_skip_placeholder_for_warm_controller', (tester) async {
await _pumpSurface( await _pumpSurface(
tester, tester,
openingFrame: const ColoredBox( placeholder: _placeholder('opening'),
key: Key('fake-opening'), warmFrameAvailable: true,
color: Colors.red, thumbnailReady: true,
), value: _initializedValue,
); );
expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('fake-opening')), findsOneWidget); expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
expect(_opacity(tester), 0); expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
}); });
testWidgets('should_reveal_paused_frame_when_initialized', (tester) async { testWidgets('should_cross_fade_once_after_first_frame_is_confirmed', (
final controller = await _pumpSurface(tester);
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
);
await tester.pump();
expect(_opacity(tester), 1);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
});
testWidgets('should_show_cover_until_first_playback_frame', (
tester, tester,
) async { ) async {
final controller = await _pumpSurface( final controller = await _pumpSurface(
tester, tester,
openingFrame: const ColoredBox(color: Colors.red), placeholder: _placeholder('opening'),
holdOpeningUntilFirstFrame: true, thumbnailReady: true,
); waitForPlaybackFrame: true,
expect(_opacity(tester), 0);
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: true,
); );
controller.value = _readyValue(position: Duration.zero, playing: true);
await tester.pump(); await tester.pump();
expect(_opacity(tester), 0); expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
controller.value = const VideoPlayerValue( controller.value = _readyValue(
duration: Duration(minutes: 1), position: const Duration(milliseconds: 1),
size: Size(1280, 720), playing: true,
position: Duration(milliseconds: 1),
isInitialized: true,
isPlaying: true,
); );
await tester.pump(); await tester.pump();
await tester.pump();
await tester.pump();
expect(_opacity(tester), 1); expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget);
expect( expect(find.byKey(const Key('player-video-layer')), findsNothing);
tester expect(_crossFadeValue(tester), 0);
.widget<AnimatedOpacity>(
find.byKey(const Key('player-video-opacity')), await tester.pump(const Duration(milliseconds: 90));
) expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
.duration, expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget);
Duration.zero, expect(_crossFadeValue(tester), greaterThan(0));
expect(_crossFadeValue(tester), lessThan(1));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
controller.value = _readyValue(
position: const Duration(seconds: 1),
playing: true,
); );
await tester.pump();
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
}); });
testWidgets('should_skip_cover_when_prewarmed_frame_is_available', ( testWidgets('should_keep_placeholder_above_video_error', (tester) async {
tester,
) async {
await _pumpSurface(
tester,
openingFrame: const ColoredBox(color: Colors.red),
holdOpeningUntilFirstFrame: true,
preparedFrameAvailable: true,
value: const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
),
);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(_opacity(tester), 1);
});
testWidgets('should_show_video_error_above_retained_fallback', (
tester,
) async {
await _pumpSurface( await _pumpSurface(
tester, tester,
placeholder: _placeholder('opening'),
value: const VideoPlayerValue.erroneous('No route to host'), value: const VideoPlayerValue.erroneous('No route to host'),
); );
expect(find.byKey(const Key('opening')), findsOneWidget);
expect(find.byKey(const Key('player-video-error')), findsOneWidget); expect(find.byKey(const Key('player-video-error')), findsOneWidget);
expect(find.text('No route to host'), findsOneWidget); expect(find.text('No route to host'), findsOneWidget);
expect(_fallbackColor(tester), Colors.black);
expect(_opacity(tester), 0);
});
testWidgets('should_cover_new_video_only_while_handoff_is_held', (
tester,
) async {
final current = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/720.m3u8'),
);
final previous = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/360.m3u8'),
);
addTearDown(current.dispose);
addTearDown(previous.dispose);
const ready = VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration(seconds: 3),
isInitialized: true,
isPlaying: true,
);
current.value = ready;
previous.value = ready;
Future<void> pump(VideoPlayerController? handoff) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlayerVideoSurface(
controller: current,
handoff: handoff,
videoUrl: current.dataSource,
theater: false,
openingFrame: const ColoredBox(color: Colors.red),
),
),
),
);
}
await pump(previous);
expect(find.byKey(const Key('player-video-handoff')), findsOneWidget);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(_opacity(tester), 1);
await pump(null);
expect(find.byKey(const Key('player-video-handoff')), findsNothing);
expect(find.byType(VideoPlayer), findsOneWidget);
}); });
} }
Widget _placeholder(String key) => ColoredBox(key: Key(key), color: Colors.red);
const VideoPlayerValue _initializedValue = VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
);
VideoPlayerValue _readyValue({
required Duration position,
required bool playing,
}) => VideoPlayerValue(
duration: const Duration(minutes: 1),
size: const Size(1280, 720),
position: position,
isInitialized: true,
isPlaying: playing,
);
Future<VideoPlayerController> _pumpSurface( Future<VideoPlayerController> _pumpSurface(
WidgetTester tester, { WidgetTester tester, {
VideoPlayerValue? value, VideoPlayerValue? value,
Widget? openingFrame, Widget? placeholder,
bool holdOpeningUntilFirstFrame = false, bool warmFrameAvailable = false,
bool preparedFrameAvailable = false, bool thumbnailReady = false,
bool waitForPlaybackFrame = false,
}) async { }) async {
final controller = VideoPlayerController.networkUrl( final controller = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/video.mp4'), Uri.parse('http://192.168.31.187:8081/files/video.mp4'),
@@ -193,9 +144,10 @@ Future<VideoPlayerController> _pumpSurface(
controller: controller, controller: controller,
videoUrl: controller.dataSource, videoUrl: controller.dataSource,
theater: false, theater: false,
openingFrame: openingFrame, placeholder: placeholder,
holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, warmFrameAvailable: warmFrameAvailable,
preparedFrameAvailable: preparedFrameAvailable, thumbnailReady: thumbnailReady,
waitForPlaybackFrame: waitForPlaybackFrame,
), ),
), ),
), ),
@@ -210,8 +162,9 @@ Color _fallbackColor(WidgetTester tester) {
.color; .color;
} }
double _opacity(WidgetTester tester) { double _crossFadeValue(WidgetTester tester) {
return tester return tester
.widget<AnimatedOpacity>(find.byKey(const Key('player-video-opacity'))) .widget<FadeTransition>(find.byKey(const Key('player-video-crossfade')))
.opacity; .opacity
.value;
} }