671 lines
23 KiB
Dart
671 lines
23 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:media_kit/media_kit.dart';
|
|
import 'package:reelbox/data/models.dart';
|
|
import 'package:reelbox/media/player_page.dart';
|
|
import 'package:reelbox/media/media_platform.dart';
|
|
import 'package:reelbox/media/cast_media_share.dart';
|
|
import 'package:reelbox/state/app_state.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class _Streams implements PlayerStream {
|
|
final positions = StreamController<Duration>.broadcast();
|
|
final durations = StreamController<Duration>.broadcast();
|
|
final playingEvents = StreamController<bool>.broadcast();
|
|
final completedEvents = StreamController<bool>.broadcast();
|
|
@override
|
|
Stream<Duration> get position => positions.stream;
|
|
@override
|
|
Stream<Duration> get duration => durations.stream;
|
|
@override
|
|
Stream<bool> get playing => playingEvents.stream;
|
|
@override
|
|
Stream<bool> get completed => completedEvents.stream;
|
|
@override
|
|
Stream<bool> get buffering => const Stream.empty();
|
|
@override
|
|
Stream<Tracks> get tracks => const Stream.empty();
|
|
@override
|
|
Stream<String> get error => const Stream.empty();
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
Future<void> close() async {
|
|
await positions.close();
|
|
await durations.close();
|
|
await playingEvents.close();
|
|
await completedEvents.close();
|
|
}
|
|
}
|
|
|
|
class _Engine implements Player {
|
|
@override
|
|
final _Streams stream = _Streams();
|
|
@override
|
|
PlayerState get state =>
|
|
PlayerState(tracks: Tracks(subtitle: subtitleTracks));
|
|
List<SubtitleTrack> subtitleTracks = const [
|
|
SubtitleTrack('auto', null, null),
|
|
SubtitleTrack('no', null, null),
|
|
];
|
|
final calls = <String>[];
|
|
final opens = <Media>[];
|
|
@override
|
|
PlatformPlayer? platform;
|
|
@override
|
|
Future<void> open(Playable playable, {bool play = true}) async {
|
|
final m = playable as Media;
|
|
opens.add(m);
|
|
calls.add('open:$play');
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async {
|
|
calls.add('play');
|
|
stream.playingEvents.add(true);
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() async {
|
|
calls.add('pause');
|
|
stream.playingEvents.add(false);
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) async {
|
|
calls.add('seek:${position.inSeconds}');
|
|
stream.positions.add(position);
|
|
}
|
|
|
|
@override
|
|
Future<void> setRate(double value) async {
|
|
calls.add('rate:$value');
|
|
}
|
|
|
|
@override
|
|
Future<void> setVolume(double value) async {
|
|
calls.add('volume:$value');
|
|
}
|
|
|
|
@override
|
|
Future<void> setVideoTrack(VideoTrack value) async {
|
|
calls.add('video:${value.id}');
|
|
}
|
|
|
|
@override
|
|
Future<void> setSubtitleTrack(SubtitleTrack value) async {
|
|
calls.add('subtitle:${value.id}');
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
calls.add('dispose');
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
testWidgets(
|
|
'persisted Stretch dispatches actual fill geometry instead of contain',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
final fits = <BoxFit>[];
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
app.dispose();
|
|
});
|
|
await app.updateSettings(AppSettings(videoFit: 'fill'));
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: const TitleRecord(id: 82, title: 'Film'),
|
|
source: const MediaSource(url: 'https://media.test/film.mp4'),
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
videoSurfaceBuilder: (fit) {
|
|
fits.add(fit);
|
|
return const SizedBox.expand();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(fits.last, BoxFit.fill);
|
|
await tester.tap(find.byTooltip('Fit or fill'));
|
|
await tester.pump();
|
|
expect(fits.last, BoxFit.cover);
|
|
await tester.tap(find.byTooltip('Fit or fill'));
|
|
await tester.pump();
|
|
expect(fits.last, BoxFit.contain);
|
|
},
|
|
);
|
|
testWidgets(
|
|
'actual subtitle track picker updates active Cast text and clears stale external data',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
const aText = '1\n00:00:00,000 --> 00:00:01,000\nExternal A';
|
|
const bText = '1\n00:00:00,000 --> 00:00:01,000\nExternal B';
|
|
engine.subtitleTracks = [
|
|
SubtitleTrack.data(aText, title: 'External A'),
|
|
const SubtitleTrack('2', 'Embedded B', 'en'),
|
|
SubtitleTrack.no(),
|
|
SubtitleTrack.data(bText, title: 'External B'),
|
|
];
|
|
final sharedText = <String?>[];
|
|
var closed = 0;
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async => switch (call.method) {
|
|
'getBrightness' => -1.0,
|
|
'getEffectiveBrightness' => 0.6,
|
|
'getVolume' => 0.5,
|
|
'castGetState' => {
|
|
'connected': true,
|
|
'deviceName': 'Fixture receiver',
|
|
},
|
|
'castLoad' => true,
|
|
_ => null,
|
|
},
|
|
);
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
app.dispose();
|
|
});
|
|
await app.updateSettings(AppSettings());
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: const TitleRecord(id: 81, title: 'Film'),
|
|
source: const MediaSource(url: 'https://media.test/film.mp4'),
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
castShareFactory:
|
|
({required payload, localFile, subtitleText}) async {
|
|
sharedText.add(subtitleText);
|
|
return CastMediaLease(
|
|
payload: payload,
|
|
onClose: () async {
|
|
closed++;
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
Future<void> choose(String name) async {
|
|
await tester.tap(find.byTooltip('Player settings'));
|
|
await tester.pumpAndSettle();
|
|
final track = engine.subtitleTracks.firstWhere(
|
|
(track) => (track.title ?? track.language ?? track.id) == name,
|
|
);
|
|
final target = find.byKey(ValueKey(track));
|
|
await tester.scrollUntilVisible(
|
|
target,
|
|
300,
|
|
scrollable: find
|
|
.descendant(
|
|
of: find.byType(BottomSheet),
|
|
matching: find.byType(Scrollable),
|
|
)
|
|
.first,
|
|
);
|
|
await tester.ensureVisible(target);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(target);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
await choose('External A');
|
|
await tester.tap(find.byTooltip('Cast to device'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Cast this video'));
|
|
await tester.pumpAndSettle();
|
|
expect(sharedText, [aText]);
|
|
await tester.tapAt(const Offset(10, 10));
|
|
await tester.pumpAndSettle();
|
|
await choose('Embedded B');
|
|
expect(sharedText, [aText, null]);
|
|
expect(
|
|
find.text(
|
|
'Embedded subtitles cannot be shared; Cast continues without them',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
await choose('no');
|
|
expect(app.prefs.subtitleEnabled, false);
|
|
expect(sharedText, [aText, null, null]);
|
|
await choose('External B');
|
|
expect(sharedText.last, bText);
|
|
expect(app.prefs.subtitleEnabled, true);
|
|
expect(closed, 3);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
testWidgets(
|
|
'offline player explicitly hands Cast a sharing lease and revokes it on exit',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
var prepared = 0, closed = 0;
|
|
String? preparedFile;
|
|
final loads = <Map>[];
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
return switch (call.method) {
|
|
'getBrightness' => -1.0,
|
|
'getEffectiveBrightness' => 0.6,
|
|
'getVolume' => 0.5,
|
|
'castGetState' => {
|
|
'connected': true,
|
|
'deviceName': 'Synthetic receiver',
|
|
},
|
|
'castLoad' => (() {
|
|
loads.add(call.arguments as Map);
|
|
return true;
|
|
})(),
|
|
'castShowPicker' => true,
|
|
_ => null,
|
|
};
|
|
},
|
|
);
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
app.dispose();
|
|
});
|
|
await app.updateSettings(AppSettings());
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: const TitleRecord(id: 42, title: 'Synthetic offline film'),
|
|
source: const MediaSource(url: 'https://media.test/original.mp4'),
|
|
localFile: '/selected/synthetic.mp4',
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
castShareFactory:
|
|
({required payload, localFile, subtitleText}) async {
|
|
prepared++;
|
|
preparedFile = localFile;
|
|
return CastMediaLease(
|
|
payload: {
|
|
...payload,
|
|
'url': 'http://127.0.0.1:1234/synthetic/media.mp4',
|
|
},
|
|
onClose: () async {
|
|
closed++;
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(
|
|
prepared,
|
|
0,
|
|
reason: 'Opening a page must not start local sharing',
|
|
);
|
|
await tester.tap(find.byTooltip('Cast to device'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
prepared,
|
|
0,
|
|
reason: 'Choosing a receiver menu must not start sharing',
|
|
);
|
|
await tester.tap(find.text('Cast this video'));
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// Native receiver connection callback, not a fabricated successful load.
|
|
final completed = Completer<void>();
|
|
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
|
MediaPlatform.channel.name,
|
|
const StandardMethodCodec().encodeMethodCall(
|
|
const MethodCall('castChanged', {
|
|
'connected': true,
|
|
'deviceName': 'Synthetic receiver',
|
|
}),
|
|
),
|
|
(_) => completed.complete(),
|
|
);
|
|
await tester.pump();
|
|
await completed.future;
|
|
await tester.pump();
|
|
expect(prepared, 1);
|
|
expect(preparedFile, '/selected/synthetic.mp4');
|
|
expect(Uri.parse(loads.single['url'] as String).scheme, 'http');
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
expect(closed, 1);
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
for (final change in [
|
|
'quality',
|
|
'episode',
|
|
'subtitles',
|
|
'stop',
|
|
'new owner',
|
|
]) {
|
|
testWidgets(
|
|
'pending page Cast lease is invalidated by actual $change change',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
final newerEngine = _Engine();
|
|
final prepared = Completer<CastMediaLease>();
|
|
final loads = <Map>[];
|
|
final snapshots = <Map<String, dynamic>>[];
|
|
var closed = 0;
|
|
const a = MediaSource(url: 'https://media.test/a.mp4', quality: '720p');
|
|
const b = MediaSource(
|
|
url: 'https://media.test/b.mp4',
|
|
quality: '1080p',
|
|
);
|
|
const one = EpisodeRecord(id: 1, title: 'First', sources: [a, b]);
|
|
const two = EpisodeRecord(id: 2, title: 'Second', sources: [b]);
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
MediaPlatform.channel,
|
|
(call) async {
|
|
return switch (call.method) {
|
|
'getBrightness' => -1.0,
|
|
'getEffectiveBrightness' => 0.6,
|
|
'getVolume' => 0.5,
|
|
'castGetState' => {'connected': true, 'deviceName': 'Receiver'},
|
|
'castLoad' => (() {
|
|
loads.add(call.arguments as Map);
|
|
return true;
|
|
})(),
|
|
_ => null,
|
|
};
|
|
},
|
|
);
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
await newerEngine.stream.close();
|
|
app.dispose();
|
|
});
|
|
await app.updateSettings(AppSettings());
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: const TitleRecord(id: 43, title: 'Series'),
|
|
source: a,
|
|
episode: one,
|
|
playlist: const [one, two],
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
castShareFactory:
|
|
({required payload, localFile, subtitleText}) async {
|
|
snapshots.add({...payload, 'selectedText': subtitleText});
|
|
if (snapshots.length == 1) return prepared.future;
|
|
return CastMediaLease(
|
|
payload: payload,
|
|
onClose: () async {},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(snapshots, isEmpty);
|
|
await tester.tap(find.byTooltip('Cast to device'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Cast this video'));
|
|
await tester.pump();
|
|
expect(snapshots.single['url'], a.url);
|
|
// Dismiss the actual sheet while its explicit load is still pending.
|
|
await tester.tapAt(const Offset(10, 10));
|
|
await tester.pumpAndSettle();
|
|
if (change == 'quality') {
|
|
await tester.tap(find.text('1080p'));
|
|
} else if (change == 'episode') {
|
|
await tester.tap(find.byTooltip('Next episode'));
|
|
} else if (change == 'subtitles') {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
|
} else if (change == 'stop') {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.mediaStop);
|
|
} else {
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
key: const ValueKey('new owner'),
|
|
title: const TitleRecord(id: 44, title: 'New owner'),
|
|
source: b,
|
|
playerFactory: () => newerEngine,
|
|
videoControllerFactory: (_) => null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
if (change == 'quality' || change == 'episode') {
|
|
expect(engine.opens.last.uri, b.url);
|
|
}
|
|
if (change == 'subtitles') expect(app.prefs.subtitleEnabled, false);
|
|
prepared.complete(
|
|
CastMediaLease(
|
|
payload: {'url': a.url, 'title': 'Obsolete'},
|
|
onClose: () async {
|
|
closed++;
|
|
},
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(
|
|
loads,
|
|
isEmpty,
|
|
reason:
|
|
'A malicious late lease must not load its captured old payload',
|
|
);
|
|
expect(closed, 1);
|
|
expect(find.textContaining('Could not load this video'), findsNothing);
|
|
if (change == 'quality' ||
|
|
change == 'episode' ||
|
|
change == 'subtitles') {
|
|
// Cancellation does not silently re-share; another explicit action can.
|
|
expect(snapshots, hasLength(1));
|
|
await tester.tap(find.byTooltip('Cast to device'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Cast this video'));
|
|
await tester.pumpAndSettle();
|
|
expect(loads.single['url'], change == 'subtitles' ? a.url : b.url);
|
|
expect(snapshots, hasLength(2));
|
|
}
|
|
},
|
|
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
|
);
|
|
}
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, (_) async => null);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
|
|
return switch (call.method) {
|
|
'getBrightness' => -1.0,
|
|
'getEffectiveBrightness' => 0.6,
|
|
'getVolume' => 0.5,
|
|
'castGetState' => <String, dynamic>{'connected': false},
|
|
_ => null,
|
|
};
|
|
});
|
|
});
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(MediaPlatform.channel, null);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
|
});
|
|
testWidgets(
|
|
'real player controls dispatch to engine and persist fit/subtitle options',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
app.dispose();
|
|
});
|
|
await app.updateSettings(AppSettings(playbackSpeed: 1.75));
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: const TitleRecord(id: 1, title: 'Film'),
|
|
source: const MediaSource(url: 'https://media.test/film.mp4'),
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// The native decoder is replaced, not the actual player control dispatch.
|
|
expect(
|
|
find.byType(CircularProgressIndicator),
|
|
findsNothing,
|
|
reason:
|
|
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
|
|
);
|
|
expect(engine.calls, containsAllInOrder(['open:false', 'rate:1.75']));
|
|
engine.stream.positions.add(const Duration(seconds: 30));
|
|
engine.stream.durations.add(const Duration(seconds: 600));
|
|
await tester.pump();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
|
|
await tester.pump();
|
|
expect(engine.calls, contains('seek:40'));
|
|
expect(engine.calls, contains('volume:0.0'));
|
|
await tester.tap(find.byTooltip('Fit or fill'));
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// The native decoder is replaced, not the actual player control dispatch.
|
|
expect(
|
|
find.byType(CircularProgressIndicator),
|
|
findsNothing,
|
|
reason:
|
|
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
|
|
);
|
|
expect(app.prefs.videoFit, 'cover');
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// The native decoder is replaced, not the actual player control dispatch.
|
|
expect(
|
|
find.byType(CircularProgressIndicator),
|
|
findsNothing,
|
|
reason:
|
|
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
|
|
);
|
|
expect(app.prefs.subtitleEnabled, false);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 5));
|
|
expect(find.byTooltip('Player settings'), findsNothing);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
|
await tester.pump();
|
|
expect(engine.calls, containsAllInOrder(['play', 'pause']));
|
|
expect(find.byTooltip('Player settings'), findsOneWidget);
|
|
},
|
|
);
|
|
testWidgets(
|
|
'resume retains history, rewinds dispatch, and next updates episode context',
|
|
(tester) async {
|
|
final app = AppState();
|
|
final engine = _Engine();
|
|
addTearDown(() async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pumpAndSettle();
|
|
await engine.stream.close();
|
|
app.dispose();
|
|
});
|
|
const title = TitleRecord(id: 2, title: 'Series');
|
|
const one = EpisodeRecord(
|
|
id: 11,
|
|
title: 'Episode one',
|
|
sources: [MediaSource(url: 'https://media.test/1.mp4')],
|
|
);
|
|
const two = EpisodeRecord(
|
|
id: 12,
|
|
title: 'Episode two',
|
|
sources: [MediaSource(url: 'https://media.test/2.mp4')],
|
|
);
|
|
await app.updateSettings(AppSettings(resumeRewindSeconds: 5));
|
|
await app.recordWatch(
|
|
title,
|
|
episodeId: 11,
|
|
episodeTitle: one.title,
|
|
positionSeconds: 60,
|
|
durationSeconds: 600,
|
|
sourceUrl: one.sources.single.url,
|
|
);
|
|
await tester.pumpWidget(
|
|
AppScope(
|
|
state: app,
|
|
child: MaterialApp(
|
|
home: PlayerPage(
|
|
title: title,
|
|
source: one.sources.single,
|
|
episode: one,
|
|
playlist: const [one, two],
|
|
playerFactory: () => engine,
|
|
videoControllerFactory: (_) => null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// The native decoder is replaced, not the actual player control dispatch.
|
|
expect(
|
|
find.byType(CircularProgressIndicator),
|
|
findsNothing,
|
|
reason:
|
|
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
|
|
);
|
|
expect(engine.opens.single.start, const Duration(seconds: 55));
|
|
expect(app.history.single.positionSeconds, 60);
|
|
engine.stream.completedEvents.add(true);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// The native decoder is replaced, not the actual player control dispatch.
|
|
expect(
|
|
find.byType(CircularProgressIndicator),
|
|
findsNothing,
|
|
reason:
|
|
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
|
|
);
|
|
expect(engine.opens.last.uri, two.sources.single.url);
|
|
expect(find.text('Episode two'), findsOneWidget);
|
|
expect(app.history.single.positionSeconds, 60);
|
|
},
|
|
);
|
|
}
|