1406 lines
42 KiB
Dart
1406 lines
42 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
// ignore: depend_on_referenced_packages
|
|
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
|
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
|
import 'package:fruitshorts/core/data/models/player_port.dart';
|
|
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_policy.dart';
|
|
import 'package:fruitshorts/core/data/repositories/download_repository.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_storage.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/video_player_port.dart';
|
|
import 'package:fruitshorts/pages/player/player_logic.dart';
|
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
|
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
|
|
|
|
import '../support/fake_watch_history.dart';
|
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
|
|
|
Drama _drama(
|
|
String id, {
|
|
List<int> numbers = const [1, 2, 3],
|
|
bool isFree = true,
|
|
}) => Drama(
|
|
id: id,
|
|
title: id,
|
|
synopsis: 'synopsis',
|
|
posterUrl: '/files/poster.png',
|
|
episodes: [
|
|
for (final number in numbers)
|
|
Episode(
|
|
id: '$id-$number',
|
|
number: number,
|
|
title: 'Episode $number',
|
|
// 与后端一致:只有可播放的剧集才带地址,锁定集为空
|
|
videoUrl: isFree ? 'https://example.test/clip.mp4' : '',
|
|
isFree: isFree,
|
|
),
|
|
],
|
|
);
|
|
|
|
AndroidPictureInPicture _pip() =>
|
|
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
|
|
|
|
PlayerLogic _logic(
|
|
FakeWatchHistory history, {
|
|
PlaybackRepository? playback,
|
|
PlaybackPositionCache? positionCache,
|
|
Future<void> Function()? waitForFrame,
|
|
}) => PlayerLogic(
|
|
history: history,
|
|
playback: playback ?? FakePlaybackRepository(),
|
|
pictureInPicture: _pip(),
|
|
positionCache: positionCache,
|
|
waitForFrame: waitForFrame ?? () async {},
|
|
);
|
|
|
|
/// Drains the session's serialized work so a test never ends mid-disposal.
|
|
///
|
|
/// `start` kicks off selection without awaiting it, so the first read of
|
|
/// `playbackSettled` can still be the untouched initial future. Pumping the
|
|
/// event queue between reads closes that race.
|
|
Future<void> _drain(PlayerLogic logic) async {
|
|
for (var attempt = 0; attempt < 4; attempt++) {
|
|
await pumpEventQueue();
|
|
await logic.session.playbackSettled;
|
|
await logic.session.preloadSettled;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('displayEpisode', () {
|
|
test('should_fall_back_until_episode_number_is_picked_for_that_drama_when_resolving', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
final drama = _drama('signal');
|
|
final fallback = drama.episodes.first;
|
|
|
|
expect(logic.displayEpisode(drama, fallback).number, 1);
|
|
|
|
logic.pickedEpisode['signal'] = 3;
|
|
expect(logic.displayEpisode(drama, fallback).number, 3);
|
|
|
|
// Picks are scoped to their own drama.
|
|
final city = _drama('city');
|
|
expect(logic.displayEpisode(city, city.episodes.first).number, 1);
|
|
});
|
|
|
|
test('should_stop_overriding_fallback_when_picked_number_is_unknown', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
final drama = _drama('signal');
|
|
final fallback = drama.episodes.first;
|
|
|
|
logic.pickedEpisode['signal'] = 99;
|
|
|
|
expect(logic.displayEpisode(drama, fallback).number, 1);
|
|
});
|
|
});
|
|
|
|
group('nextVisibleIndex', () {
|
|
test('should_walk_visible_list_and_stop_at_the_end_when_advancing', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.visibleIndices = [0, 2, 4];
|
|
|
|
expect(logic.nextVisibleIndex(0), 2);
|
|
expect(logic.nextVisibleIndex(2), 4);
|
|
expect(logic.nextVisibleIndex(4), isNull);
|
|
});
|
|
|
|
test('should_have_no_successor_when_index_is_outside_visible_list', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.visibleIndices = [0, 2, 4];
|
|
|
|
expect(logic.nextVisibleIndex(1), isNull);
|
|
expect(logic.nextVisibleIndex(9), isNull);
|
|
expect(logic.nextVisibleIndex(-1), isNull);
|
|
});
|
|
|
|
test('should_have_no_successor_when_visible_list_is_empty', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.visibleIndices = [];
|
|
|
|
expect(logic.nextVisibleIndex(0), isNull);
|
|
});
|
|
});
|
|
|
|
group('playback position cache', () {
|
|
test('should_use_cached_resume_point_without_reading_history', () async {
|
|
final history = FakeWatchHistory()..saved = const Duration(seconds: 12);
|
|
final cache = PlaybackPositionCache();
|
|
cache.save(
|
|
episodeId: 'signal-1',
|
|
dramaId: 'signal',
|
|
position: const Duration(minutes: 1, seconds: 20),
|
|
duration: const Duration(minutes: 3),
|
|
);
|
|
final logic = _logic(history, positionCache: cache);
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: _RecordingFactory().create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
await pumpEventQueue();
|
|
|
|
expect(logic.resumePoint?.position, const Duration(seconds: 80));
|
|
expect(history.reads, isEmpty);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_fall_back_to_server_when_cache_belongs_to_other_drama',
|
|
() async {
|
|
final history = FakeWatchHistory()
|
|
..records['signal'] = const WatchProgress(
|
|
dramaId: 'signal',
|
|
episodeId: 'signal-2',
|
|
position: Duration(seconds: 42),
|
|
duration: Duration(minutes: 3),
|
|
);
|
|
final cache = PlaybackPositionCache();
|
|
cache.save(
|
|
episodeId: 'signal-1',
|
|
dramaId: 'other-drama',
|
|
position: const Duration(seconds: 80),
|
|
duration: const Duration(minutes: 3),
|
|
);
|
|
final logic = _logic(history, positionCache: cache);
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: _RecordingFactory().create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
await pumpEventQueue();
|
|
|
|
expect(logic.resumePoint?.episodeId, 'signal-2');
|
|
expect(logic.resumePoint?.position, const Duration(seconds: 42));
|
|
expect(history.reads, ['signal']);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_fall_back_to_server_when_cache_is_missing', () async {
|
|
final history = FakeWatchHistory()
|
|
..records['signal'] = const WatchProgress(
|
|
dramaId: 'signal',
|
|
episodeId: 'signal-1',
|
|
position: Duration(seconds: 36),
|
|
duration: Duration(minutes: 3),
|
|
);
|
|
final logic = _logic(history);
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: _RecordingFactory().create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
await pumpEventQueue();
|
|
|
|
expect(logic.resumePoint?.position, const Duration(seconds: 36));
|
|
expect(history.reads, ['signal']);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
testWidgets(
|
|
'should_cache_actual_position_after_seek_completes',
|
|
(tester) async {
|
|
late BuildContext context;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Builder(
|
|
builder: (pageContext) {
|
|
context = pageContext;
|
|
return const Scaffold(body: SizedBox.expand());
|
|
},
|
|
),
|
|
),
|
|
);
|
|
final cache = PlaybackPositionCache();
|
|
final history = FakeWatchHistory();
|
|
final factory = _VideoRecordingFactory();
|
|
late PlayerLogic logic;
|
|
await tester.runAsync(() async {
|
|
logic = _logic(history, positionCache: cache);
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
});
|
|
final player = logic.session.active! as _VideoRecordingPlayer;
|
|
logic.beginScrub(player);
|
|
|
|
await tester.runAsync(
|
|
() => logic.seekTo(
|
|
context,
|
|
player,
|
|
const Duration(seconds: 42),
|
|
),
|
|
);
|
|
await tester.runAsync(() => logic.pendingWrite);
|
|
|
|
expect(
|
|
cache.read('signal-1', dramaId: 'signal')?.position,
|
|
const Duration(seconds: 42),
|
|
);
|
|
expect(history.writes.last, 'signal-1|42000|false');
|
|
|
|
logic.beginScrub(player);
|
|
await tester.runAsync(
|
|
() => logic.seekTo(
|
|
context,
|
|
player,
|
|
const Duration(minutes: 2),
|
|
),
|
|
);
|
|
await tester.runAsync(() => logic.pendingWrite);
|
|
|
|
expect(player.controller.value.position, const Duration(minutes: 1));
|
|
expect(
|
|
cache.read('signal-1', dramaId: 'signal')?.position,
|
|
const Duration(minutes: 1),
|
|
);
|
|
expect(history.writes.last, 'signal-1|60000|false');
|
|
logic.dispose();
|
|
await tester.pump();
|
|
},
|
|
);
|
|
|
|
testWidgets('should_seek_cached_position_before_play_when_rebuilding', (
|
|
tester,
|
|
) async {
|
|
final previousPlatform = VideoPlayerPlatform.instance;
|
|
VideoPlayerPlatform.instance = _VideoPlayerPlatformStub();
|
|
addTearDown(() => VideoPlayerPlatform.instance = previousPlatform);
|
|
final cache = PlaybackPositionCache();
|
|
cache.save(
|
|
episodeId: 'signal-1',
|
|
dramaId: 'signal',
|
|
position: const Duration(seconds: 42),
|
|
duration: const Duration(minutes: 1),
|
|
);
|
|
final factory = _VideoRecordingFactory();
|
|
late PlayerLogic logic;
|
|
await tester.runAsync(() async {
|
|
logic = _logic(FakeWatchHistory(), positionCache: cache);
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
await pumpEventQueue();
|
|
await logic.session.playbackSettled;
|
|
await logic.session.preloadSettled;
|
|
});
|
|
|
|
final player = logic.session.active! as _VideoRecordingPlayer;
|
|
expect(player.events, containsAllInOrder(['initialize', 'seek', 'play']));
|
|
expect(player.controller.value.position, const Duration(seconds: 42));
|
|
logic.dispose();
|
|
await tester.pump();
|
|
});
|
|
});
|
|
|
|
test('should_play_local_file_without_ticket_when_episode_is_downloaded',
|
|
() async {
|
|
final playback = FakePlaybackRepository();
|
|
final downloads = DownloadController(
|
|
_EmptyDownloadRepository(),
|
|
storage: _LocalStorage(),
|
|
);
|
|
await downloads.restore();
|
|
final drama = _drama('signal');
|
|
final logic = PlayerLogic(
|
|
history: FakeWatchHistory(),
|
|
playback: playback,
|
|
pictureInPicture: _pip(),
|
|
downloads: downloads,
|
|
);
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator());
|
|
await pumpEventQueue();
|
|
await logic.session.playbackSettled;
|
|
|
|
expect(playback.requests, isEmpty);
|
|
expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4');
|
|
});
|
|
|
|
group('surface flags', () {
|
|
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
final immersive = <bool>[];
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.onImmersiveChanged = immersive.add;
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
logic.onPipChanged(true);
|
|
|
|
expect(logic.pip, isTrue);
|
|
expect(logic.pipPreparing, isFalse);
|
|
expect(logic.immersive, isTrue);
|
|
expect(logic.chromeHidden, isTrue);
|
|
expect(immersive.last, isTrue);
|
|
|
|
logic.onPipChanged(false);
|
|
expect(logic.pip, isFalse);
|
|
expect(logic.immersive, isFalse);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_ignore_picture_in_picture_callback_when_unmounted', () {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.isMounted = () => false;
|
|
|
|
logic.onPipChanged(true);
|
|
|
|
expect(logic.pip, isFalse);
|
|
});
|
|
|
|
test('should_clear_stale_pip_when_app_returns_to_foreground', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
final immersive = <bool>[];
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.onImmersiveChanged = immersive.add;
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
// 模拟画中画已进入但退出时没有回调 false。
|
|
logic.onPipChanged(true);
|
|
expect(logic.chromeHidden, isTrue);
|
|
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
|
|
expect(logic.pip, isFalse);
|
|
expect(logic.pipPreparing, isFalse);
|
|
expect(logic.chromeHidden, isFalse);
|
|
expect(immersive.last, isFalse);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_resume_playback_after_seek_when_paused', () async {
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: _drama('signal'), initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: _RecordingFactory().create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
logic.session.setUserWantsPlay(false);
|
|
expect(logic.session.userWantsPlay, isFalse);
|
|
|
|
logic.resumeAfterSeek();
|
|
|
|
expect(logic.session.userWantsPlay, isTrue);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
});
|
|
|
|
group('history writes', () {
|
|
test('should_serialize_progress_writes_in_call_order_when_saving', () async {
|
|
final history = FakeWatchHistory();
|
|
final logic = _logic(history);
|
|
final drama = _drama('signal');
|
|
|
|
await logic.queueProgress(
|
|
drama,
|
|
drama.episodes[0],
|
|
const Duration(seconds: 1),
|
|
);
|
|
await logic.queueProgress(
|
|
drama,
|
|
drama.episodes[1],
|
|
const Duration(seconds: 2),
|
|
);
|
|
|
|
expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']);
|
|
});
|
|
|
|
test('should_keep_later_writes_when_an_earlier_write_fails', () async {
|
|
final history = FakeWatchHistory();
|
|
final logic = _logic(history);
|
|
final drama = _drama('signal');
|
|
|
|
history.failWrites = true;
|
|
await logic.queueProgress(
|
|
drama,
|
|
drama.episodes[0],
|
|
const Duration(seconds: 1),
|
|
);
|
|
|
|
history.failWrites = false;
|
|
await logic.queueProgress(
|
|
drama,
|
|
drama.episodes[1],
|
|
const Duration(seconds: 2),
|
|
);
|
|
|
|
expect(history.writes, ['signal-2|2000|false']);
|
|
});
|
|
|
|
test('should_write_nothing_when_no_player_is_tracked', () async {
|
|
final history = FakeWatchHistory();
|
|
final logic = _logic(history);
|
|
|
|
await logic.saveCurrentProgress();
|
|
await logic.saveCurrentProgress();
|
|
|
|
expect(history.writes, isEmpty);
|
|
expect(history.reads, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('start', () {
|
|
test('should_play_single_series_when_route_is_detail', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
expect(logic.queue.length, 3);
|
|
expect(logic.visibleIndices, [0, 1, 2]);
|
|
expect(logic.session.currentIndex, 0);
|
|
expect(factory.created, hasLength(2));
|
|
expect(factory.created.first.plays, 1);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
expect(factory.live, 0);
|
|
});
|
|
|
|
test('should_play_one_episode_per_feed_drama_when_in_home_mode', () async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('signal'), _drama('city'), _drama('vengeance')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
expect(logic.queue.length, 3);
|
|
expect(logic.queue.entryAt(1).drama.id, 'city');
|
|
expect(logic.queue.entryAt(1).episode.number, 1);
|
|
expect(logic.visibleIndices, [0, 1, 2]);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_never_reach_player_factory_when_episode_is_locked', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('locked', isFree: false);
|
|
// 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断
|
|
final logic = _logic(
|
|
FakeWatchHistory(),
|
|
playback: FakePlaybackRepository(
|
|
ticket: const PlayTicket(
|
|
episodeId: 'locked-1',
|
|
accessible: false,
|
|
accessType: PlayTicket.coinRequired,
|
|
unlockCoin: 30,
|
|
),
|
|
),
|
|
);
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
expect(logic.session.isLocked, isTrue);
|
|
expect(factory.created, isEmpty);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_preload_only_what_follows_when_starting_from_a_later_index', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 2),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
expect(logic.session.currentIndex, 2);
|
|
// 最后一集没有下一集,但仍会预热上一集。
|
|
expect(factory.created, hasLength(2));
|
|
expect(logic.session.preparedPlayerAt(1), isNotNull);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_hold_no_players_when_route_is_inactive', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0, active: false),
|
|
isMounted: () => true,
|
|
);
|
|
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
expect(factory.created, isEmpty);
|
|
expect(logic.session.loading, isFalse);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
});
|
|
|
|
group('updateFeed', () {
|
|
test('should_append_feed_without_restarting_current_drama', () async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('signal'), _drama('city')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
final activeBefore = logic.session.active;
|
|
|
|
logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal');
|
|
await logic.feedUpdateSettled;
|
|
|
|
expect(logic.currentDramaId, 'signal');
|
|
expect(logic.visibleIndices, [0, 1, 2]);
|
|
expect(logic.sessionEpisodes, hasLength(3));
|
|
expect(logic.session.active, same(activeBefore));
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_remap_current_drama_without_restarting_when_order_changes',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('signal'), _drama('city')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 1,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
final activeBefore = logic.session.active;
|
|
|
|
logic.updateFeed(
|
|
[_drama('city'), _drama('signal'), _drama('third')],
|
|
keepDramaId: 'city',
|
|
);
|
|
await logic.feedUpdateSettled;
|
|
|
|
expect(logic.currentDramaId, 'city');
|
|
expect(logic.session.currentIndex, 0);
|
|
expect(logic.session.active, same(activeBefore));
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_return_to_first_drama_when_feed_channel_changes', () async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('signal'), _drama('city')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
logic.updateFeed(
|
|
[_drama('anime-a'), _drama('anime-b')],
|
|
resetToFirst: true,
|
|
);
|
|
await logic.feedUpdateSettled;
|
|
await _drain(logic);
|
|
|
|
expect(logic.queue.entryAt(0).drama.id, 'anime-a');
|
|
expect(logic.currentDramaId, 'anime-a');
|
|
expect(logic.session.currentIndex, 0);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_clear_queue_safely_when_feed_becomes_empty', () async {
|
|
final feed = [_drama('signal')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: _RecordingFactory().create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
|
|
logic.updateFeed(const []);
|
|
await logic.feedUpdateSettled;
|
|
|
|
expect(logic.queue.length, 0);
|
|
expect(logic.visibleIndices, isEmpty);
|
|
expect(logic.currentDramaId, isNull);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_apply_only_latest_feed_when_update_is_called_five_times',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final logic = _logic(FakeWatchHistory());
|
|
final initial = [_drama('initial')];
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: initial.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: initial,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
for (var index = 1; index <= 5; index++) {
|
|
logic.updateFeed([_drama('feed-$index')], resetToFirst: true);
|
|
}
|
|
await logic.feedUpdateSettled;
|
|
await _drain(logic);
|
|
|
|
expect(logic.feedRevision, 5);
|
|
expect(logic.queue.entryAt(0).drama.id, 'feed-5');
|
|
expect(logic.sessionEpisodes.single.id, 'feed-5-1');
|
|
expect(logic.currentDramaId, 'feed-5');
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_select_first_item_when_feed_recovers_from_empty', () async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('signal')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
logic.updateFeed(const []);
|
|
await logic.feedUpdateSettled;
|
|
|
|
logic.updateFeed([_drama('restored')], resetToFirst: true);
|
|
await logic.feedUpdateSettled;
|
|
await _drain(logic);
|
|
|
|
expect(logic.queue.entryAt(0).drama.id, 'restored');
|
|
expect(logic.session.currentIndex, 0);
|
|
expect(logic.currentDramaId, 'restored');
|
|
expect(factory.live, greaterThan(0));
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_reset_to_first_when_channel_changes_with_same_ids',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final feed = [_drama('same-a'), _drama('same-b')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: feed.first,
|
|
initialIndex: 1,
|
|
homeMode: true,
|
|
feedDramas: feed,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
expect(logic.session.currentIndex, 1);
|
|
|
|
logic.updateFeed(feed, resetToFirst: true);
|
|
await logic.feedUpdateSettled;
|
|
await _drain(logic);
|
|
|
|
expect(logic.session.currentIndex, 0);
|
|
expect(logic.currentDramaId, 'same-a');
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
testWidgets(
|
|
'should_defer_feed_update_side_effects_when_called_during_build',
|
|
(tester) async {
|
|
final factory = _RecordingFactory();
|
|
final initial = [_drama('a'), _drama('b'), _drama('c')];
|
|
final latest = [_drama('d'), _drama('c'), _drama('a')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: initial.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: initial,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
await tester.runAsync(() async {
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
});
|
|
expect(logic.currentDramaId, 'a');
|
|
expect(logic.session.active, isNotNull);
|
|
var updateScheduled = false;
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) {
|
|
if (!updateScheduled) {
|
|
updateScheduled = true;
|
|
logic.updateFeed(latest, keepDramaId: 'a');
|
|
}
|
|
return Scaffold(
|
|
body: PageView.builder(
|
|
controller: logic.pages,
|
|
itemCount: logic.visibleIndices.length,
|
|
itemBuilder: (_, index) =>
|
|
ColoredBox(color: Colors.black, key: ValueKey(index)),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
expect(logic.pages.hasClients, isTrue);
|
|
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(logic.queue.entryAt(2).drama.id, 'a');
|
|
expect(logic.pages.page, closeTo(2, 0.01));
|
|
logic.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'should_unmount_old_controller_before_disposing_it_when_feed_changes',
|
|
(tester) async {
|
|
final factory = _VideoRecordingFactory();
|
|
final initial = [_drama('old')];
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(
|
|
drama: initial.first,
|
|
initialIndex: 0,
|
|
homeMode: true,
|
|
feedDramas: initial,
|
|
),
|
|
isMounted: () => true,
|
|
);
|
|
await tester.runAsync(() async {
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
});
|
|
final oldPlayer = factory.created.single;
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: _SessionVideoSurface(logic: logic)),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byType(VideoPlayer), findsOneWidget);
|
|
|
|
logic.updateFeed([_drama('new')], resetToFirst: true);
|
|
for (var frame = 0; frame < 5; frame++) {
|
|
await tester.pump();
|
|
await tester.runAsync(pumpEventQueue);
|
|
}
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(oldPlayer.disposed, isTrue);
|
|
expect(factory.created.length, greaterThanOrEqualTo(2));
|
|
logic.dispose();
|
|
await tester.pump();
|
|
},
|
|
);
|
|
});
|
|
|
|
group('lifecycle', () {
|
|
test(
|
|
'should_auto_resume_previously_paused_playback_when_app_returns',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final playback = FakePlaybackRepository(fresh: false);
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory(), playback: playback);
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
final player = factory.created.first;
|
|
final requestsBeforeResume = playback.requests.length;
|
|
logic
|
|
..trackedEpisode = drama.episodes.first
|
|
..trackedDrama = drama;
|
|
|
|
logic.session.setUserWantsPlay(false);
|
|
await _drain(logic);
|
|
expect(logic.session.userWantsPlay, isFalse);
|
|
expect(player.plays, 1);
|
|
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
expect(logic.trackedEpisode, isNotNull);
|
|
expect(logic.ticketError, isNull);
|
|
expect(logic.blockedEpisodeId, isNull);
|
|
expect(logic.unlockingEpisodeId, isNull);
|
|
expect(logic.session.isLocked, isFalse);
|
|
expect(logic.session.error, isNull);
|
|
expect(logic.session.canResumeAfterBackground, isTrue);
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
await _drain(logic);
|
|
|
|
expect(logic.session.userWantsPlay, isTrue);
|
|
expect(player.plays, 2);
|
|
expect(playback.requests.length, greaterThan(requestsBeforeResume));
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
},
|
|
);
|
|
|
|
test('should_not_auto_resume_locked_episode_when_app_returns', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('locked', isFree: false);
|
|
final logic = _logic(
|
|
FakeWatchHistory(),
|
|
playback: FakePlaybackRepository(
|
|
ticket: const PlayTicket(
|
|
episodeId: 'locked-1',
|
|
accessible: false,
|
|
accessType: PlayTicket.coinRequired,
|
|
unlockCoin: 30,
|
|
),
|
|
),
|
|
);
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
logic.trackedEpisode = drama.episodes.first;
|
|
logic.session.setUserWantsPlay(false);
|
|
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
await _drain(logic);
|
|
|
|
expect(logic.session.userWantsPlay, isFalse);
|
|
expect(factory.created, isEmpty);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test(
|
|
'should_not_auto_resume_when_ticket_failure_or_overlay_is_active',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
final player = factory.created.first;
|
|
logic.session.setUserWantsPlay(false);
|
|
await _drain(logic);
|
|
|
|
logic.ticketError = 'ticket failed';
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
await _drain(logic);
|
|
expect(logic.session.userWantsPlay, isFalse);
|
|
expect(player.plays, 1);
|
|
|
|
logic.ticketError = null;
|
|
logic.session.setOverlayOpen(true);
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
await _drain(logic);
|
|
expect(logic.session.userWantsPlay, isFalse);
|
|
expect(player.plays, 1);
|
|
|
|
logic.session.setOverlayOpen(false);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'should_save_progress_and_pause_when_app_enters_background',
|
|
() async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final history = FakeWatchHistory();
|
|
final logic = _logic(history);
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(
|
|
factory: factory.create,
|
|
coordinator: PlaybackCoordinator(),
|
|
);
|
|
await _drain(logic);
|
|
final player = factory.created.first;
|
|
final tracked = VideoPlayerPort(drama.episodes.first);
|
|
tracked.controller.value = const VideoPlayerValue(
|
|
duration: Duration(minutes: 3),
|
|
position: Duration(seconds: 5),
|
|
isInitialized: true,
|
|
);
|
|
logic
|
|
..trackedPlayer = tracked
|
|
..trackedEpisode = drama.episodes.first
|
|
..trackedDrama = drama;
|
|
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
await logic.pendingWrite;
|
|
|
|
expect(player.pauses, 1);
|
|
expect(history.writes, ['signal-1|5000|false']);
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
await tracked.dispose();
|
|
},
|
|
);
|
|
|
|
test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
final player = factory.created.first;
|
|
expect(player.plays, 1);
|
|
expect(player.pauses, 0);
|
|
|
|
logic.onLifecycle(AppLifecycleState.paused);
|
|
await _drain(logic);
|
|
expect(player.pauses, 1);
|
|
|
|
logic.onLifecycle(AppLifecycleState.resumed);
|
|
await _drain(logic);
|
|
expect(player.plays, 2);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
|
|
test('should_stop_playing_without_losing_intent_when_route_becomes_inactive', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
final start = PlayerPage(drama: drama, initialIndex: 0);
|
|
logic.attach(start, isMounted: () => true);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
final player = factory.created.first;
|
|
expect(player.plays, 1);
|
|
|
|
logic.onPageUpdated(start);
|
|
await _drain(logic);
|
|
expect(player.plays, 1);
|
|
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0, active: false),
|
|
isMounted: () => true,
|
|
);
|
|
logic.onPageUpdated(start);
|
|
await _drain(logic);
|
|
expect(player.pauses, 1);
|
|
expect(logic.session.userWantsPlay, isTrue);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
});
|
|
});
|
|
|
|
group('dispose', () {
|
|
test('should_release_every_player_when_dispose_runs_twice', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
expect(factory.live, greaterThan(0));
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
|
|
expect(factory.live, 0);
|
|
expect(() => logic.dispose(), returnsNormally);
|
|
});
|
|
|
|
test('should_disable_further_notifications_when_disposed', () async {
|
|
final factory = _RecordingFactory();
|
|
final drama = _drama('signal');
|
|
final logic = _logic(FakeWatchHistory());
|
|
logic.attach(
|
|
PlayerPage(drama: drama, initialIndex: 0),
|
|
isMounted: () => true,
|
|
);
|
|
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
|
await _drain(logic);
|
|
|
|
var notifications = 0;
|
|
logic.addListener(() => notifications++);
|
|
|
|
logic.dispose();
|
|
await _drain(logic);
|
|
logic.notify();
|
|
|
|
expect(notifications, 0);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _LocalStorage implements DownloadStorage {
|
|
@override
|
|
Future<Map<String, String>> restore() async => {
|
|
'signal-1': 'file:///local/signal-1.mp4',
|
|
};
|
|
|
|
@override
|
|
Future<String?> localPathFor(String episodeId) async =>
|
|
episodeId == 'signal-1' ? 'file:///local/signal-1.mp4' : null;
|
|
|
|
@override
|
|
Future<DownloadedMedia> download({
|
|
required String episodeId,
|
|
required String url,
|
|
DownloadProgress? onProgress,
|
|
}) async => throw UnimplementedError();
|
|
|
|
@override
|
|
Future<void> delete(String episodeId) async {}
|
|
|
|
@override
|
|
Future<void> clear() async {}
|
|
}
|
|
|
|
class _EmptyDownloadRepository implements DownloadRepository {
|
|
@override
|
|
Future<DownloadPolicy> fetchPolicy() async => const DownloadPolicy(
|
|
maxPerDevice: 20,
|
|
licenseDays: 30,
|
|
wifiOnly: false,
|
|
maxQuality: 'AUTO',
|
|
maxStorageBytes: 0,
|
|
used: 0,
|
|
remaining: 20,
|
|
expirePolicyText: '',
|
|
);
|
|
|
|
@override
|
|
Future<List<DownloadLicense>> list() async => const [];
|
|
|
|
@override
|
|
Future<DownloadLicense> grant(String episodeId, {String? quality}) async =>
|
|
throw UnimplementedError();
|
|
|
|
@override
|
|
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {}
|
|
|
|
@override
|
|
Future<DownloadLicense> refreshUrl(String episodeId) async =>
|
|
throw UnimplementedError();
|
|
|
|
@override
|
|
Future<DownloadLicense> reportProgress(
|
|
String episodeId, {
|
|
String? deviceId,
|
|
String? status,
|
|
int? progressPercent,
|
|
int? downloadedBytes,
|
|
int? totalBytes,
|
|
String? errorMsg,
|
|
}) async => throw UnimplementedError();
|
|
|
|
@override
|
|
Future<void> remove(String episodeId) async {}
|
|
|
|
@override
|
|
Future<int> clear() async => 0;
|
|
}
|
|
|
|
/// Counts native-style allocations so leaks are visible to a test.
|
|
class _RecordingFactory {
|
|
final List<_RecordingPlayer> created = [];
|
|
int live = 0;
|
|
|
|
PlayerPort create(Episode episode) {
|
|
live++;
|
|
final player = _RecordingPlayer(onDispose: () => live--);
|
|
created.add(player);
|
|
return player;
|
|
}
|
|
}
|
|
|
|
class _RecordingPlayer implements PlayerPort {
|
|
_RecordingPlayer({required this.onDispose});
|
|
|
|
final void Function() onDispose;
|
|
int plays = 0;
|
|
int pauses = 0;
|
|
bool disposed = false;
|
|
|
|
@override
|
|
Future<void> initialize() async {}
|
|
|
|
@override
|
|
Future<void> play() async => plays++;
|
|
|
|
@override
|
|
Future<void> pause() async => pauses++;
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
onDispose();
|
|
}
|
|
}
|
|
|
|
class _SessionVideoSurface extends StatelessWidget {
|
|
const _SessionVideoSurface({required this.logic});
|
|
|
|
final PlayerLogic logic;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => AnimatedBuilder(
|
|
animation: logic.session,
|
|
builder: (context, _) {
|
|
final port = logic.session.active;
|
|
final video = port is VideoPlayerPort ? port : null;
|
|
return Scaffold(
|
|
body: PlayerVideoSurface(
|
|
controller: video?.controller,
|
|
videoUrl: video?.controller.dataSource ?? '',
|
|
theater: false,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VideoRecordingFactory {
|
|
final List<_VideoRecordingPlayer> created = [];
|
|
final List<String> events = [];
|
|
|
|
PlayerPort create(Episode episode) {
|
|
final player = _VideoRecordingPlayer(episode, events);
|
|
created.add(player);
|
|
return player;
|
|
}
|
|
}
|
|
|
|
class _VideoPlayerPlatformStub extends VideoPlayerPlatform {
|
|
@override
|
|
Future<void> init() async {}
|
|
|
|
@override
|
|
Future<void> dispose(int playerId) async {}
|
|
|
|
@override
|
|
Future<void> setVolume(int playerId, double volume) async {}
|
|
|
|
@override
|
|
Future<void> setPlaybackSpeed(int playerId, double speed) async {}
|
|
}
|
|
|
|
class _VideoRecordingPlayer extends VideoPlayerPort {
|
|
_VideoRecordingPlayer(super.episode, this.events);
|
|
|
|
bool disposed = false;
|
|
final List<String> events;
|
|
|
|
@override
|
|
Future<void> initialize() async {
|
|
events.add('initialize');
|
|
controller.value = const VideoPlayerValue(
|
|
duration: Duration(minutes: 1),
|
|
size: Size(1280, 720),
|
|
position: Duration(seconds: 1),
|
|
isInitialized: true,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async => events.add('play');
|
|
|
|
@override
|
|
Future<void> pause() async => events.add('pause');
|
|
|
|
@override
|
|
Future<void> seekTo(Duration position) async {
|
|
events.add('seek');
|
|
final current = controller.value;
|
|
controller.value = VideoPlayerValue(
|
|
duration: current.duration,
|
|
size: current.size,
|
|
position: position,
|
|
isInitialized: current.isInitialized,
|
|
isPlaying: current.isPlaying,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
await controller.dispose();
|
|
}
|
|
}
|