diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 08:51:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 08:51:49 +0300 |
| commit | 87c6ebd9949866775e1c62041cf6cf15009d8de6 (patch) | |
| tree | c7e621827fe0b9c43de2aa56f20fb7eddd865507 | |
| parent | 1edf0e1add29f7f342d38b595a7a563a75414d0c (diff) | |
Implement VideoPlayerScreen with chewie, progress sync, and bearer auth (za)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 68 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 8 | ||||
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 339 | ||||
| -rw-r--r-- | player-android/test/screens/video_player_screen_test.dart | 293 |
4 files changed, 683 insertions, 25 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index d9b24ed..533c951 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -264,6 +264,74 @@ class DioPlayerApiClient extends PlayerApiClient { _getBytesFromUrl('$_kApiV1/media/$mediaId/thumbnail'); // --------------------------------------------------------------------------- + // Progress + // --------------------------------------------------------------------------- + + /// Returns the last saved playback position for [mediaId], or `null`. + /// + /// GET /api/v1/media/{id} — extracts the `progress.position_seconds` field + /// from the response envelope. Returns `null` when there is no saved + /// progress or when the item has been marked as finished (so the player + /// starts from the beginning rather than the very end). + @override + Future<double?> getMediaProgress(int mediaId) async { + final response = await rawDio.get<Map<String, dynamic>>( + '$_kApiV1/media/$mediaId', + ); + + final envelope = response.data ?? {}; + final progress = envelope['progress']; + + // Return null when there is no progress row or when the item is finished + // (a finished item should restart from the beginning, not resume near end). + if (progress is! Map<String, dynamic>) return null; + if (progress['finished'] == true) return null; + + final positionSeconds = progress['position_seconds']; + if (positionSeconds is num) return positionSeconds.toDouble(); + return null; + } + + /// Saves a playback position for a single media item. + /// + /// POST /api/v1/progress + /// Call this periodically while the user is playing (e.g. every 5 seconds). + /// The server increments [play_count] based on a 60-second accumulator so + /// frequent updates are both safe and encouraged. + @override + Future<void> updateProgress({ + required int mediaId, + required double positionSeconds, + }) async { + await rawDio.post<void>( + '$_kApiV1/progress', + data: { + 'media_id': mediaId, + 'position_seconds': positionSeconds, + }, + ); + } + + /// Marks a media item as finished or resets its progress. + /// + /// POST /api/v1/progress/status + /// [status] must be either `"finished"` or `"not_started"`. + /// Use `"finished"` when playback reaches the 95% threshold. + @override + Future<void> updateProgressStatus({ + required int mediaId, + required String status, + }) async { + await rawDio.post<void>( + '$_kApiV1/progress/status', + data: { + 'media_id': mediaId, + 'status': status, + }, + ); + } + + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index e9d99a0..7af4c8d 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -217,6 +217,14 @@ class PlayerApiClient { }) => throw UnimplementedError(); + /// Returns the last saved playback position in seconds for [mediaId], + /// or `null` if the user has never started this item. + /// + /// Fetches the progress from the `GET /api/v1/media/{id}` envelope rather + /// than a dedicated progress endpoint, keeping the surface area small while + /// still giving the player screen the position it needs for resume-on-open. + Future<double?> getMediaProgress(int mediaId) => throw UnimplementedError(); + Future<List<Media>> listInProgress() => throw UnimplementedError(); // --------------------------------------------------------------------------- diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart index 280a4b9..311e51f 100644 --- a/player-android/lib/screens/video_player_screen.dart +++ b/player-android/lib/screens/video_player_screen.dart @@ -1,24 +1,37 @@ -// ignore_for_file: unused_import -// The chewie and video_player imports are intentionally present even in this -// placeholder so that package resolution is verified at analysis time and the -// import graph is established before feature implementation begins. +import 'dart:async'; + import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:video_player/video_player.dart'; -/// Placeholder video player screen — full implementation is deferred. -/// -/// Accepts [mediaId] (the route path parameter) and [mediaUrl] (the resolved -/// stream URL, passed as route extra) so the router wiring is established and -/// the package imports are verified before feature work begins. +import '../api/player_api_client.dart'; +import '../providers/api_client_provider.dart'; + +// How often progress updates are emitted to the server while playing. +const _kProgressInterval = Duration(seconds: 5); + +// Playback fraction at which the item is considered finished (95 %). +const _kFinishedThreshold = 0.95; + +// --------------------------------------------------------------------------- +// VideoPlayerScreen +// --------------------------------------------------------------------------- + +/// Full-screen video player that streams from `/api/v1/media/{id}/stream`. /// -/// TODO(video-player): Convert to [StatefulWidget]. In [State.initState] -/// create [VideoPlayerController.networkUrl] from [mediaUrl], then wrap it -/// in a [ChewieController] with `aspectRatio`, `autoPlay`, etc. Dispose -/// both controllers in [State.dispose]. -/// See: https://pub.dev/packages/chewie -/// https://pub.dev/packages/video_player -class VideoPlayerScreen extends StatelessWidget { +/// Design decisions: +/// - [ConsumerStatefulWidget] gives access to Riverpod providers while +/// holding the mutable controller state in [State]. +/// - Bearer token is attached via `httpHeaders` on [VideoPlayerController] +/// so the native platform layer (ExoPlayer / AVPlayer) can authenticate +/// directly without routing bytes through Dart. +/// - Progress updates (every [_kProgressInterval]) and the finished mark +/// are fire-and-forget: errors are swallowed silently so a transient +/// network blip never interrupts playback. +/// - Both controllers are disposed in [dispose] to prevent resource leaks. +/// - All async continuations guard on [mounted] before calling [setState]. +class VideoPlayerScreen extends ConsumerStatefulWidget { const VideoPlayerScreen({ super.key, required this.mediaId, @@ -28,29 +41,305 @@ class VideoPlayerScreen extends StatelessWidget { /// The media item identifier extracted from the '/video/:mediaId' route path. final String mediaId; - /// The resolved HLS/direct stream URL, optionally provided as a route extra. - /// Will be required once real playback is wired up. + /// The resolved HLS/direct stream URL, optionally provided as route extra. + /// When null, [PlayerApiClient.streamUrl] is called to derive the URL so the + /// base URL stays in a single place (Dependency Inversion Principle). final String? mediaUrl; @override + ConsumerState<VideoPlayerScreen> createState() => _VideoPlayerScreenState(); +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> { + // Nullable until initialisation completes (or fails). + VideoPlayerController? _videoController; + ChewieController? _chewieController; + + // Non-null when initialisation failed; shown in the error view. + String? _error; + + // True while the controllers are being set up; shows a full-screen spinner. + bool _isLoading = true; + + // Prevents emitting a "finished" update more than once per playback session. + bool _finishedEmitted = false; + + // Periodic timer that fires every [_kProgressInterval] while playing. + Timer? _progressTimer; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + @override + void initState() { + super.initState(); + // Defer initialisation so all Riverpod provider overrides are applied + // before we read from [ref] (important for widget tests). + WidgetsBinding.instance.addPostFrameCallback((_) => _initPlayer()); + } + + @override + void dispose() { + _progressTimer?.cancel(); + _chewieController?.dispose(); + _videoController?.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Player initialisation + // --------------------------------------------------------------------------- + + /// Initialises [VideoPlayerController] and [ChewieController]. + /// + /// Steps: + /// 1. Resolve the stream URL (from route extra or [PlayerApiClient]). + /// 2. Read the bearer token for the `Authorization` header. + /// 3. Create and initialise [VideoPlayerController.networkUrl]. + /// 4. Fetch the saved position via [getMediaProgress] and seek to it. + /// 5. Wrap in [ChewieController] and start the progress ticker. + Future<void> _initPlayer() async { + if (!mounted) return; + + final client = ref.read(apiClientProvider); + final storage = ref.read(tokenStorageProvider); + final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; + + // Step 1: resolve the stream URL — prefer the route-extra URL so the + // calling screen can forward a pre-computed URL; fall back to streamUrl. + final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); + + // Step 2: read the bearer token so the native player can authenticate + // without routing bytes through Dart (performance and correctness). + final token = await storage.readToken(); + if (!mounted) return; + + final headers = <String, String>{ + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + }; + + // Step 3: create and initialise the VideoPlayerController. + VideoPlayerController videoController; + try { + videoController = VideoPlayerController.networkUrl( + Uri.parse(url), + httpHeaders: headers, + ); + await videoController.initialize(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = _initErrorMessage(e); + _isLoading = false; + }); + return; + } + + if (!mounted) { + videoController.dispose(); + return; + } + + // Step 4: resume from the server-saved position (best-effort; ignore + // errors so a missing progress row never blocks playback). + try { + final savedSeconds = await client.getMediaProgress(mediaIdInt); + if (savedSeconds != null && savedSeconds > 0) { + await videoController.seekTo( + Duration(milliseconds: (savedSeconds * 1000).round()), + ); + } + } catch (_) { + // Progress fetch failure is non-fatal; start from the beginning. + } + + if (!mounted) { + videoController.dispose(); + return; + } + + // Step 5: wrap in ChewieController with sensible defaults for a + // distraction-free full-screen experience. + final chewieController = ChewieController( + videoPlayerController: videoController, + autoPlay: true, + looping: false, + allowFullScreen: true, + allowMuting: true, + showOptions: false, + ); + + setState(() { + _videoController = videoController; + _chewieController = chewieController; + _isLoading = false; + }); + + // Start the periodic progress ticker now that playback is ready. + _startProgressTicker(mediaIdInt, client); + } + + // --------------------------------------------------------------------------- + // Progress reporting + // --------------------------------------------------------------------------- + + /// Starts a periodic timer that emits progress updates every + /// [_kProgressInterval] and marks the item finished at [_kFinishedThreshold]. + /// + /// The [client] reference is captured once here so we avoid accessing [ref] + /// inside the timer callback after the widget may have been disposed. + void _startProgressTicker(int mediaId, PlayerApiClient client) { + _progressTimer = Timer.periodic(_kProgressInterval, (_) async { + final vc = _videoController; + if (vc == null) return; + + // Skip network calls while paused — no progress to record and avoids + // unnecessary server traffic when the user has paused playback. + if (!vc.value.isPlaying) return; + + final position = vc.value.position; + final duration = vc.value.duration; + + // Emit raw position update — fire-and-forget so a transient network + // error never interrupts playback. + try { + await client.updateProgress( + mediaId: mediaId, + positionSeconds: position.inMilliseconds / 1000.0, + ); + } catch (_) {} + + // Mark finished once when playback fraction reaches the threshold. + // Guard with [_finishedEmitted] to avoid duplicate server calls. + if (!_finishedEmitted && + duration.inMilliseconds > 0 && + position.inMilliseconds / duration.inMilliseconds >= + _kFinishedThreshold) { + _finishedEmitted = true; + try { + await client.updateProgressStatus( + mediaId: mediaId, + status: 'finished', + ); + } catch (_) {} + } + }); + } + + // --------------------------------------------------------------------------- + // Error mapping + // --------------------------------------------------------------------------- + + /// Converts a controller initialisation exception to a readable UI string. + /// + /// Kept in the state class because it is tightly coupled to this screen's + /// error UI — no general-purpose helper needed (YAGNI). + String _initErrorMessage(Object e) { + final detail = e.toString(); + if (detail.isNotEmpty && detail != 'null') { + return 'Playback failed: $detail'; + } + return 'Could not start video playback. Please try again.'; + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('Video – $mediaId')), - body: const Center( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text('Video – ${widget.mediaId}'), + ), + body: _buildBody(), + ); + } + + /// Selects the appropriate body widget based on current state. + Widget _buildBody() { + if (_isLoading) return _buildLoadingView(); + if (_error != null) return _buildErrorView(_error!); + return _buildPlayerView(); + } + + /// Full-screen loading spinner shown while the player initialises. + Widget _buildLoadingView() { + return const Center( + key: Key('video_player_loading'), + child: CircularProgressIndicator(), + ); + } + + /// Error view shown when initialisation fails. + /// + /// Provides a human-readable message and a retry button so the user can + /// attempt re-initialisation without navigating away. + Widget _buildErrorView(String message) { + return Center( + key: const Key('video_player_error'), + child: Padding( + padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.videocam_outlined, size: 64), - SizedBox(height: 16), - Text('Video player TODO', style: TextStyle(fontSize: 18)), - SizedBox(height: 8), + const Icon(Icons.error_outline, color: Colors.white70, size: 64), + const SizedBox(height: 16), Text( - 'Will use video_player + chewie for playback controls.', + message, + style: const TextStyle(color: Colors.white70), textAlign: TextAlign.center, + key: const Key('video_player_error_message'), + ), + const SizedBox(height: 24), + ElevatedButton( + key: const Key('video_player_retry'), + onPressed: _onRetry, + child: const Text('Retry'), ), ], ), ), ); } + + /// The Chewie player widget that fills the available space. + Widget _buildPlayerView() { + return Center( + key: const Key('video_player_chewie'), + child: AspectRatio( + aspectRatio: _videoController!.value.aspectRatio, + child: Chewie(controller: _chewieController!), + ), + ); + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + /// Tears down current controllers and re-runs [_initPlayer]. + /// + /// Extracted to keep [_buildErrorView] below 30 lines (style guideline). + void _onRetry() { + _progressTimer?.cancel(); + _chewieController?.dispose(); + _videoController?.dispose(); + setState(() { + _chewieController = null; + _videoController = null; + _error = null; + _isLoading = true; + _finishedEmitted = false; + }); + _initPlayer(); + } } diff --git a/player-android/test/screens/video_player_screen_test.dart b/player-android/test/screens/video_player_screen_test.dart new file mode 100644 index 0000000..8f24817 --- /dev/null +++ b/player-android/test/screens/video_player_screen_test.dart @@ -0,0 +1,293 @@ +// Widget tests for VideoPlayerScreen (video_player_screen.dart). +// +// Tests cover: +// 1. Loading indicator shown during the initial build before initState fires. +// 2. Error view rendered when VideoPlayerController.initialize() throws. +// 3. Retry button re-triggers initialisation and ends in an error state. +// 4. Screen renders the AppBar title containing the mediaId. +// 5. Stream URL resolution (route-extra URL and client.streamUrl fallback). +// +// VideoPlayerController relies on native platform channels (ExoPlayer / +// AVPlayer) that are unavailable in the Flutter test harness. We exploit the +// fact that VideoPlayerController.initialize() throws a MissingPluginException, +// turning every initialisation attempt into a predictable error path — which +// is exactly what we need for error-state coverage. +// +// Timing notes: +// - [VideoPlayerScreen] uses addPostFrameCallback to start _initPlayer so +// that Riverpod provider overrides are fully applied before the first read. +// - pumpWidget() renders the first frame with _isLoading = true. +// - pump() processes addPostFrameCallback → _initPlayer() → throws → error. +// - Therefore the loading spinner is visible immediately after pumpWidget() +// but NOT after a subsequent pump(); check it before pumping. +// +// Run with: flutter test test/screens/video_player_screen_test.dart + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/screens/video_player_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed test token. +/// +/// Avoids the platform-specific OS keychain in widget tests. +class _FakeTokenStorage implements TokenStorage { + const _FakeTokenStorage(); + + @override + Future<String?> readToken() async => 'test-token'; + + @override + Future<void> writeToken(String token) async {} + + @override + Future<void> deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [VideoPlayerScreen] tests. +/// +/// Only the progress methods and [streamUrl] are implemented; all other +/// methods throw [UnimplementedError] to catch unexpected usage immediately. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// Records how many times [getMediaProgress] was called. + int getMediaProgressCallCount = 0; + + /// When non-null, [getMediaProgress] returns this value. + double? progressResult; + + /// Records how many times [updateProgress] was called. + int updateProgressCallCount = 0; + + /// Records how many times [updateProgressStatus] was called. + int updateProgressStatusCallCount = 0; + + @override + Future<double?> getMediaProgress(int mediaId) async { + getMediaProgressCallCount++; + return progressResult; + } + + @override + Future<void> updateProgress({ + required int mediaId, + required double positionSeconds, + }) async { + updateProgressCallCount++; + } + + @override + Future<void> updateProgressStatus({ + required int mediaId, + required String status, + }) async { + updateProgressStatusCallCount++; + } + + /// Returns a synthetic stream URL so [VideoPlayerController.networkUrl] can + /// be constructed even though it will fail to initialise (no platform). + @override + String streamUrl(int mediaId) => + 'http://localhost:8080/api/v1/media/$mediaId/stream'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Pumps [VideoPlayerScreen] for [mediaId] inside a [ProviderScope] with +/// overridden providers, backed by a minimal [GoRouter] for navigation. +/// +/// [mediaUrl] may be supplied to exercise the route-extra URL path; when null +/// the screen falls back to [PlayerApiClient.streamUrl]. +/// +/// The returned widget is rendered after the first frame but BEFORE +/// addPostFrameCallback fires, so _isLoading is still true. +Future<void> _pumpScreen( + WidgetTester tester, + _FakeApiClient fakeClient, { + String mediaId = '42', + String? mediaUrl, +}) async { + final router = GoRouter( + initialLocation: '/video/$mediaId', + routes: [ + GoRoute( + path: '/video/:mediaId', + builder: (context, state) => VideoPlayerScreen( + mediaId: state.pathParameters['mediaId']!, + mediaUrl: mediaUrl, + ), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets( + 'shows a loading indicator immediately after pumpWidget (before initState callback fires)', + (tester) async { + final fakeClient = _FakeApiClient(); + // pumpWidget renders the first frame with _isLoading == true but does + // NOT fire addPostFrameCallback yet — that fires on the next pump. + await _pumpScreen(tester, fakeClient); + + // Immediately after pumpWidget the initial build has completed with + // _isLoading = true; the spinner must be visible at this point. + expect( + find.byKey(const Key('video_player_loading')), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Drain remaining async work to avoid "pending timers" warnings. + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets( + 'shows error view when VideoPlayerController fails to initialise', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient); + // pumpAndSettle processes addPostFrameCallback → _initPlayer → throws → + // error state is rendered. + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_player_error')), findsOneWidget); + }); + + testWidgets('error view contains a human-readable message', (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The error message key should be present and contain non-empty text. + expect( + find.byKey(const Key('video_player_error_message')), + findsOneWidget, + ); + // Verify the text widget is present with a non-empty string inside the + // error_message keyed slot. + final textWidget = tester.widget<Text>( + find.byKey(const Key('video_player_error_message')), + ); + expect(textWidget.data, isNotEmpty); + }); + + testWidgets('error view contains a retry button', (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('video_player_retry')), findsOneWidget); + }); + + testWidgets( + 'tapping retry eventually shows the error state again after re-initialisation', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Confirm we start in the error state. + expect(find.byKey(const Key('video_player_error')), findsOneWidget); + + // Tap Retry — this calls _onRetry which calls setState then _initPlayer. + await tester.tap(find.byKey(const Key('video_player_retry'))); + + // Let the second initialisation attempt complete (also fails in test + // harness due to no platform plugin) and settle back into error state. + await tester.pumpAndSettle(); + expect(find.byKey(const Key('video_player_error')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // AppBar + // -------------------------------------------------------------------------- + + group('app bar', () { + testWidgets('renders title containing the mediaId', (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient, mediaId: '99'); + await tester.pump(); + + // The title includes the mediaId string somewhere in the widget tree. + expect(find.textContaining('99'), findsWidgets); + }); + }); + + // -------------------------------------------------------------------------- + // Stream URL resolution + // -------------------------------------------------------------------------- + + group('stream URL resolution', () { + testWidgets('transitions through loading to error when mediaUrl is null', + (tester) async { + // When mediaUrl is null the screen calls client.streamUrl(mediaId). + // We verify the full lifecycle: starts loading → error after init fails. + final fakeClient = _FakeApiClient(); + await _pumpScreen(tester, fakeClient, mediaUrl: null); + + // Immediately after pumpWidget the loading state is visible. + expect(find.byKey(const Key('video_player_loading')), findsOneWidget); + + // After settling, error state is shown (platform plugin missing). + await tester.pumpAndSettle(); + expect(find.byKey(const Key('video_player_error')), findsOneWidget); + }); + + testWidgets( + 'transitions through loading to error when an explicit mediaUrl is given', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpScreen( + tester, + fakeClient, + mediaUrl: 'http://localhost:8080/api/v1/media/42/stream', + ); + + // Loading state visible immediately after pumpWidget. + expect(find.byKey(const Key('video_player_loading')), findsOneWidget); + + // Settles to error state (VideoPlayerController fails in test harness). + await tester.pumpAndSettle(); + expect(find.byKey(const Key('video_player_error')), findsOneWidget); + }); + }); +} |
