133 lines
5.2 KiB
Dart
133 lines
5.2 KiB
Dart
|
|
import 'package:flutter/rendering.dart';
|
||
|
|
import 'package:flutter_test/flutter_test.dart';
|
||
|
|
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
||
|
|
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
|
||
|
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||
|
|
import 'package:http/http.dart' as http;
|
||
|
|
import 'package:integration_test/integration_test.dart';
|
||
|
|
import 'package:material_ui/material_ui.dart';
|
||
|
|
|
||
|
|
// The video player for real (ExoPlayer on Android, libmpv on Linux and
|
||
|
|
// Windows), streaming from a Jellyfin server, which `flutter test` can't:
|
||
|
|
// on a device or the desktop,
|
||
|
|
//
|
||
|
|
// flutter test integration_test/jellyfin_player_test.dart -d <device> \
|
||
|
|
// --dart-define=JELLYFIN_URL=https://jellyfin.example.org \
|
||
|
|
// --dart-define=JELLYFIN_USER=<name> \
|
||
|
|
// --dart-define=JELLYFIN_PASSWORD=<password>
|
||
|
|
//
|
||
|
|
// with a server holding at least one episode, film or video with text
|
||
|
|
// subtitles, reached over HTTPS as the app reaches it (Android refuses plain
|
||
|
|
// HTTP to it). A Linux build needs libmpv-dev.
|
||
|
|
const _url = String.fromEnvironment('JELLYFIN_URL');
|
||
|
|
const _user = String.fromEnvironment('JELLYFIN_USER');
|
||
|
|
const _password = String.fromEnvironment('JELLYFIN_PASSWORD');
|
||
|
|
|
||
|
|
void main() {
|
||
|
|
// Frames as the app draws them, as the picture is checked.
|
||
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized().framePolicy =
|
||
|
|
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||
|
|
|
||
|
|
testWidgets('streams a video with subtitles from the server and plays it', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
final client = http.Client();
|
||
|
|
addTearDown(client.close);
|
||
|
|
final gateway = HttpJellyfinGateway(
|
||
|
|
client,
|
||
|
|
device: (id: 'device-check', name: 'Device check', version: '0'),
|
||
|
|
);
|
||
|
|
final found = await tester.runAsync(() async {
|
||
|
|
final server = await gateway.server(Uri.parse(_url));
|
||
|
|
final account = await gateway.signIn(server, _user, _password);
|
||
|
|
final videos = await gateway.search(account, '');
|
||
|
|
final libraries = await gateway.libraries(account);
|
||
|
|
// Down the first library to its first video.
|
||
|
|
var items = [...videos, ...libraries];
|
||
|
|
for (var depth = 0; depth < 4; depth++) {
|
||
|
|
final video = items.where((item) => item.kind.isVideo).firstOrNull;
|
||
|
|
if (video != null) {
|
||
|
|
final playback = await gateway.playback(account, video.id);
|
||
|
|
final track = playback.subtitles
|
||
|
|
.where((track) => track.isText)
|
||
|
|
.firstOrNull;
|
||
|
|
final cues = track == null
|
||
|
|
? const <SubtitleCue>[]
|
||
|
|
: parseSubtitles(
|
||
|
|
await gateway.subtitles(account, playback, track),
|
||
|
|
);
|
||
|
|
return (account: account, playback: playback, cues: cues);
|
||
|
|
}
|
||
|
|
items = items.isEmpty
|
||
|
|
? items
|
||
|
|
: await gateway.children(account, items.first.id);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
});
|
||
|
|
expect(found, isNotNull, reason: 'No video found on $_url');
|
||
|
|
final (:account, :playback, :cues) = found!;
|
||
|
|
expect(cues, isNotEmpty, reason: 'The video has no text subtitles');
|
||
|
|
|
||
|
|
final players = PlatformMediaPlayers();
|
||
|
|
expect(players.available, isTrue);
|
||
|
|
// From the middle, where a picture is unlikely to be one colour.
|
||
|
|
final video = (await tester.runAsync(
|
||
|
|
() => players.open(
|
||
|
|
gateway.streamOf(account, playback),
|
||
|
|
headers: gateway.headersOf(account),
|
||
|
|
start: (playback.runTime ?? Duration.zero) ~/ 2,
|
||
|
|
),
|
||
|
|
))!;
|
||
|
|
addTearDown(() => tester.runAsync(video.dispose));
|
||
|
|
final picture = GlobalKey();
|
||
|
|
await tester.pumpWidget(
|
||
|
|
MaterialApp(
|
||
|
|
home: Scaffold(
|
||
|
|
body: Center(
|
||
|
|
child: RepaintBoundary(
|
||
|
|
key: picture,
|
||
|
|
child: SizedBox(width: 640, height: 360, child: video.view()),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
await tester.runAsync(() async {
|
||
|
|
await video.play();
|
||
|
|
await Future<void>.delayed(const Duration(seconds: 4));
|
||
|
|
});
|
||
|
|
await tester.pump();
|
||
|
|
|
||
|
|
final state = video.state.value;
|
||
|
|
expect(state.error, isNull);
|
||
|
|
expect(state.duration, greaterThan(Duration.zero));
|
||
|
|
expect(state.position, greaterThan(const Duration(seconds: 1)));
|
||
|
|
// The picture is drawn, not only the sound played: a frame isn't one
|
||
|
|
// flat colour.
|
||
|
|
final boundary =
|
||
|
|
picture.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||
|
|
final frame = (await tester.runAsync(() async {
|
||
|
|
final image = await boundary.toImage();
|
||
|
|
return (await image.toByteData())!;
|
||
|
|
}))!;
|
||
|
|
final lightness = [
|
||
|
|
for (var i = 0; i < frame.lengthInBytes; i += 4)
|
||
|
|
frame.getUint8(i) + frame.getUint8(i + 1) + frame.getUint8(i + 2),
|
||
|
|
];
|
||
|
|
final mean = lightness.reduce((a, b) => a + b) / lightness.length;
|
||
|
|
final spread =
|
||
|
|
lightness.map((value) => (value - mean).abs()).reduce((a, b) => a + b) /
|
||
|
|
lightness.length;
|
||
|
|
expect(spread, greaterThan(4), reason: 'The picture is one flat colour');
|
||
|
|
|
||
|
|
await tester.runAsync(() => video.seekTo(cues.first.start));
|
||
|
|
await tester.runAsync(
|
||
|
|
() => Future<void>.delayed(const Duration(milliseconds: 500)),
|
||
|
|
);
|
||
|
|
expect(
|
||
|
|
cueAt(cues, video.state.value.position + const Duration(milliseconds: 1)),
|
||
|
|
isNotNull,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|