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 \ // --dart-define=JELLYFIN_URL=https://jellyfin.example.org \ // --dart-define=JELLYFIN_USER= \ // --dart-define=JELLYFIN_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 [] : 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.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.delayed(const Duration(milliseconds: 500)), ); expect( cueAt(cues, video.state.value.position + const Duration(milliseconds: 1)), isNotNull, ); }); }