diff options
Diffstat (limited to 'player-android/lib/screens/video_player_screen.dart')
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 339 |
1 files changed, 314 insertions, 25 deletions
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(); + } } |
