fix(player): 修复竖滑上一集不自动播放
This commit is contained in:
1 parent
df36890b48
commit
516593499c
10 files changed
+264
-80
No files matched your search
@@ -67,15 +67,19 @@ Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async {
|
|||||||
await session._retire(slot);
|
await session._retire(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前集已经出画后,静默准备上一集和下一集。
|
/// 当前集已经出画后,按手势方向静默准备上一集和下一集。
|
||||||
void _warmNeighbors(PlaybackSession session) {
|
void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) {
|
||||||
final request = session._generation;
|
final request = session._generation;
|
||||||
session._preloadTask = () async {
|
final nextTask = _warmOne(session, request, session._nextIndex, next: true);
|
||||||
await Future.wait([
|
final previousTask = _warmOne(
|
||||||
_warmOne(session, request, session._nextIndex, next: true),
|
session,
|
||||||
_warmOne(session, request, session._previousIndex, next: false),
|
request,
|
||||||
]);
|
session._previousIndex,
|
||||||
}();
|
next: false,
|
||||||
|
);
|
||||||
|
session._preloadTask = Future.wait(
|
||||||
|
preferredNext ? [nextTask, previousTask] : [previousTask, nextTask],
|
||||||
|
).then<void>((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _warmOne(
|
Future<void> _warmOne(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../models/player_port.dart';
|
|||||||
import 'video_player_port.dart';
|
import 'video_player_port.dart';
|
||||||
|
|
||||||
part 'playback_neighbors.dart';
|
part 'playback_neighbors.dart';
|
||||||
|
part 'playback_session_controls.dart';
|
||||||
|
|
||||||
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
|
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
|
||||||
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
|
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
|
||||||
@@ -40,6 +41,7 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
final Future<void> Function()? waitForFrame;
|
final Future<void> Function()? waitForFrame;
|
||||||
final PlaybackCoordinator _coordinator;
|
final PlaybackCoordinator _coordinator;
|
||||||
final Set<_PlayerSlot> _live = {};
|
final Set<_PlayerSlot> _live = {};
|
||||||
|
final Set<String> _pausedEpisodeIds = {};
|
||||||
_PlayerSlot? _active;
|
_PlayerSlot? _active;
|
||||||
_PlayerSlot? _next;
|
_PlayerSlot? _next;
|
||||||
_PlayerSlot? _previous;
|
_PlayerSlot? _previous;
|
||||||
@@ -106,15 +108,6 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
|
|
||||||
PlayerPort? handoffAt(int index) {
|
|
||||||
final slot = _handoff;
|
|
||||||
if (_closed || slot == null || slot.retiring || slot.index != index) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return slot.player;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
||||||
void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) {
|
void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) {
|
||||||
if (_closed || index < 0 || index >= episodes.length) return;
|
if (_closed || index < 0 || index >= episodes.length) return;
|
||||||
@@ -214,10 +207,9 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
int? previousIndex,
|
int? previousIndex,
|
||||||
}) async {
|
}) async {
|
||||||
if (_closed || index < 0 || index >= episodes.length) return;
|
if (_closed || index < 0 || index >= episodes.length) return;
|
||||||
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
|
// 新集默认起播;用户手动暂停过的同一集滑回时继续尊重暂停。
|
||||||
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
|
|
||||||
if (!_selected || index != _currentIndex) {
|
if (!_selected || index != _currentIndex) {
|
||||||
_userWantsPlay = true;
|
_userWantsPlay = !_pausedEpisodeIds.contains(episodes[index].id);
|
||||||
}
|
}
|
||||||
final request = ++_generation;
|
final request = ++_generation;
|
||||||
_selected = true;
|
_selected = true;
|
||||||
@@ -256,7 +248,10 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
target = old;
|
target = old;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (old != null) await old.player.pause();
|
if (old != null) {
|
||||||
|
await old.player.pause();
|
||||||
|
old.playing = false;
|
||||||
|
}
|
||||||
final warmNext = _next;
|
final warmNext = _next;
|
||||||
final warmPrevious = _previous;
|
final warmPrevious = _previous;
|
||||||
_active = null;
|
_active = null;
|
||||||
@@ -369,7 +364,10 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
_handoff = previous;
|
_handoff = previous;
|
||||||
_active = slot;
|
_active = slot;
|
||||||
shown = true;
|
shown = true;
|
||||||
if (previous != null) await previous!.player.pause();
|
if (previous != null) {
|
||||||
|
await previous!.player.pause();
|
||||||
|
previous!.playing = false;
|
||||||
|
}
|
||||||
_notify();
|
_notify();
|
||||||
});
|
});
|
||||||
if (!shown || _active != slot) return null;
|
if (!shown || _active != slot) return null;
|
||||||
@@ -422,27 +420,6 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
slot.playing = shouldPlay;
|
slot.playing = shouldPlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAppResumed(bool value) {
|
|
||||||
_appResumed = value;
|
|
||||||
_syncPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setRouteVisible(bool value) {
|
|
||||||
_routeVisible = value;
|
|
||||||
_syncPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setOverlayOpen(bool value) {
|
|
||||||
_overlayOpen = value;
|
|
||||||
_syncPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUserWantsPlay(bool value) {
|
|
||||||
_userWantsPlay = value;
|
|
||||||
_syncPlayback();
|
|
||||||
_notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncPlayback() {
|
void _syncPlayback() {
|
||||||
if (_closed) return;
|
if (_closed) return;
|
||||||
if (_visible && _selected && active == null) {
|
if (_visible && _selected && active == null) {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
part of 'playback_session.dart';
|
||||||
|
|
||||||
|
/// 播放会话的可见性、用户意图与相邻集预热控制。
|
||||||
|
extension PlaybackSessionControls on PlaybackSession {
|
||||||
|
/// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。
|
||||||
|
void prewarmNeighbors({required bool forward}) {
|
||||||
|
if (!_visible || _overlayOpen || _active?.prepared != true) return;
|
||||||
|
_warmNeighbors(this, preferredNext: forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
|
||||||
|
PlayerPort? handoffAt(int index) {
|
||||||
|
final slot = _handoff;
|
||||||
|
if (_closed || slot == null || slot.retiring || slot.index != index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return slot.player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步应用前台状态并恢复或暂停当前集。
|
||||||
|
void setAppResumed(bool value) {
|
||||||
|
_appResumed = value;
|
||||||
|
_syncPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步播放器路由是否可见。
|
||||||
|
void setRouteVisible(bool value) {
|
||||||
|
_routeVisible = value;
|
||||||
|
_syncPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步浮层是否遮挡播放器。
|
||||||
|
void setOverlayOpen(bool value) {
|
||||||
|
_overlayOpen = value;
|
||||||
|
_syncPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录用户播放意图;手动暂停的剧集滑回时不再自动起播。
|
||||||
|
void setUserWantsPlay(bool value) {
|
||||||
|
if (episodes.isNotEmpty) {
|
||||||
|
final episodeId = episodes[_currentIndex].id;
|
||||||
|
if (value) {
|
||||||
|
_pausedEpisodeIds.remove(episodeId);
|
||||||
|
} else {
|
||||||
|
_pausedEpisodeIds.add(episodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_userWantsPlay = value;
|
||||||
|
_syncPlayback();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -969,7 +969,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
!isMounted()) {
|
!isMounted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ready && ticketError != null) return;
|
if (!ready) return;
|
||||||
await _selectVisible(index);
|
await _selectVisible(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -124,6 +125,8 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
double? _speedBeforeBoost;
|
double? _speedBeforeBoost;
|
||||||
bool? _chromeBeforeBoost;
|
bool? _chromeBeforeBoost;
|
||||||
DateTime? _lastDoubleTapAt;
|
DateTime? _lastDoubleTapAt;
|
||||||
|
DateTime? _swipeStartedAt;
|
||||||
|
bool _swipePrewarmCaptured = false;
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
final Map<String, int> _favoriteDelta = {};
|
final Map<String, int> _favoriteDelta = {};
|
||||||
@@ -308,6 +311,34 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
unawaited(logic.setPlaybackSpeed(speed));
|
unawaited(logic.setPlaybackSpeed(speed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _handlePageScroll(ScrollNotification notification) {
|
||||||
|
if (notification is ScrollStartNotification &&
|
||||||
|
notification.dragDetails != null) {
|
||||||
|
_swipeStartedAt = DateTime.now();
|
||||||
|
_swipePrewarmCaptured = false;
|
||||||
|
} else if (notification is ScrollUpdateNotification &&
|
||||||
|
!_swipePrewarmCaptured &&
|
||||||
|
notification.dragDetails != null) {
|
||||||
|
final delta = notification.scrollDelta ?? 0;
|
||||||
|
if (delta != 0) {
|
||||||
|
_swipePrewarmCaptured = true;
|
||||||
|
logic.session.prewarmNeighbors(forward: delta > 0);
|
||||||
|
}
|
||||||
|
} else if (notification is ScrollEndNotification) {
|
||||||
|
_swipePrewarmCaptured = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logSwipeReveal() {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
final startedAt = _swipeStartedAt;
|
||||||
|
if (startedAt == null) return;
|
||||||
|
_swipeStartedAt = null;
|
||||||
|
final elapsed = DateTime.now().difference(startedAt).inMilliseconds;
|
||||||
|
debugPrint('[player] swipe reveal ${elapsed}ms');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshHomeFeed() async {
|
Future<void> _refreshHomeFeed() async {
|
||||||
final refresh = widget.onRefreshFeed;
|
final refresh = widget.onRefreshFeed;
|
||||||
if (refresh == null) return;
|
if (refresh == null) return;
|
||||||
@@ -420,25 +451,28 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final content = AnimatedBuilder(
|
final content = AnimatedBuilder(
|
||||||
animation: logic.session,
|
animation: logic.session,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final pages = PageView.builder(
|
final pages = NotificationListener<ScrollNotification>(
|
||||||
controller: logic.pages,
|
onNotification: _handlePageScroll,
|
||||||
scrollDirection: Axis.vertical,
|
child: PageView.builder(
|
||||||
allowImplicitScrolling: true,
|
controller: logic.pages,
|
||||||
physics: widget.onRefreshFeed == null
|
scrollDirection: Axis.vertical,
|
||||||
? null
|
allowImplicitScrolling: true,
|
||||||
: const FeedRefreshPhysics(),
|
physics: widget.onRefreshFeed == null
|
||||||
itemCount: indices.length,
|
? null
|
||||||
onPageChanged: (position) {
|
: const FeedRefreshPhysics(),
|
||||||
final index = indices[position];
|
itemCount: indices.length,
|
||||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
onPageChanged: (position) {
|
||||||
unawaited(logic.selectEpisode(index));
|
final index = indices[position];
|
||||||
if (position >= indices.length - 3) {
|
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||||
final loadMore = widget.onLoadMore;
|
unawaited(logic.selectEpisode(index));
|
||||||
if (loadMore != null) unawaited(loadMore());
|
if (position >= indices.length - 3) {
|
||||||
}
|
final loadMore = widget.onLoadMore;
|
||||||
},
|
if (loadMore != null) unawaited(loadMore());
|
||||||
itemBuilder: (context, position) =>
|
}
|
||||||
_buildEpisode(context, indices[position], interactions),
|
},
|
||||||
|
itemBuilder: (context, position) =>
|
||||||
|
_buildEpisode(context, indices[position], interactions),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (widget.onRefreshFeed == null) return pages;
|
if (widget.onRefreshFeed == null) return pages;
|
||||||
return FeedPullRefresh(
|
return FeedPullRefresh(
|
||||||
@@ -550,6 +584,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
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();
|
||||||
final ready = videoReady || port != null;
|
final ready = videoReady || port != null;
|
||||||
final theater = logic.pip || logic.pipPreparing;
|
final theater = logic.pip || logic.pipPreparing;
|
||||||
final locked = logic.session.isLocked || episode.locked;
|
final locked = logic.session.isLocked || episode.locked;
|
||||||
@@ -577,7 +612,10 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
handoff: handoffPort?.controller,
|
handoff: handoffPort?.controller,
|
||||||
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
||||||
theater: theater,
|
theater: theater,
|
||||||
openingFrame: playerOpeningFrame(episode.thumbnailsUrl),
|
openingFrame: playerOpeningFrame(
|
||||||
|
episode.thumbnailsUrl,
|
||||||
|
posterUrl: drama.posterUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (selected &&
|
if (selected &&
|
||||||
!theater &&
|
!theater &&
|
||||||
|
|||||||
@@ -8,25 +8,33 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../core/config/app_config.dart';
|
import '../../../core/config/app_config.dart';
|
||||||
import '../../../core/data/services/sprite_thumbnail_asset_loader.dart';
|
import '../../../core/data/services/sprite_thumbnail_asset_loader.dart';
|
||||||
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
import 'scrub_preview_card.dart';
|
import 'scrub_preview_card.dart';
|
||||||
|
|
||||||
/// 无雪碧图地址时不垫帧,交还给纯黑底。
|
/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。
|
||||||
Widget? playerOpeningFrame(String? thumbnailsUrl) {
|
Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) {
|
||||||
final url = thumbnailsUrl?.trim() ?? '';
|
final url = thumbnailsUrl?.trim() ?? '';
|
||||||
if (url.isEmpty) return null;
|
final poster = posterUrl?.trim() ?? '';
|
||||||
return PlayerOpeningFrame(thumbnailsUrl: url);
|
if (url.isEmpty && poster.isEmpty) return null;
|
||||||
|
return PlayerOpeningFrame(
|
||||||
|
thumbnailsUrl: url.isEmpty ? null : url,
|
||||||
|
posterUrl: poster.isEmpty ? null : poster,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解码出视频画面前,用本集雪碧图第一帧垫底。
|
/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。
|
||||||
class PlayerOpeningFrame extends StatefulWidget {
|
class PlayerOpeningFrame extends StatefulWidget {
|
||||||
const PlayerOpeningFrame({
|
const PlayerOpeningFrame({
|
||||||
super.key,
|
super.key,
|
||||||
required this.thumbnailsUrl,
|
this.thumbnailsUrl,
|
||||||
|
this.posterUrl,
|
||||||
this.loader,
|
this.loader,
|
||||||
this.cache,
|
this.cache,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String thumbnailsUrl;
|
final String? thumbnailsUrl;
|
||||||
|
final String? posterUrl;
|
||||||
final SpriteThumbnailAssetLoader? loader;
|
final SpriteThumbnailAssetLoader? loader;
|
||||||
final OpeningFrameCache? cache;
|
final OpeningFrameCache? cache;
|
||||||
|
|
||||||
@@ -55,8 +63,10 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final serial = ++_serial;
|
final serial = ++_serial;
|
||||||
|
final thumbnailsUrl = widget.thumbnailsUrl;
|
||||||
|
if (thumbnailsUrl == null) return;
|
||||||
final frame = await (widget.cache ?? OpeningFrameCache.shared).load(
|
final frame = await (widget.cache ?? OpeningFrameCache.shared).load(
|
||||||
widget.thumbnailsUrl,
|
thumbnailsUrl,
|
||||||
loader: widget.loader,
|
loader: widget.loader,
|
||||||
);
|
);
|
||||||
if (!mounted || serial != _serial) return;
|
if (!mounted || serial != _serial) return;
|
||||||
@@ -66,11 +76,25 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final frame = _frame;
|
final frame = _frame;
|
||||||
if (frame == null) return const SizedBox.expand();
|
final posterUrl = widget.posterUrl;
|
||||||
return SpriteCropPreview(
|
return Stack(
|
||||||
key: const Key('player-opening-sprite'),
|
fit: StackFit.expand,
|
||||||
image: frame.image,
|
children: [
|
||||||
sourceRect: frame.sourceRect,
|
const ColoredBox(color: AppColors.mediaBlack),
|
||||||
|
if (posterUrl != null)
|
||||||
|
RemoteImage(
|
||||||
|
posterUrl,
|
||||||
|
key: const Key('player-opening-poster'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallbackColor: AppColors.mediaBlack,
|
||||||
|
),
|
||||||
|
if (frame != null)
|
||||||
|
SpriteCropPreview(
|
||||||
|
key: const Key('player-opening-sprite'),
|
||||||
|
image: frame.image,
|
||||||
|
sourceRect: frame.sourceRect,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,64 @@ void main() {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_play_previous_episode_when_swiping_back', () async {
|
||||||
|
final factory = _Factory();
|
||||||
|
final session = PlaybackSession(
|
||||||
|
episodes: episodes,
|
||||||
|
factory: factory.create,
|
||||||
|
canPlay: (_) => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.select(1, preloadIndex: 2, previousIndex: 0);
|
||||||
|
await session.preloadSettled;
|
||||||
|
await session.select(2, preloadIndex: 3, previousIndex: 1);
|
||||||
|
await session.preloadSettled;
|
||||||
|
expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-2'));
|
||||||
|
|
||||||
|
await session.select(1, preloadIndex: 2, previousIndex: 0);
|
||||||
|
await session.preloadSettled;
|
||||||
|
|
||||||
|
expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-1'));
|
||||||
|
expect(
|
||||||
|
factory.players.firstWhere((player) => player.id == 'ep-1').playing,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(session.userWantsPlay, isTrue);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_keep_manual_pause_when_returning_to_same_episode', () async {
|
||||||
|
final factory = _Factory();
|
||||||
|
final session = PlaybackSession(
|
||||||
|
episodes: episodes,
|
||||||
|
factory: factory.create,
|
||||||
|
canPlay: (_) => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.select(1, preloadIndex: 2, previousIndex: 0);
|
||||||
|
await session.preloadSettled;
|
||||||
|
session.setUserWantsPlay(false);
|
||||||
|
await session.playbackSettled;
|
||||||
|
expect(session.userWantsPlay, isFalse);
|
||||||
|
|
||||||
|
await session.select(2, preloadIndex: 3, previousIndex: 1);
|
||||||
|
await session.preloadSettled;
|
||||||
|
expect(session.userWantsPlay, isTrue);
|
||||||
|
expect(
|
||||||
|
factory.players.firstWhere((player) => player.id == 'ep-2').playing,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.select(1, preloadIndex: 2, previousIndex: 0);
|
||||||
|
await session.preloadSettled;
|
||||||
|
expect(session.userWantsPlay, isFalse);
|
||||||
|
expect(
|
||||||
|
factory.players.firstWhere((player) => player.id == 'ep-1').playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('should_reuse_in_flight_preload_when_swiped_before_ready', () async {
|
test('should_reuse_in_flight_preload_when_swiped_before_ready', () async {
|
||||||
final gate = Completer<void>();
|
final gate = Completer<void>();
|
||||||
final factory = _Factory(gates: {'ep-1': gate});
|
final factory = _Factory(gates: {'ep-1': gate});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ void main() {
|
|||||||
scrubPreviewLeft(trackWidth: 300, fraction: 1),
|
scrubPreviewLeft(trackWidth: 300, fraction: 1),
|
||||||
300 - scrubPreviewWidth - scrubPreviewSafeMargin,
|
300 - scrubPreviewWidth - scrubPreviewSafeMargin,
|
||||||
);
|
);
|
||||||
expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0);
|
expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
|
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ void main() {
|
|||||||
.homeMode,
|
.homeMode,
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(find.byType(PageView), findsOneWidget);
|
final pageViews = tester.widgetList<PageView>(find.byType(PageView));
|
||||||
|
expect(
|
||||||
|
pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ sprite_001.jpg#xywh=0,0,160,90
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () {
|
test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () {
|
||||||
expect(playerOpeningFrame(null), isNull);
|
expect(playerOpeningFrame(null, posterUrl: ' '), isNull);
|
||||||
expect(playerOpeningFrame(' '), isNull);
|
expect(playerOpeningFrame(' '), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_keep_opening_frame_when_only_poster_is_available', () {
|
||||||
|
expect(
|
||||||
|
playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'),
|
||||||
|
isA<PlayerOpeningFrame>(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async {
|
testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async {
|
||||||
final loader = _FakeSpriteLoader();
|
final loader = _FakeSpriteLoader();
|
||||||
final cache = OpeningFrameCache();
|
final cache = OpeningFrameCache();
|
||||||
@@ -58,6 +65,24 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget);
|
expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_poster_while_sprite_is_loading', (tester) async {
|
||||||
|
final cache = OpeningFrameCache();
|
||||||
|
addTearDown(cache.clear);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: PlayerOpeningFrame(
|
||||||
|
thumbnailsUrl: 'https://cdn.test/thumbnails.vtt',
|
||||||
|
posterUrl: 'https://cdn.test/poster.jpg',
|
||||||
|
loader: _FakeSpriteLoader(),
|
||||||
|
cache: cache,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-opening-poster')), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
||||||
|
|||||||
Reference in new issue
Block a user