fix(player): 重构单层过渡并阻止锁定集串播
This commit is contained in:
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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user