summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 08:51:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 08:51:49 +0300
commit87c6ebd9949866775e1c62041cf6cf15009d8de6 (patch)
treec7e621827fe0b9c43de2aa56f20fb7eddd865507
parent1edf0e1add29f7f342d38b595a7a563a75414d0c (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.dart68
-rw-r--r--player-android/lib/api/player_api_client.dart8
-rw-r--r--player-android/lib/screens/video_player_screen.dart339
-rw-r--r--player-android/test/screens/video_player_screen_test.dart293
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);
+ });
+ });
+}