import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:reelbox/data/models.dart'; import 'package:reelbox/media/media_platform.dart'; import 'package:reelbox/media/player_page.dart'; import 'package:reelbox/state/app_state.dart'; import 'package:shared_preferences/shared_preferences.dart'; const title = TitleRecord(id: 78, title: 'Film'); const source = MediaSource( url: 'https://media.test/film.mkv?token=unchanged', type: 'mkv', ); Map exitAt( int position, { bool opened = true, bool completed = false, }) => { 'positionMs': position, 'durationMs': 3600000, 'opened': opened, 'completed': completed, }; Future mount( WidgetTester tester, { EpisodeRecord? episode, List playlist = const [], List alternatives = const [], String? localFile, }) async { SharedPreferences.setMockInitialValues({}); final state = AppState(storage: await SharedPreferences.getInstance()); await state.initialize(discover: false); await state.updateSettings(state.prefs.copyWith(autoplay: true)); await state.recordWatch( title, sourceUrl: source.url, positionSeconds: 600, durationSeconds: 3600, episodeId: episode?.id, episodeTitle: episode?.title ?? '', ); await tester.pumpWidget( AppScope( state: state, child: MaterialApp( home: PlayerPage( title: title, source: source, episode: episode, playlist: playlist, alternatives: alternatives, localFile: localFile, ), ), ), ); await tester.pump(); return state; } void main() { testWidgets( 'early native EOF never advances or records a completed episode', (tester) async { const first = EpisodeRecord( id: 101, title: 'Episode 1', sources: [source], ); const second = EpisodeRecord( id: 102, title: 'Episode 2', sources: [MediaSource(url: 'https://media.test/episode2.mkv')], ); final calls = []; final firstExit = Completer>(); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, (call) async { if (call.method != 'openNativePlayer') return null; calls.add(call); return calls.length == 1 ? firstExit.future : exitAt(0, opened: false); }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, null, ), ); final state = await mount( tester, episode: first, playlist: [first, second], ); addTearDown(state.dispose); firstExit.complete(exitAt(180000, completed: true)); await tester.pumpAndSettle(); expect( calls, hasLength(1), reason: 'Three minutes is not the end of this episode', ); expect(state.history, hasLength(1)); expect(state.history.single.episodeId, first.id); expect(state.history.single.positionSeconds, 180); expect(state.history.single.durationSeconds, 3600); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.android), ); testWidgets( 'auto-next recovery keeps alternatives and history on the current episode', (tester) async { const oldAlternative = MediaSource( url: 'https://media.test/episode1-720.mkv', quality: '720p', ); const first = EpisodeRecord( id: 101, title: 'Episode 1', sources: [source, oldAlternative], ); const secondSource = MediaSource(url: 'https://media.test/episode2.mkv'); const secondAlternative = MediaSource( url: 'https://media.test/episode2-720.mkv?token=unchanged', quality: '720p', ); const trailer = MediaSource( url: 'https://media.test/episode2-trailer.mp4', quality: 'Trailer', ); const denied = MediaSource( url: 'https://t.me/episode2', quality: 'Denied', ); const second = EpisodeRecord( id: 102, title: 'Episode 2', sources: [secondSource, secondAlternative, trailer, denied], ); final calls = []; final firstExit = Completer>(); final recoveryExit = Completer>(); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, (call) async { if (call.method != 'openNativePlayer') return null; calls.add(call); if (calls.length == 1) return firstExit.future; if (calls.length == 2) return exitAt(0, opened: false); return recoveryExit.future; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, null, ), ); final state = await mount( tester, episode: first, playlist: [first, second], alternatives: first.sources, ); addTearDown(state.dispose); firstExit.complete(exitAt(3600000, completed: true)); await tester.pumpAndSettle(); expect(calls.length, 2); expect(calls.last.arguments['url'], secondSource.url); expect(find.text('Episode 2'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); await tester.tap(find.widgetWithText(ListTile, '720p')); await tester.pump(); expect(calls.length, 3); // Both episodes offer 720p: the row must dispatch episode 2, not 1. final recoveryUrl = calls.last.arguments['url']; recoveryExit.complete(exitAt(10000)); await tester.pumpAndSettle(); final recovered = state.history.singleWhere((e) => e.episodeId == 102); expect(recovered.episodeTitle, second.title); expect(recovered.positionSeconds, 10); expect(recoveryUrl, secondAlternative.url); expect(recovered.sourceUrl, secondAlternative.url); expect(state.history.map((e) => e.episodeId).toSet(), {101, 102}); expect(find.text('Trailer'), findsNothing); expect(find.text('Denied'), findsNothing); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.android), ); testWidgets( 'Android default player dispatches VLC with resume and preserves source identity', (tester) async { final calls = []; final result = Completer>(); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, (call) async { calls.add(call); if (call.method == 'openNativePlayer') return result.future; return null; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, null, ), ); final state = await mount(tester); addTearDown(state.dispose); await tester.pump(const Duration(milliseconds: 100)); final call = calls.singleWhere( (call) => call.method == 'openNativePlayer', ); expect(call.arguments['url'], source.url); expect(call.arguments['positionMs'], 595000); expect(call.arguments['autoplay'], true); expect(state.history.single.positionSeconds, 600); result.complete(exitAt(700000)); await tester.pumpAndSettle(); expect(state.history.single.positionSeconds, 700); expect(state.history.single.sourceUrl, source.url); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.android), ); testWidgets('failed native open never overwrites a saved checkpoint', ( tester, ) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, (call) async { if (call.method == 'openNativePlayer') return exitAt(0, opened: false); return null; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, null, ), ); final state = await mount(tester); addTearDown(state.dispose); await tester.pumpAndSettle(); expect(state.history.single.positionSeconds, 600); expect(find.text('External player'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets( 'natural native episode completion opens next episode without losing history', (tester) async { const first = EpisodeRecord( id: 101, title: 'Episode 1', sources: [source], ); const second = EpisodeRecord( id: 102, title: 'Episode 2', sources: [MediaSource(url: 'https://media.test/episode2.mkv')], ); final calls = []; final firstExit = Completer>(); final secondExit = Completer>(); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, (call) async { if (call.method != 'openNativePlayer') return null; calls.add(call); return calls.length == 1 ? firstExit.future : secondExit.future; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( MediaPlatform.channel, null, ), ); final state = await mount( tester, episode: first, playlist: [first, second], ); addTearDown(state.dispose); await tester.pump(const Duration(milliseconds: 100)); expect(calls.length, 1); firstExit.complete(exitAt(3600000, completed: true)); await tester.pump(const Duration(milliseconds: 200)); expect(calls.length, 2); expect(calls.last.arguments['url'], second.sources.single.url); expect(calls.last.arguments['positionMs'], 0); expect(state.history.single.episodeId, 101); secondExit.complete(exitAt(10000)); await tester.pumpAndSettle(); expect(state.history.map((e) => e.episodeId).toSet(), {101, 102}); expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.only(TargetPlatform.android), ); }