summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 18:21:56 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 18:21:56 +0300
commit627f44e105926e5ad4963336cdbfd9f9522bcdb1 (patch)
tree50878446d6770361d4b755a21954da0052afd072
parent753dbb942a6c5c98db4b9048c7dd80ca7ec866ee (diff)
Implement ContinueWatchingScreen with resume cards and progress routing (2b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/api/dio_player_api_client.dart13
-rw-r--r--player-android/lib/app_routes.dart11
-rw-r--r--player-android/lib/router.dart47
-rw-r--r--player-android/lib/screens/audio_player_screen.dart15
-rw-r--r--player-android/lib/screens/continue_watching_screen.dart483
-rw-r--r--player-android/lib/screens/video_player_screen.dart15
-rw-r--r--player-android/lib/utils/error_mappers.dart13
-rw-r--r--player-android/test/screens/continue_watching_screen_test.dart483
8 files changed, 1064 insertions, 16 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart
index a1ed037..f591608 100644
--- a/player-android/lib/api/dio_player_api_client.dart
+++ b/player-android/lib/api/dio_player_api_client.dart
@@ -267,6 +267,19 @@ class DioPlayerApiClient extends PlayerApiClient {
// Progress
// ---------------------------------------------------------------------------
+ /// Returns all media items the authenticated user has started but not finished.
+ ///
+ /// GET /api/v1/in-progress — returns the same [Media] array as GET /api/v1/media.
+ /// The caller (ContinueWatchingScreen) uses this to populate the resume list.
+ @override
+ Future<List<Media>> listInProgress() async {
+ final response = await rawDio.get<List<dynamic>>('$_kApiV1/in-progress');
+ return (response.data ?? [])
+ .cast<Map<String, dynamic>>()
+ .map(Media.fromJson)
+ .toList();
+ }
+
/// Returns the last saved playback position for [mediaId], or `null`.
///
/// GET /api/v1/media/{id} — extracts the `progress.position_seconds` field
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart
index bc2fda1..eefda4b 100644
--- a/player-android/lib/app_routes.dart
+++ b/player-android/lib/app_routes.dart
@@ -29,6 +29,9 @@ abstract final class AppRoutes {
/// Opens [PodcastListScreen] and supports the SubscribeDialog FAB.
static const podcasts = '/podcasts';
+ /// Route that shows the Continue Watching screen (in-progress media items).
+ static const continueWatching = '/continue';
+
/// Returns the concrete path for a media-detail page given a numeric [id].
static String mediaDetailPath(int id) => '/media/$id';
@@ -40,4 +43,12 @@ abstract final class AppRoutes {
/// Returns the concrete path for the audio player of a given [mediaId].
static String audioPlayerPath(String mediaId) => '/audio/$mediaId';
+
+ /// Returns the appropriate player path for [type] and [mediaId].
+ ///
+ /// Centralises the audio-vs-video routing decision so call-sites do not need
+ /// to repeat the same if/else. Audio maps to [audioPlayerPath]; every other
+ /// type (including 'video' and unknown) maps to [videoPlayerPath].
+ static String playerPathForType(String type, String mediaId) =>
+ type == 'audio' ? audioPlayerPath(mediaId) : videoPlayerPath(mediaId);
}
diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart
index 2a44c16..617a7f1 100644
--- a/player-android/lib/router.dart
+++ b/player-android/lib/router.dart
@@ -8,6 +8,7 @@ import 'providers/auth_state_provider.dart';
import 'providers/first_run_provider.dart';
import 'screens/audio_player_screen.dart';
import 'screens/bootstrap_screen.dart';
+import 'screens/continue_watching_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'screens/media_detail_screen.dart';
@@ -104,6 +105,11 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const SetsListScreen(),
),
GoRoute(
+ // Continue-watching screen — lists all in-progress media items.
+ path: AppRoutes.continueWatching,
+ builder: (context, state) => const ContinueWatchingScreen(),
+ ),
+ GoRoute(
path: AppRoutes.mediaGrid,
builder: (context, state) {
// The ':setId' path parameter is guaranteed by the route pattern.
@@ -143,11 +149,12 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) {
// ':mediaId' is guaranteed present by the route pattern.
final mediaId = state.pathParameters['mediaId']!;
- // The resolved stream URL is optionally forwarded as a route extra
- // (String) by the calling screen (e.g. MediaDetailScreen).
- final mediaUrl =
- state.extra is String ? state.extra as String : null;
- return VideoPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl);
+ final (mediaUrl, startPosition) = _parsePlayerExtra(state.extra);
+ return VideoPlayerScreen(
+ mediaId: mediaId,
+ mediaUrl: mediaUrl,
+ startPosition: startPosition,
+ );
},
),
GoRoute(
@@ -155,11 +162,12 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) {
// ':mediaId' is guaranteed present by the route pattern.
final mediaId = state.pathParameters['mediaId']!;
- // The resolved stream URL is optionally forwarded as a route extra
- // (String) by the calling screen (e.g. MediaDetailScreen).
- final mediaUrl =
- state.extra is String ? state.extra as String : null;
- return AudioPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl);
+ final (mediaUrl, startPosition) = _parsePlayerExtra(state.extra);
+ return AudioPlayerScreen(
+ mediaId: mediaId,
+ mediaUrl: mediaUrl,
+ startPosition: startPosition,
+ );
},
),
],
@@ -170,6 +178,25 @@ final routerProvider = Provider<GoRouter>((ref) {
// Internal helpers
// ---------------------------------------------------------------------------
+/// Parses the route [extra] passed to video and audio player routes.
+///
+/// Accepts two shapes forwarded by different call-sites:
+/// - `Map<String, dynamic>`: `{mediaUrl: String, position: double}` from
+/// [ContinueWatchingScreen] so the player can seek immediately without an
+/// extra [getMediaProgress] round-trip.
+/// - `String`: plain stream URL forwarded by [MediaDetailScreen].
+///
+/// Returns a record `(mediaUrl, startPosition)` with null for absent values.
+/// Extracted to avoid duplicating this logic across the video and audio routes.
+(String?, double?) _parsePlayerExtra(Object? extra) {
+ if (extra is Map<String, dynamic>) {
+ final mediaUrl = extra['mediaUrl'] as String?;
+ final startPosition = (extra['position'] as num?)?.toDouble();
+ return (mediaUrl, startPosition);
+ }
+ return (extra is String ? extra : null, null);
+}
+
/// Bridges Riverpod's auth and first-run providers to [GoRouter.refreshListenable].
///
/// GoRouter expects a [ChangeNotifier] (or any [Listenable]) for its refresh
diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart
index 7aa49de..00465e4 100644
--- a/player-android/lib/screens/audio_player_screen.dart
+++ b/player-android/lib/screens/audio_player_screen.dart
@@ -44,6 +44,7 @@ class AudioPlayerScreen extends ConsumerStatefulWidget {
super.key,
required this.mediaId,
this.mediaUrl,
+ this.startPosition,
});
/// The media item identifier extracted from the '/audio/:mediaId' route path.
@@ -54,6 +55,11 @@ class AudioPlayerScreen extends ConsumerStatefulWidget {
/// base URL stays in a single place (Dependency Inversion Principle).
final String? mediaUrl;
+ /// Optional start position in seconds, forwarded from the continue-watching
+ /// screen to resume at the saved position without an extra API round-trip.
+ /// When null, [PlayerApiClient.getMediaProgress] is called instead.
+ final double? startPosition;
+
@override
ConsumerState<AudioPlayerScreen> createState() => _AudioPlayerScreenState();
}
@@ -155,10 +161,13 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> {
return;
}
- // Step 4: resume from the server-saved position (best-effort; ignore
- // errors so a missing progress row never blocks playback).
+ // Step 4: resume from the saved position.
+ // Prefer [widget.startPosition] (forwarded by the continue-watching screen)
+ // to avoid a redundant API round-trip. Fall back to [getMediaProgress] so
+ // audio items opened from other screens still resume correctly.
try {
- final savedSeconds = await client.getMediaProgress(mediaIdInt);
+ final savedSeconds =
+ widget.startPosition ?? await client.getMediaProgress(mediaIdInt);
if (savedSeconds != null && savedSeconds > 0) {
await audioPlayer.seek(
Duration(milliseconds: (savedSeconds * 1000).round()),
diff --git a/player-android/lib/screens/continue_watching_screen.dart b/player-android/lib/screens/continue_watching_screen.dart
new file mode 100644
index 0000000..9153e01
--- /dev/null
+++ b/player-android/lib/screens/continue_watching_screen.dart
@@ -0,0 +1,483 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../api/player_api_client.dart';
+import '../app_routes.dart';
+import '../models/models.dart';
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
+
+/// Continue Watching screen: lists all media items the authenticated user has
+/// started but not finished, with a thumbnail, title, type icon, and duration.
+///
+/// Design notes:
+/// - [ConsumerStatefulWidget] is used so we can manage local loading and
+/// error state, guard async continuations on [mounted], and call
+/// [setState] to trigger rebuilds (mirrors [SetsListScreen] patterns).
+/// - [listInProgress] is called from [initState] and again on
+/// pull-to-refresh. The result is stored locally rather than in a
+/// Riverpod notifier because this screen owns its full lifecycle.
+/// - Error handling uses the top-level [continueWatchingErrorMessage] helper
+/// from error_mappers.dart (DIP — no Dio import in this file).
+/// - Tapping a card routes to `/video/:mediaId` or `/audio/:mediaId` with a
+/// [Map] extra carrying `{mediaUrl, position}` so the player can seek to
+/// the saved position without an extra API round-trip.
+class ContinueWatchingScreen extends ConsumerStatefulWidget {
+ const ContinueWatchingScreen({super.key});
+
+ @override
+ ConsumerState<ContinueWatchingScreen> createState() =>
+ _ContinueWatchingScreenState();
+}
+
+class _ContinueWatchingScreenState
+ extends ConsumerState<ContinueWatchingScreen> {
+ // Nullable: null means "not yet loaded" (loading indicator is shown).
+ List<Media>? _items;
+
+ // Non-null when the last load attempt failed.
+ String? _error;
+
+ // True while the initial or refresh load is in flight.
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // Defer the first load until after the first frame so [ref] is fully bound
+ // and any provider overrides in the test environment are applied.
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Data loading
+ // ---------------------------------------------------------------------------
+
+ /// Fetches all in-progress media items and updates local state.
+ ///
+ /// Called on first mount and on pull-to-refresh. Errors are mapped by the
+ /// top-level [continueWatchingErrorMessage] helper so this method stays simple.
+ Future<void> _load() async {
+ if (!mounted) return;
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final items = await client.listInProgress();
+ if (!mounted) return;
+ setState(() {
+ _items = items;
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = continueWatchingErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: _buildAppBar(),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Builds the app bar with title.
+ AppBar _buildAppBar() {
+ return AppBar(
+ title: const Text('Continue Watching'),
+ );
+ }
+
+ /// Builds the main body, delegating to the appropriate state widget:
+ /// - Loading spinner (first load, before any data arrives).
+ /// - Error view with a retry button.
+ /// - Empty-state message when the server returns an empty list.
+ /// - List of resume cards once data is available.
+ Widget _buildBody(BuildContext context) {
+ // Show a full-screen spinner only on the very first load (no data yet).
+ if (_isLoading && _items == null) {
+ return const Center(
+ key: Key('continue_watching_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ // Show an error view with a retry button if the load failed.
+ if (_error != null) {
+ return _ErrorView(message: _error!, onRetry: _load);
+ }
+
+ // [RefreshIndicator] wraps the scrollable content so pull-to-refresh
+ // triggers [_load] on both the list and the empty-state view.
+ // Read client once here so sub-widgets can build authenticated URLs
+ // (e.g. thumbnailUrl) without re-reading the provider on every rebuild.
+ final client = ref.read(apiClientProvider);
+ return RefreshIndicator(
+ onRefresh: _load,
+ child: _items == null || _items!.isEmpty
+ ? const _EmptyView()
+ : _ResumeList(items: _items!, client: client, onTap: _onCardTap),
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Navigation
+ // ---------------------------------------------------------------------------
+
+ /// Navigates to the appropriate player screen for [item].
+ ///
+ /// Passes a [Map] extra with `mediaUrl` and `position` so the player can
+ /// seek to the saved position without a separate [getMediaProgress] call
+ /// (avoids an extra API round-trip per resume action).
+ ///
+ /// [getMediaProgress] is called here to obtain the saved position. A null
+ /// result (no progress row) starts the player from the beginning.
+ Future<void> _onCardTap(Media item) async {
+ final client = ref.read(apiClientProvider);
+ final mediaId = item.id;
+ final mediaUrl = client.streamUrl(mediaId);
+
+ // Fetch saved position best-effort; null means "start from beginning".
+ double? position;
+ try {
+ position = await client.getMediaProgress(mediaId);
+ } catch (_) {
+ // Position fetch failure is non-fatal; the player starts from the start.
+ }
+
+ if (!mounted) return;
+
+ // Pass both the stream URL and the saved position so the player can seek
+ // immediately without a second round-trip to the server.
+ final extra = <String, dynamic>{
+ 'mediaUrl': mediaUrl,
+ if (position != null) 'position': position,
+ };
+
+ final mediaIdStr = mediaId.toString();
+ // Delegate audio-vs-video path selection to the centralised helper so this
+ // call-site does not duplicate the routing logic (OCP).
+ context.go(AppRoutes.playerPathForType(item.type, mediaIdStr), extra: extra);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sub-widgets
+// ---------------------------------------------------------------------------
+
+/// Scrollable list of resume cards.
+///
+/// Extracted into its own stateless widget so [_ContinueWatchingScreenState]
+/// stays small and the list layout is independently testable.
+class _ResumeList extends StatelessWidget {
+ const _ResumeList({
+ required this.items,
+ required this.client,
+ required this.onTap,
+ });
+
+ final List<Media> items;
+
+ /// API client forwarded to each card so thumbnails use authenticated URLs.
+ final PlayerApiClient client;
+
+ /// Called with the tapped [Media] item; the parent handles navigation.
+ final void Function(Media) onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView.builder(
+ key: const Key('continue_watching_list'),
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: items.length,
+ itemBuilder: (context, index) => _ResumeCard(
+ item: items[index],
+ client: client,
+ onTap: onTap,
+ ),
+ );
+ }
+}
+
+/// A single resume card showing thumbnail, title, type icon, and duration.
+///
+/// Tapping the card delegates navigation back to [_ContinueWatchingScreenState]
+/// via [onTap] (Single Responsibility — this widget is purely presentational).
+class _ResumeCard extends StatelessWidget {
+ const _ResumeCard({
+ required this.item,
+ required this.client,
+ required this.onTap,
+ });
+
+ final Media item;
+
+ /// API client used to build the authenticated thumbnail URL.
+ final PlayerApiClient client;
+
+ final void Function(Media) onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ key: Key('resume_card_${item.id}'),
+ margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: () => onTap(item),
+ child: Row(
+ children: [
+ // Thumbnail on the left: fixed 100×75 px, using the authenticated
+ // API endpoint rather than the raw relative server path.
+ _Thumbnail(item: item, client: client),
+ // Title, type icon and duration on the right.
+ Expanded(child: _CardDetails(item: item)),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Left-side thumbnail for a resume card.
+///
+/// Displays the media thumbnail via [CachedNetworkImage] with a placeholder
+/// and error fallback, keeping re-download count low across rebuilds.
+///
+/// Uses [client.thumbnailUrl] (the authenticated API endpoint) rather than
+/// [Media.thumbnailPath] (a relative server path that is not a valid URL).
+class _Thumbnail extends StatelessWidget {
+ const _Thumbnail({required this.item, required this.client});
+
+ final Media item;
+
+ /// API client used to build the authenticated thumbnail URL.
+ final PlayerApiClient client;
+
+ @override
+ Widget build(BuildContext context) {
+ // Use the authenticated thumbnail endpoint instead of the raw relative path.
+ final url = client.thumbnailUrl(item.id);
+
+ // Skip the network request entirely when the URL is empty (server has not
+ // generated a thumbnail yet) and fall back to the placeholder icon.
+ if (url.isEmpty) return _placeholder(context);
+
+ return SizedBox(
+ width: 100,
+ height: 75,
+ child: CachedNetworkImage(
+ imageUrl: url,
+ fit: BoxFit.cover,
+ width: 100,
+ height: 75,
+ placeholder: (_, __) => _loadingWidget(),
+ errorWidget: (_, __, ___) => _placeholder(context),
+ ),
+ );
+ }
+
+ static Widget _loadingWidget() => const SizedBox(
+ width: 100,
+ height: 75,
+ child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
+ );
+
+ static Widget _placeholder(BuildContext context) => SizedBox(
+ width: 100,
+ height: 75,
+ child: ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: Icon(
+ Icons.movie_outlined,
+ size: 36,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+}
+
+/// Right-side details: title, type icon badge, and total duration.
+///
+/// Shows a video/audio icon badge next to the title so the user can identify
+/// the media type at a glance without relying on colour alone.
+class _CardDetails extends StatelessWidget {
+ const _CardDetails({required this.item});
+
+ final Media item;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ // Title row with type icon badge.
+ Row(
+ children: [
+ _TypeIcon(type: item.type),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ item.fileName,
+ key: Key('resume_card_title_${item.id}'),
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ // Total duration: provides a rough "how much is left" sense even
+ // without the saved position in the list response.
+ Text(
+ _formatDuration(item.duration),
+ key: Key('resume_card_duration_${item.id}'),
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Formats [durationSeconds] as `mm:ss` or `h:mm:ss`.
+///
+/// Top-level function (consistent with the audio player screen pattern) so it
+/// can be reused without an instance and is easy to unit-test in isolation.
+String _formatDuration(double durationSeconds) {
+ final d = Duration(milliseconds: (durationSeconds * 1000).round());
+ final h = d.inHours;
+ final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
+ final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
+ return h > 0 ? '$h:$m:$s' : '$m:$s';
+}
+
+/// Small type-icon badge for video or audio items.
+///
+/// Uses [Icons.videocam_outlined] for video and [Icons.headphones] for audio,
+/// falling back to [Icons.play_circle_outline] for unknown types.
+class _TypeIcon extends StatelessWidget {
+ const _TypeIcon({required this.type});
+
+ final String type;
+
+ @override
+ Widget build(BuildContext context) {
+ final icon = switch (type) {
+ 'audio' => Icons.headphones,
+ 'video' => Icons.videocam_outlined,
+ _ => Icons.play_circle_outline,
+ };
+ return Icon(
+ icon,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ );
+ }
+}
+
+/// Full-screen empty-state view shown when [listInProgress] returns an empty list.
+///
+/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the
+/// [RefreshIndicator] can still trigger pull-to-refresh with no content.
+class _EmptyView extends StatelessWidget {
+ const _EmptyView();
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [
+ SizedBox(
+ height: MediaQuery.of(context).size.height * 0.6,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.play_circle_outline,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Nothing in progress',
+ key: const Key('continue_watching_empty'),
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Start playing something and it will appear here.',
+ style: Theme.of(context).textTheme.bodySmall,
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// Full-screen error view with a retry button.
+///
+/// Shown when [listInProgress] throws (network error, server error, etc.).
+/// The [message] comes from [continueWatchingErrorMessage].
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('continue_watching_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('continue_watching_retry'),
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart
index 311e51f..b3247a3 100644
--- a/player-android/lib/screens/video_player_screen.dart
+++ b/player-android/lib/screens/video_player_screen.dart
@@ -36,6 +36,7 @@ class VideoPlayerScreen extends ConsumerStatefulWidget {
super.key,
required this.mediaId,
this.mediaUrl,
+ this.startPosition,
});
/// The media item identifier extracted from the '/video/:mediaId' route path.
@@ -46,6 +47,11 @@ class VideoPlayerScreen extends ConsumerStatefulWidget {
/// base URL stays in a single place (Dependency Inversion Principle).
final String? mediaUrl;
+ /// Optional start position in seconds, forwarded from the continue-watching
+ /// screen to resume at the saved position without an extra API round-trip.
+ /// When null, [PlayerApiClient.getMediaProgress] is called instead.
+ final double? startPosition;
+
@override
ConsumerState<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
@@ -145,10 +151,13 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> {
return;
}
- // Step 4: resume from the server-saved position (best-effort; ignore
- // errors so a missing progress row never blocks playback).
+ // Step 4: resume from the saved position.
+ // Prefer [widget.startPosition] (forwarded by the continue-watching screen)
+ // to avoid a redundant API round-trip. Fall back to [getMediaProgress] so
+ // videos opened from other screens still resume correctly.
try {
- final savedSeconds = await client.getMediaProgress(mediaIdInt);
+ final savedSeconds =
+ widget.startPosition ?? await client.getMediaProgress(mediaIdInt);
if (savedSeconds != null && savedSeconds > 0) {
await videoController.seekTo(
Duration(milliseconds: (savedSeconds * 1000).round()),
diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart
index 6ab9731..2d6f446 100644
--- a/player-android/lib/utils/error_mappers.dart
+++ b/player-android/lib/utils/error_mappers.dart
@@ -145,6 +145,19 @@ String podcastListErrorMessage(Object error) {
return 'Unexpected error. Please try again.';
}
+/// Maps any thrown object from [PlayerApiClient.listInProgress] to a UI string.
+///
+/// Delegates to [dioConnectionErrorMessage] for [DioException]; returns a
+/// generic fallback for all other exception types. Kept as a separate function
+/// (Open-Closed) so it can evolve independently — for example, adding a 401
+/// message if session refresh is needed in a future iteration.
+String continueWatchingErrorMessage(Object error) {
+ if (error is DioException) {
+ return dioConnectionErrorMessage(error);
+ }
+ return 'Unexpected error. Please try again.';
+}
+
/// Maps any thrown object from [PlayerApiClient.subscribePodcast] to a UI string.
///
/// Adds human-readable messages for the common failure modes:
diff --git a/player-android/test/screens/continue_watching_screen_test.dart b/player-android/test/screens/continue_watching_screen_test.dart
new file mode 100644
index 0000000..f2ec15a
--- /dev/null
+++ b/player-android/test/screens/continue_watching_screen_test.dart
@@ -0,0 +1,483 @@
+// Widget tests for ContinueWatchingScreen (continue_watching_screen.dart).
+//
+// Tests cover:
+// 1. Renders a loading indicator while listInProgress is in flight.
+// 2. Renders resume cards after a successful load (title, duration keys).
+// 3. Shows the correct type icon for video and audio items.
+// 4. Empty-state widget when listInProgress returns [].
+// 5. Error view when listInProgress throws a DioException.
+// 6. Retry button calls listInProgress again.
+// 7. Pull-to-refresh calls listInProgress a second time.
+// 8. Tapping a video card routes to /video/:id.
+// 9. Tapping an audio card routes to /audio/:id.
+// 10. continueWatchingErrorMessage unit tests.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/continue_watching_screen_test.dart
+
+import 'dart:async';
+
+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/models/models.dart';
+import 'package:player_android/providers/api_client_provider.dart';
+import 'package:player_android/screens/continue_watching_screen.dart';
+import 'package:player_android/utils/error_mappers.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain.
+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 [ContinueWatchingScreen] tests.
+///
+/// Only [listInProgress], [streamUrl], and [getMediaProgress] are implemented;
+/// all other methods remain [UnimplementedError] — the screen calls only these.
+class _FakeApiClient extends PlayerApiClient {
+ _FakeApiClient() : super(dio: Dio());
+
+ /// When non-null, [listInProgress] returns this list.
+ List<Media>? inProgressResult;
+
+ /// When non-null, [listInProgress] throws this instead of returning.
+ Object? inProgressError;
+
+ /// Number of times [listInProgress] has been called; useful for refresh tests.
+ int listInProgressCallCount = 0;
+
+ @override
+ Future<List<Media>> listInProgress() async {
+ listInProgressCallCount++;
+ if (inProgressError != null) throw inProgressError!;
+ return inProgressResult!;
+ }
+
+ /// Returns a stable fake stream URL so navigation assertions can verify the
+ /// expected path without real network calls.
+ @override
+ String streamUrl(int mediaId) => 'http://fake/stream/$mediaId';
+
+ /// Returns an empty string so [_Thumbnail] skips the network request in tests.
+ @override
+ String thumbnailUrl(int mediaId) => '';
+
+ /// Returns `null` so the player starts from the beginning in tests.
+ @override
+ Future<double?> getMediaProgress(int mediaId) async => null;
+}
+
+/// [PlayerApiClient] stub that delays [listInProgress] until [complete] is called.
+///
+/// Used to inspect mid-flight loading state before the response arrives.
+class _DelayedFakeApiClient extends PlayerApiClient {
+ _DelayedFakeApiClient() : super(dio: Dio());
+
+ final _completer = Completer<List<Media>>();
+
+ /// Resolves the pending [listInProgress] call with [items].
+ void complete(List<Media> items) => _completer.complete(items);
+
+ @override
+ Future<List<Media>> listInProgress() => _completer.future;
+
+ @override
+ String streamUrl(int mediaId) => 'http://fake/stream/$mediaId';
+
+ /// Returns an empty string so [_Thumbnail] skips the network request in tests.
+ @override
+ String thumbnailUrl(int mediaId) => '';
+
+ @override
+ Future<double?> getMediaProgress(int mediaId) async => null;
+}
+
+// ---------------------------------------------------------------------------
+// Sample data
+// ---------------------------------------------------------------------------
+
+/// A video media item.
+const _kVideo = Media(
+ id: 1,
+ setId: 10,
+ relPath: 'movies/film.mp4',
+ fileName: 'film.mp4',
+ absPath: '/media/movies/film.mp4',
+ type: 'video',
+ duration: 7200.0,
+ codec: 'h264/aac',
+ resolution: '1920x1080',
+ bitrate: 4500,
+ fileSizeBytes: 1024,
+ width: 1920,
+ height: 1080,
+ thumbnailPath: '',
+ playCount: 1,
+);
+
+/// An audio media item.
+const _kAudio = Media(
+ id: 2,
+ setId: 11,
+ relPath: 'podcasts/episode.mp3',
+ fileName: 'episode.mp3',
+ absPath: '/media/podcasts/episode.mp3',
+ type: 'audio',
+ duration: 3600.0,
+ codec: 'mp3',
+ resolution: '',
+ bitrate: 128,
+ fileSizeBytes: 512,
+ width: 0,
+ height: 0,
+ thumbnailPath: '',
+ playCount: 1,
+);
+
+// ---------------------------------------------------------------------------
+// Pump helper
+// ---------------------------------------------------------------------------
+
+/// Pumps [ContinueWatchingScreen] inside a [ProviderScope] with overrides.
+///
+/// Captures navigated routes via a [GoRouter] stub so tap tests can assert
+/// the correct player route was pushed. Captured routes are stored in
+/// [_lastNavigatedRoutes] and reset by [setUp] before each test.
+Future<void> _pumpScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ // Track the last navigated location via a GoRouter with a simple observer.
+ final navigatedRoutes = <String>[];
+
+ final router = GoRouter(
+ initialLocation: '/continue',
+ routes: [
+ GoRoute(
+ path: '/continue',
+ builder: (_, __) => const ContinueWatchingScreen(),
+ ),
+ // Stub routes so navigation does not crash during tap tests.
+ GoRoute(
+ path: '/video/:mediaId',
+ builder: (_, state) {<